From ba376b66888221032cdbf57b7d2b6c98ae28966e Mon Sep 17 00:00:00 2001 From: Minimons Date: Fri, 17 Jul 2026 19:32:02 +0200 Subject: [PATCH] 29: Generate an unsigned transaction for increasing a Jupiter Perps position --- .../jupiter/perps/JupiterPerpsService.tjava | 70 ++++- .../AnchorIdlJupiterPerpsServiceImpl.tjava | 278 +++++++++++++++++- .../protocol/ExecuteTransactionRequest.tjava | 14 + .../protocol/ExecuteTransactionResponse.tjava | 14 + .../protocol/IncreasePositionRequest.tjava | 30 ++ .../protocol/IncreasePositionResponse.tjava | 25 ++ .../perps/protocol/TransactionMetadata.tjava | 17 ++ .../solana/SolanaUnsignedTransaction.tjava | 15 + 8 files changed, 455 insertions(+), 8 deletions(-) create mode 100644 src/main/tjava/com/r35157/libs/jupiter/perps/protocol/ExecuteTransactionRequest.tjava create mode 100644 src/main/tjava/com/r35157/libs/jupiter/perps/protocol/ExecuteTransactionResponse.tjava create mode 100644 src/main/tjava/com/r35157/libs/jupiter/perps/protocol/IncreasePositionRequest.tjava create mode 100644 src/main/tjava/com/r35157/libs/jupiter/perps/protocol/IncreasePositionResponse.tjava create mode 100644 src/main/tjava/com/r35157/libs/jupiter/perps/protocol/TransactionMetadata.tjava create mode 100644 src/main/tjava/com/r35157/libs/solana/SolanaUnsignedTransaction.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 b45e2e7..dd72980 100644 --- a/src/main/tjava/com/r35157/libs/jupiter/perps/JupiterPerpsService.tjava +++ b/src/main/tjava/com/r35157/libs/jupiter/perps/JupiterPerpsService.tjava @@ -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. * - *

This service is read-only. It does not open, close, modify, or sign transactions - * for Jupiter Perpetual Contracts.

- * - *

NOTICE: The first supported operation is reading a known Jupiter Perps position account - * and returning its decoded position data.

+ *

This service does not sign transactions. A compatible wallet is + * required to sign a constructed transaction before it can be executed.

*/ public interface JupiterPerpsService { /** @@ -45,4 +46,61 @@ public interface JupiterPerpsService { */ @NotNull Set getOpenPositions(@NotNull ΩSolanaWalletIdΩ owner) throws IOException, InterruptedException; + + /** + * Constructs an unsigned Solana transaction for opening or increasing + * a Jupiter Perps position. + * + *

The transaction is constructed for the supplied wallet, traded asset, + * position direction, USDC collateral amount, position-size delta, and + * slippage limit.

+ * + *

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.

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

The supplied transaction must previously have been constructed by + * {@link #buildPositionIncreaseTransaction} 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-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; } \ 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 818e08e..2f15c47 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 @@ -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 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 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; diff --git a/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/ExecuteTransactionRequest.tjava b/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/ExecuteTransactionRequest.tjava new file mode 100644 index 0000000..856bad4 --- /dev/null +++ b/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/ExecuteTransactionRequest.tjava @@ -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 +) { +} \ No newline at end of file diff --git a/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/ExecuteTransactionResponse.tjava b/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/ExecuteTransactionResponse.tjava new file mode 100644 index 0000000..5b35e18 --- /dev/null +++ b/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/ExecuteTransactionResponse.tjava @@ -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 +) { +} \ No newline at end of file diff --git a/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/IncreasePositionRequest.tjava b/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/IncreasePositionRequest.tjava new file mode 100644 index 0000000..ac3b9fe --- /dev/null +++ b/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/IncreasePositionRequest.tjava @@ -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 +) { +} diff --git a/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/IncreasePositionResponse.tjava b/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/IncreasePositionResponse.tjava new file mode 100644 index 0000000..45ef744 --- /dev/null +++ b/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/IncreasePositionResponse.tjava @@ -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. + * + *

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

+ * + * @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 +) { +} diff --git a/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/TransactionMetadata.tjava b/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/TransactionMetadata.tjava new file mode 100644 index 0000000..7914302 --- /dev/null +++ b/src/main/tjava/com/r35157/libs/jupiter/perps/protocol/TransactionMetadata.tjava @@ -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 +) { +} diff --git a/src/main/tjava/com/r35157/libs/solana/SolanaUnsignedTransaction.tjava b/src/main/tjava/com/r35157/libs/solana/SolanaUnsignedTransaction.tjava new file mode 100644 index 0000000..4612d68 --- /dev/null +++ b/src/main/tjava/com/r35157/libs/solana/SolanaUnsignedTransaction.tjava @@ -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 +) { +} \ No newline at end of file