From 5b50e67d8e9ec6bb8bf64acbbcc774dd9e765795 Mon Sep 17 00:00:00 2001 From: Minimons Date: Fri, 24 Jul 2026 12:12:56 +0200 Subject: [PATCH] 38: Add support for building and executing Jupiter Perps position decrease transactions --- .../jupiter/perps/JupiterPerpsService.tjava | 49 ++++ .../AnchorIdlJupiterPerpsServiceImpl.tjava | 223 +++++++++++++++++- .../protocol/DecreasePositionRequest.tjava | 20 ++ .../protocol/DecreasePositionResponse.tjava | 24 ++ 4 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 src/main/tjava/com/r35157/libs/jupiter/perps/protocol/DecreasePositionRequest.tjava create mode 100644 src/main/tjava/com/r35157/libs/jupiter/perps/protocol/DecreasePositionResponse.tjava diff --git a/src/main/tjava/com/r35157/libs/jupiter/perps/JupiterPerpsService.tjava b/src/main/tjava/com/r35157/libs/jupiter/perps/JupiterPerpsService.tjava index dd72980..e68dd91 100644 --- a/src/main/tjava/com/r35157/libs/jupiter/perps/JupiterPerpsService.tjava +++ b/src/main/tjava/com/r35157/libs/jupiter/perps/JupiterPerpsService.tjava @@ -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. + * + *

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.

+ * + * @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. + * + *

The supplied transaction must previously have been constructed by + * {@link #buildPositionDecreaseTransaction} and signed by its required + * Solana wallet.

+ * + *

Unlike the build method, this operation submits the transaction and + * may therefore modify blockchain state.

+ * + * @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; } \ No newline at end of file diff --git a/src/main/tjava/com/r35157/libs/jupiter/perps/impl/anchoridl/AnchorIdlJupiterPerpsServiceImpl.tjava b/src/main/tjava/com/r35157/libs/jupiter/perps/impl/anchoridl/AnchorIdlJupiterPerpsServiceImpl.tjava index 2f15c47..949dfa5 100644 --- a/src/main/tjava/com/r35157/libs/jupiter/perps/impl/anchoridl/AnchorIdlJupiterPerpsServiceImpl.tjava +++ b/src/main/tjava/com/r35157/libs/jupiter/perps/impl/anchoridl/AnchorIdlJupiterPerpsServiceImpl.tjava @@ -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 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"); diff --git a/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/DecreasePositionRequest.tjava b/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/DecreasePositionRequest.tjava new file mode 100644 index 0000000..99b3835 --- /dev/null +++ b/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/DecreasePositionRequest.tjava @@ -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 +) { +} \ No newline at end of file diff --git a/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/DecreasePositionResponse.tjava b/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/DecreasePositionResponse.tjava new file mode 100644 index 0000000..84f5b50 --- /dev/null +++ b/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/DecreasePositionResponse.tjava @@ -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. + * + *

Additional response fields that are not required by the current + * implementation are ignored during decoding.

+ * + * @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 +) { +} \ No newline at end of file