38: Add support for building and executing Jupiter Perps position decrease transactions

This commit is contained in:
2026-08-05 10:12:39 +02:00
parent d2f28af1bc
commit 5b50e67d8e
4 changed files with 313 additions and 3 deletions
@@ -103,4 +103,53 @@ public interface JupiterPerpsService {
ΩSolanaTransactionSignatureΩ executePositionIncreaseTransaction(
@NotNull SolanaSignedTransaction transaction
) throws IOException, InterruptedException;
/**
* Constructs an unsigned Solana transaction for decreasing
* an existing Jupiter Perps position.
*
* <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 position owner's wallet and submitted separately.</p>
*
* @param positionAccount the Jupiter Perps position to decrease
* @param receiveTokenMint the mint address of the token to receive
* @param sizeUsdDelta the requested decrease 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 buildPositionDecreaseTransaction(
@NotNull ΩJupiterPerpsPositionAccountΩ positionAccount,
@NotNull ΩSPLMintAddressΩ receiveTokenMint,
@NotNull ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) throws IOException, InterruptedException;
/**
* Executes a signed Jupiter Perps position-decrease transaction.
*
* <p>The supplied transaction must previously have been constructed by
* {@link #buildPositionDecreaseTransaction} 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-decrease 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Ω executePositionDecreaseTransaction(
@NotNull SolanaSignedTransaction transaction
) throws IOException, InterruptedException;
}
@@ -4,6 +4,8 @@ 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.DecreasePositionRequest;
import com.r35157.libs.jupiter.perps.protocol.DecreasePositionResponse;
import com.r35157.libs.jupiter.perps.protocol.ExecuteTransactionRequest;
import com.r35157.libs.jupiter.perps.protocol.ExecuteTransactionResponse;
import com.r35157.libs.jupiter.perps.protocol.IncreasePositionRequest;
@@ -193,6 +195,120 @@ public class AnchorIdlJupiterPerpsServiceImpl implements JupiterPerpsService {
return response.txid();
}
@Override
public @NotNull SolanaUnsignedTransaction buildPositionDecreaseTransaction(
@NotNull ΩJupiterPerpsPositionAccountΩ positionAccount,
@NotNull ΩSPLMintAddressΩ receiveTokenMint,
@NotNull ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) throws IOException, InterruptedException {
DecreasePositionRequest request = buildDecreasePositionRequest(
positionAccount,
receiveTokenMint,
sizeUsdDelta,
maxSlippageBps
);
DecreasePositionResponse response =
requestPositionDecrease(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 {
return new SolanaUnsignedTransaction(
response.serializedTxBase64(),
metadata.blockhash(),
Long.parseLong(metadata.lastValidBlockHeight())
);
} catch (NumberFormatException e) {
throw new IOException(
"Invalid lastValidBlockHeight returned by Jupiter: "
+ metadata.lastValidBlockHeight(),
e
);
}
}
@Override
public @NotNull ΩSolanaTransactionSignatureΩ
executePositionDecreaseTransaction(
@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(
"decrease-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 static DecreasePositionRequest buildDecreasePositionRequest(
ΩJupiterPerpsPositionAccountΩ positionAccount,
ΩSPLMintAddressΩ receiveTokenMint,
ΩUSDCAmountΩ sizeUsdDelta,
int maxSlippageBps
) {
if (positionAccount.isBlank()) {
throw new IllegalArgumentException(
"Position account must not be blank"
);
}
if (sizeUsdDelta.signum() <= 0) {
throw new IllegalArgumentException(
"Position size delta must be greater than zero: "
+ sizeUsdDelta
);
}
if (maxSlippageBps < 0 || maxSlippageBps > BASIS_POINTS_DIVISOR) {
throw new IllegalArgumentException(
"Maximum slippage must be between 0 and "
+ BASIS_POINTS_DIVISOR
+ " basis points: "
+ maxSlippageBps
);
}
return new DecreasePositionRequest(
positionAccount,
toProtocolReceiveToken(receiveTokenMint),
toMicroAmount(sizeUsdDelta),
Integer.toString(maxSlippageBps)
);
}
private JupiterPerpsPosition buildPosition(
ΩJupiterPerpsPositionAccountΩ positionAccount,
JupiterPerpsPositionInfo perpsPositionInfo
@@ -415,6 +531,31 @@ public class AnchorIdlJupiterPerpsServiceImpl implements JupiterPerpsService {
);
}
private static String toProtocolReceiveToken(
ΩSPLMintAddressΩ receiveTokenMint
) {
if (SOL_MINT.equals(receiveTokenMint)) {
return "SOL";
}
if (BTC_MINT.equals(receiveTokenMint)) {
return "BTC";
}
if (ETH_MINT.equals(receiveTokenMint)) {
return "ETH";
}
if (USDC_MINT.equals(receiveTokenMint)) {
return "USDC";
}
throw new IllegalArgumentException(
"Unsupported Jupiter Perps receive-token mint: "
+ receiveTokenMint
);
}
private static String toProtocolSide(
JupiterPerpsPositionDirection direction
) {
@@ -488,6 +629,76 @@ public class AnchorIdlJupiterPerpsServiceImpl implements JupiterPerpsService {
return response;
}
private static DecreasePositionResponse requestPositionDecrease(
DecreasePositionRequest request
) throws IOException, InterruptedException {
String requestJson = OBJECT_MAPPER.writeValueAsString(request);
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(DECREASE_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 decrease-position request failed with HTTP "
+ httpResponse.statusCode()
+ ": "
+ httpResponse.body()
);
}
DecreasePositionResponse response = OBJECT_MAPPER.readValue(
httpResponse.body(),
DecreasePositionResponse.class
);
TransactionMetadata metadata = response.txMetadata();
if (metadata == null) {
throw new IOException(
"Jupiter Perps response did not contain transaction metadata"
);
}
if (metadata.blockhash() == null || metadata.blockhash().isBlank()) {
throw new IOException(
"Jupiter Perps response did not contain a blockhash"
);
}
long lastValidBlockHeight;
try {
lastValidBlockHeight =
Long.parseLong(metadata.lastValidBlockHeight());
} catch (NumberFormatException e) {
throw new IOException(
"Invalid lastValidBlockHeight returned by Jupiter: "
+ metadata.lastValidBlockHeight(),
e
);
}
if (lastValidBlockHeight <= 0) {
throw new IOException(
"Invalid lastValidBlockHeight returned by Jupiter: "
+ lastValidBlockHeight
);
}
return response;
}
private static ExecuteTransactionResponse requestTransactionExecution(
ExecuteTransactionRequest request
) throws IOException, InterruptedException {
@@ -523,15 +734,21 @@ public class AnchorIdlJupiterPerpsServiceImpl implements JupiterPerpsService {
);
}
private static final ΩSPLMintAddressΩ SOL_MINT = "So11111111111111111111111111111111111111112";
private static final ΩSPLMintAddressΩ BTC_MINT = "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh";
private static final ΩSPLMintAddressΩ ETH_MINT = "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs";
private static final ΩSPLMintAddressΩ BTC_MINT = "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh";
private static final ΩSPLMintAddressΩ ETH_MINT = "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs";
private static final ΩSPLMintAddressΩ SOL_MINT = "So11111111111111111111111111111111111111112";
private static final ΩSPLMintAddressΩ USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
private static final URI EXECUTE_TRANSACTION_ENDPOINT =
URI.create(
"https://perps-api.jup.ag/v2/transaction/execute"
);
private static final URI DECREASE_POSITION_ENDPOINT =
URI.create(
"https://perps-api.jup.ag/v2/positions/decrease"
);
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");
@@ -0,0 +1,20 @@
package com.r35157.libs.jupiter.perps.protocol;
/**
* Jupiter Perps protocol request for constructing an unsigned
* position-decrease transaction.
*
* @param positionPubkey the Solana account address of the position to decrease
* @param receiveToken the symbol of the token to receive
* @param sizeUsdDelta the requested position-size decrease in micro-USD,
* encoded as a decimal string
* @param maxSlippageBps the maximum accepted slippage in basis points,
* encoded as a decimal string
*/
public record DecreasePositionRequest(
String positionPubkey,
String receiveToken,
String sizeUsdDelta,
String maxSlippageBps
) {
}
@@ -0,0 +1,24 @@
package com.r35157.libs.jupiter.perps.protocol;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* Relevant fields returned by Jupiter after constructing an unsigned
* position-decrease 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 decreased position
* @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 DecreasePositionResponse(
String positionPubkey,
String serializedTxBase64,
TransactionMetadata txMetadata
) {
}