29: Generate an unsigned transaction for increasing a Jupiter Perps position
This commit is contained in:
@@ -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.
|
||||
*
|
||||
* <p>This service is read-only. It does not open, close, modify, or sign transactions
|
||||
* for Jupiter Perpetual Contracts.</p>
|
||||
*
|
||||
* <p>NOTICE: The first supported operation is reading a known Jupiter Perps position account
|
||||
* and returning its decoded position data.</p>
|
||||
* <p>This service does not sign transactions. A compatible wallet is
|
||||
* required to sign a constructed transaction before it can be executed.</p>
|
||||
*/
|
||||
public interface JupiterPerpsService {
|
||||
/**
|
||||
@@ -45,4 +46,61 @@ public interface JupiterPerpsService {
|
||||
*/
|
||||
@NotNull Set<JupiterPerpsPosition> getOpenPositions(@NotNull ΩSolanaWalletIdΩ owner)
|
||||
throws IOException, InterruptedException;
|
||||
|
||||
/**
|
||||
* Constructs an unsigned Solana transaction for opening or increasing
|
||||
* a Jupiter Perps position.
|
||||
*
|
||||
* <p>The transaction is constructed for the supplied wallet, traded asset,
|
||||
* position direction, USDC collateral amount, position-size delta, and
|
||||
* slippage limit.</p>
|
||||
*
|
||||
* <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 supplied wallet and submitted separately.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>The supplied transaction must previously have been constructed by
|
||||
* {@link #buildPositionIncreaseTransaction} 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-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;
|
||||
}
|
||||
+276
-2
@@ -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<String> 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<String> 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;
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
+14
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <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 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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
Reference in New Issue
Block a user