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(); + + HttpResponseAdditional 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