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