29: Generate an unsigned transaction for increasing a Jupiter Perps position

This commit is contained in:
2026-08-05 10:12:38 +02:00
parent a0d0740f10
commit ba376b6688
8 changed files with 455 additions and 8 deletions
@@ -1,19 +1,20 @@
package com.r35157.libs.jupiter.perps;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.Set;
/**
* Service for reading Jupiter Perps data.
* Service for reading Jupiter Perps data and constructing and executing
* Jupiter Perps transactions.
*
* <p>This service is read-only. It does not open, close, modify, or sign transactions
* for Jupiter Perpetual Contracts.</p>
*
* <p>NOTICE: The first supported operation is reading a known Jupiter Perps position account
* and returning its decoded position data.</p>
* <p>This service does not sign transactions. A compatible wallet is
* required to sign a constructed transaction before it can be executed.</p>
*/
public interface JupiterPerpsService {
/**
@@ -45,4 +46,61 @@ public interface JupiterPerpsService {
*/
@NotNull Set<JupiterPerpsPosition> getOpenPositions(@NotNull ΩSolanaWalletIdΩ owner)
throws IOException, InterruptedException;
/**
* Constructs an unsigned Solana transaction for opening or increasing
* a Jupiter Perps position.
*
* <p>The transaction is constructed for the supplied wallet, traded asset,
* position direction, USDC collateral amount, position-size delta, and
* slippage limit.</p>
*
* <p>This method does not sign or submit the transaction and therefore does
* not modify blockchain state. The returned transaction must be signed by
* the supplied wallet and submitted separately.</p>
*
* @param owner the wallet that owns or will own the position
* @param tradedTokenMint the mint address of the asset being traded
* @param direction whether the position is long or short
* @param inputTokenAmount the amount of USDC to supply as collateral
* @param sizeUsdDelta the requested increase in position size, denominated in USD
* @param maxSlippageBps the maximum accepted slippage, in basis points
* @return the complete unsigned Solana transaction
* @throws IllegalArgumentException if an amount, mint, or slippage value is invalid
* @throws IOException if Jupiter cannot construct the transaction or its response
* cannot be decoded
* @throws InterruptedException if the calling thread is interrupted while
* communicating with Jupiter
*/
@NotNull SolanaUnsignedTransaction buildPositionIncreaseTransaction(
@NotNull ΩSolanaWalletIdΩ owner,
@NotNull ΩSPLMintAddressΩ tradedTokenMint,
@NotNull JupiterPerpsPositionDirection direction,
@NotNull ΩUSDCAmountΩ inputTokenAmount,
@NotNull ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) throws IOException, InterruptedException;
/**
* Executes a signed Jupiter Perps position-increase transaction.
*
* <p>The supplied transaction must previously have been constructed by
* {@link #buildPositionIncreaseTransaction} and signed by its required
* Solana wallet.</p>
*
* <p>Unlike the build method, this operation submits the transaction and
* may therefore modify blockchain state.</p>
*
* @param transaction the complete signed position-increase transaction
* @return the Solana transaction signature
* @throws IllegalArgumentException if the transaction is missing or blank
* @throws IOException if Jupiter rejects the transaction or its response
* cannot be decoded
* @throws InterruptedException if the calling thread is interrupted while
* communicating with Jupiter
*/
@NotNull
ΩSolanaTransactionSignatureΩ executePositionIncreaseTransaction(
@NotNull SolanaSignedTransaction transaction
) throws IOException, InterruptedException;
}
@@ -1,11 +1,19 @@
package com.r35157.libs.jupiter.perps.impl.anchoridl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.r35157.libs.jupiter.perps.JupiterPerpsPosition;
import com.r35157.libs.jupiter.perps.JupiterPerpsPositionDirection;
import com.r35157.libs.jupiter.perps.JupiterPerpsService;
import com.r35157.libs.jupiter.perps.protocol.ExecuteTransactionRequest;
import com.r35157.libs.jupiter.perps.protocol.ExecuteTransactionResponse;
import com.r35157.libs.jupiter.perps.protocol.IncreasePositionRequest;
import com.r35157.libs.jupiter.perps.protocol.IncreasePositionResponse;
import com.r35157.libs.jupiter.perps.protocol.TransactionMetadata;
import com.r35157.libs.solana.SolanaAccountInfo;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.SolanaProgramAccountMemcmpFilter;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import com.r35157.libs.valuetypes.basic.WellKnownCurrencyTypes;
import org.jetbrains.annotations.NotNull;
@@ -15,6 +23,10 @@ import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.HashSet;
import java.util.Set;
@@ -99,6 +111,88 @@ public class AnchorIdlJupiterPerpsServiceImpl implements JupiterPerpsService {
return Set.copyOf(positions);
}
@Override
public @NotNull SolanaUnsignedTransaction buildPositionIncreaseTransaction(
@NotNull ΩSolanaWalletIdΩ owner,
@NotNull ΩSPLMintAddressΩ tradedTokenMint,
@NotNull JupiterPerpsPositionDirection direction,
@NotNull ΩUSDCAmountΩ inputTokenAmount,
@NotNull ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) throws IOException, InterruptedException {
IncreasePositionRequest request = buildIncreasePositionRequest(
owner,
tradedTokenMint,
direction,
inputTokenAmount,
sizeUsdDelta,
maxSlippageBps
);
IncreasePositionResponse response = requestPositionIncrease(request);
TransactionMetadata metadata = response.txMetadata();
if (response.serializedTxBase64() == null
|| response.serializedTxBase64().isBlank()) {
throw new IOException(
"Jupiter Perps response did not contain a serialized transaction"
);
}
if (metadata == null) {
throw new IOException(
"Jupiter Perps response did not contain transaction metadata"
);
}
try {
SolanaUnsignedTransaction utx = new SolanaUnsignedTransaction(
response.serializedTxBase64(),
metadata.blockhash(),
Long.parseLong(metadata.lastValidBlockHeight())
);
return utx;
} catch (NumberFormatException e) {
throw new IOException(
"Invalid lastValidBlockHeight returned by Jupiter: "
+ metadata.lastValidBlockHeight(),
e
);
}
}
@Override
public @NotNull ΩSolanaTransactionSignatureΩ
executePositionIncreaseTransaction(
@NotNull SolanaSignedTransaction transaction
) throws IOException, InterruptedException {
if (transaction.serializedTransaction() == null
|| transaction.serializedTransaction().isBlank()) {
throw new IllegalArgumentException(
"Signed transaction must not be blank"
);
}
ExecuteTransactionRequest request =
new ExecuteTransactionRequest(
"increase-position",
transaction.serializedTransaction()
);
ExecuteTransactionResponse response =
requestTransactionExecution(request);
if (response.txid() == null || response.txid().isBlank()) {
throw new IOException(
"Jupiter transaction execution response did not "
+ "contain a transaction signature"
);
}
return response.txid();
}
private JupiterPerpsPosition buildPosition(
ΩJupiterPerpsPositionAccountΩ positionAccount,
JupiterPerpsPositionInfo perpsPositionInfo
@@ -261,11 +355,191 @@ public class AnchorIdlJupiterPerpsServiceImpl implements JupiterPerpsService {
throw new IllegalArgumentException("Unsupported Jupiter Perps position direction: " + direction);
}
private static IncreasePositionRequest buildIncreasePositionRequest(
ΩSolanaWalletIdΩ owner,
ΩSPLMintAddressΩ tradedTokenMint,
JupiterPerpsPositionDirection direction,
ΩUSDCAmountΩ inputTokenAmount,
ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) {
if (inputTokenAmount.signum() <= 0) {
throw new IllegalArgumentException(
"Input token amount must be greater than zero: " + inputTokenAmount
);
}
if (sizeUsdDelta.signum() < 0) {
throw new IllegalArgumentException(
"Position size delta must not be negative: " + sizeUsdDelta
);
}
if (maxSlippageBps < 0 || maxSlippageBps > BASIS_POINTS_DIVISOR) {
throw new IllegalArgumentException(
"Maximum slippage must be between 0 and "
+ BASIS_POINTS_DIVISOR
+ " basis points: "
+ maxSlippageBps
);
}
IncreasePositionRequest request = new IncreasePositionRequest(
toProtocolAsset(tradedTokenMint),
"USDC",
toMicroAmount(inputTokenAmount),
toProtocolSide(direction),
Integer.toString(maxSlippageBps),
toMicroAmount(sizeUsdDelta),
owner
);
return request;
}
private static String toProtocolAsset(ΩSPLMintAddressΩ tradedTokenMint) {
if (SOL_MINT.equals(tradedTokenMint)) {
return "SOL";
}
if (BTC_MINT.equals(tradedTokenMint)) {
return "BTC";
}
if (ETH_MINT.equals(tradedTokenMint)) {
return "ETH";
}
throw new IllegalArgumentException(
"Unsupported Jupiter Perps traded-token mint: " + tradedTokenMint
);
}
private static String toProtocolSide(
JupiterPerpsPositionDirection direction
) {
return switch (direction) {
case LONG -> "long";
case SHORT -> "short";
};
}
private static String toMicroAmount(BigDecimal amount) {
return amount
.movePointRight(USDC_DECIMALS)
.toBigIntegerExact()
.toString();
}
private static IncreasePositionResponse requestPositionIncrease(
IncreasePositionRequest request
) throws IOException, InterruptedException {
String requestJson = OBJECT_MAPPER.writeValueAsString(request);
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(INCREASE_POSITION_ENDPOINT)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("x-client-platform", "nenjim-hub")
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
.build();
HttpResponse<String> httpResponse = HTTP_CLIENT.send(
httpRequest,
HttpResponse.BodyHandlers.ofString()
);
if (httpResponse.statusCode() < 200 || httpResponse.statusCode() >= 300) {
throw new IOException(
"Jupiter Perps increase-position request failed with HTTP "
+ httpResponse.statusCode()
+ ": "
+ httpResponse.body()
);
}
IncreasePositionResponse response = OBJECT_MAPPER.readValue(
httpResponse.body(),
IncreasePositionResponse.class
);
TransactionMetadata metadata = response.txMetadata();
if (metadata.blockhash() == null || metadata.blockhash().isBlank()) {
String errorMsg = "Jupiter Perps response did not contain a blockhash";
throw new IOException(errorMsg);
}
long lastValidBlockHeight;
try {
lastValidBlockHeight =
Long.parseLong(metadata.lastValidBlockHeight());
} catch (NumberFormatException e) {
String errorMsg = "Invalid lastValidBlockHeight returned by Jupiter: " + metadata.lastValidBlockHeight();
throw new IOException(errorMsg, e);
}
if (lastValidBlockHeight <= 0) {
String errorMsg = "Invalid lastValidBlockHeight returned by Jupiter: " + lastValidBlockHeight;
throw new IOException(errorMsg);
}
return response;
}
private static ExecuteTransactionResponse requestTransactionExecution(
ExecuteTransactionRequest request
) throws IOException, InterruptedException {
String requestJson =
OBJECT_MAPPER.writeValueAsString(request);
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(EXECUTE_TRANSACTION_ENDPOINT)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("x-client-platform", "nenjim-hub")
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
.build();
HttpResponse<String> httpResponse = HTTP_CLIENT.send(
httpRequest,
HttpResponse.BodyHandlers.ofString()
);
if (httpResponse.statusCode() < 200
|| httpResponse.statusCode() >= 300) {
throw new IOException(
"Jupiter transaction execution failed with HTTP "
+ httpResponse.statusCode()
+ ": "
+ httpResponse.body()
);
}
return OBJECT_MAPPER.readValue(
httpResponse.body(),
ExecuteTransactionResponse.class
);
}
private static final ΩSPLMintAddressΩ SOL_MINT = "So11111111111111111111111111111111111111112";
private static final ΩSPLMintAddressΩ BTC_MINT = "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh";
private static final ΩSPLMintAddressΩ ETH_MINT = "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs";
private static final URI EXECUTE_TRANSACTION_ENDPOINT =
URI.create(
"https://perps-api.jup.ag/v2/transaction/execute"
);
private static final ΩJupiterPerpsProgramIdΩ JUPITER_PERPS_PROGRAM_ID = "PERPHjGBqRHArX4DySjwM6UJHiR3sWAatqfdBS2qQJu";
private static final URI INCREASE_POSITION_ENDPOINT = URI.create("https://perps-api.jup.ag/v2/positions/increase");
private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient();
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final long RATE_POWER = 1_000_000_000L;
private static final int CLOSE_FEE_BPS = 6;
private static final int BASIS_POINTS_DIVISOR = 10_000;
private static final ΩJupiterPerpsProgramIdΩ JUPITER_PERPS_PROGRAM_ID =
"PERPHjGBqRHArX4DySjwM6UJHiR3sWAatqfdBS2qQJu";
private static final int POSITION_OWNER_OFFSET = 8;
private static final int MAX_LEVERAGE = 500;
private static final int USDC_DECIMALS = 6;
@@ -0,0 +1,14 @@
package com.r35157.libs.jupiter.perps.protocol;
/**
* Jupiter Perps protocol request for executing a signed transaction.
*
* @param action the Jupiter transaction action
* @param serializedTxBase64 the complete signed Solana transaction,
* encoded as Base64
*/
public record ExecuteTransactionRequest(
String action,
String serializedTxBase64
) {
}
@@ -0,0 +1,14 @@
package com.r35157.libs.jupiter.perps.protocol;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* Relevant fields returned by Jupiter after executing a transaction.
*
* @param txid the Solana transaction signature
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ExecuteTransactionResponse(
String txid
) {
}
@@ -0,0 +1,30 @@
package com.r35157.libs.jupiter.perps.protocol;
/**
* Jupiter Perps protocol request for constructing an unsigned
* position-increase transaction.
*
* @param asset the Jupiter Perps market symbol, such as {@code SOL},
* {@code BTC}, or {@code ETH}
* @param inputToken the symbol of the token supplied as collateral,
* currently {@code USDC}
* @param inputTokenAmount the input-token amount in raw chain units,
* encoded as a decimal string
* @param side the position direction in Jupiter protocol format:
* {@code long} or {@code short}
* @param maxSlippageBps the maximum accepted slippage in basis points,
* encoded as a decimal string
* @param sizeUsdDelta the requested position-size increase in micro-USD,
* encoded as a decimal string
* @param walletAddress the Solana wallet that owns or will own the position
*/
public record IncreasePositionRequest(
String asset,
String inputToken,
String inputTokenAmount,
String side,
String maxSlippageBps,
String sizeUsdDelta,
String walletAddress
) {
}
@@ -0,0 +1,25 @@
package com.r35157.libs.jupiter.perps.protocol;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* Relevant fields returned by Jupiter after constructing an unsigned
* position-increase transaction.
*
* <p>Additional response fields that are not required by the current
* implementation are ignored during decoding.</p>
*
* @param positionPubkey the Solana account address of the position that
* will be opened or increased
* @param serializedTxBase64 the complete unsigned Solana transaction,
* encoded as Base64
* @param txMetadata metadata describing the transaction's blockhash
* and validity period
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record IncreasePositionResponse(
String positionPubkey,
String serializedTxBase64,
TransactionMetadata txMetadata
) {
}
@@ -0,0 +1,17 @@
package com.r35157.libs.jupiter.perps.protocol;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* Validity metadata for a Solana transaction constructed by Jupiter.
*
* @param blockhash the recent Solana blockhash embedded in the transaction
* @param lastValidBlockHeight the last block height at which the transaction
* may be processed, encoded as a decimal string
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record TransactionMetadata(
String blockhash,
String lastValidBlockHeight
) {
}
@@ -0,0 +1,15 @@
package com.r35157.libs.solana;
/**
* A serialized Solana transaction that has not yet been signed or submitted.
*
* @param serializedTransaction the complete Base64 encoded Solana transaction
* @param blockhash the recent blockhash used by the transaction
* @param lastValidBlockHeight the last block height at which the transaction is valid
*/
public record SolanaUnsignedTransaction(
ΩBase64StringΩ serializedTransaction,
ΩSolanaBlockhashΩ blockhash,
long lastValidBlockHeight
) {
}