51: Add exact-input Jupiter Swap V2 service

This commit is contained in:
2026-08-11 07:40:15 +02:00
parent 5a096379f2
commit e7f551b8dd
9 changed files with 1127 additions and 0 deletions
@@ -0,0 +1,56 @@
package com.r35157.libs.jupiter.swap;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import org.jetbrains.annotations.NotNull;
import java.math.BigDecimal;
import java.util.Objects;
/**
* Result of a successfully executed Jupiter token swap.
*
* @param transactionSignature confirmed Solana transaction signature
* @param spentInputTokenAmount actual input-token amount deducted from the
* wallet, in human-readable units
* @param receivedOutputTokenAmount actual output-token amount received by the
* wallet, in human-readable units
*/
public record JupiterSwapResult(
@NotNull ΩSolanaTransactionSignatureΩ transactionSignature,
@NotNull ΩAmountΩ spentInputTokenAmount,
@NotNull ΩAmountΩ receivedOutputTokenAmount
) {
/**
* Validates the successful swap result.
*/
public JupiterSwapResult {
Objects.requireNonNull(
transactionSignature,
"transactionSignature"
);
Objects.requireNonNull(
spentInputTokenAmount,
"spentInputTokenAmount"
);
Objects.requireNonNull(
receivedOutputTokenAmount,
"receivedOutputTokenAmount"
);
if (transactionSignature.isBlank()) {
throw new IllegalArgumentException(
"Transaction signature must not be blank"
);
}
if (spentInputTokenAmount.signum() <= 0) {
throw new IllegalArgumentException(
"Spent input-token amount must be greater than zero"
);
}
if (receivedOutputTokenAmount.signum() <= 0) {
throw new IllegalArgumentException(
"Received output-token amount must be greater than zero"
);
}
}
}
@@ -0,0 +1,51 @@
package com.r35157.libs.jupiter.swap;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.math.BigDecimal;
/**
* Exchanges an exact human-readable SPL-token amount through Jupiter.
*
* <p>The service resolves token precision from Solana, obtains and validates a
* Jupiter Swap V2 order, asks its configured wallet to sign the transaction,
* and submits the signed transaction through Jupiter's managed execution
* endpoint.</p>
*/
public interface JupiterSwapService {
/**
* Swaps an exact amount of one SPL token for another SPL token.
*
* <p>The returned amounts are the actual wallet-level amounts reported by
* Jupiter after successful execution, not the quoted amounts. If an error
* or interruption occurs after execution submission, the transaction's
* outcome can be unknown; callers must reload wallet balances before
* deciding whether to initiate another swap.</p>
*
* @param inputTokenMint input SPL-token mint address
* @param inputTokenAmount exact input amount in human-readable token units
* @param outputTokenMint output SPL-token mint address
* @param maxSlippageBps maximum accepted slippage from 0 through 10000
* basis points
* @return confirmed transaction signature and actual amounts spent and
* received
* @throws IllegalArgumentException if a mint, amount, or slippage value is
* invalid
* @throws IllegalStateException if a mint does not exist, is owned by an
* unsupported token program, or has invalid
* metadata
* @throws IOException if Solana or Jupiter communication, response
* decoding, signing, or execution fails
* @throws InterruptedException if order pacing, Solana access, signing, or
* Jupiter communication is interrupted
*/
@NotNull
JupiterSwapResult swap(
@NotNull ΩSPLMintAddressΩ inputTokenMint,
@NotNull ΩAmountΩ inputTokenAmount,
@NotNull ΩSPLMintAddressΩ outputTokenMint,
int maxSlippageBps
) throws IOException, InterruptedException;
}
@@ -0,0 +1,712 @@
package com.r35157.libs.jupiter.swap.impl.ref;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.libs.jupiter.swap.JupiterSwapResult;
import com.r35157.libs.jupiter.swap.JupiterSwapService;
import com.r35157.libs.solana.SPLTokenSupply;
import com.r35157.libs.solana.SolanaAccountInfo;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* Reference implementation of exact-input token swaps through Jupiter Swap
* V2.
*/
public final class JupiterSwapServiceImpl implements JupiterSwapService {
/**
* Creates a Jupiter swap service using the supplied Solana services.
*
* @param solanaBlockChain blockchain access used to resolve mint metadata
* @param solanaWallet wallet used as taker and transaction signer
*/
public JupiterSwapServiceImpl(
@NotNull SolanaBlockChain solanaBlockChain,
@NotNull SolanaWallet solanaWallet
) {
this.solanaBlockChain = Objects.requireNonNull(
solanaBlockChain,
"solanaBlockChain"
);
this.solanaWallet = Objects.requireNonNull(
solanaWallet,
"solanaWallet"
);
}
@Override
public @NotNull JupiterSwapResult swap(
@NotNull ΩSPLMintAddressΩ inputTokenMint,
@NotNull ΩAmountΩ inputTokenAmount,
@NotNull ΩSPLMintAddressΩ outputTokenMint,
int maxSlippageBps
) throws IOException, InterruptedException {
validateArguments(
inputTokenMint,
inputTokenAmount,
outputTokenMint,
maxSlippageBps
);
MintMetadata inputMetadata = resolveMint(inputTokenMint);
MintMetadata outputMetadata = resolveMint(outputTokenMint);
BigInteger rawInputAmount = toRawInputAmount(
inputTokenAmount,
inputMetadata.decimals()
);
ΩSolanaWalletIdΩ taker = solanaWallet.getAddress();
OrderResponse order = requestOrder(
inputTokenMint,
outputTokenMint,
rawInputAmount,
taker,
maxSlippageBps
);
ValidatedOrder validatedOrder = validateOrder(
order,
inputTokenMint,
outputTokenMint,
rawInputAmount,
taker,
maxSlippageBps
);
SolanaUnsignedTransaction unsignedTransaction =
new SolanaUnsignedTransaction(
validatedOrder.transaction(),
null,
validatedOrder.lastValidBlockHeight()
);
SolanaSignedTransaction signedTransaction =
solanaWallet.signTransaction(unsignedTransaction);
if (signedTransaction == null
|| signedTransaction.serializedTransaction() == null
|| signedTransaction.serializedTransaction().isBlank()) {
throw new IOException(
"Solana wallet returned a blank signed Jupiter "
+ "transaction"
);
}
ExecuteResponse execution = executeOnce(
signedTransaction.serializedTransaction(),
validatedOrder.requestId(),
validatedOrder.lastValidBlockHeight()
);
return validateExecution(
execution,
inputMetadata.decimals(),
outputMetadata.decimals()
);
}
private static void validateArguments(
ΩSPLMintAddressΩ inputTokenMint,
BigDecimal inputTokenAmount,
ΩSPLMintAddressΩ outputTokenMint,
int maxSlippageBps
) {
if (inputTokenMint == null || inputTokenMint.isBlank()) {
throw new IllegalArgumentException(
"Input SPL-token mint address must not be blank"
);
}
if (outputTokenMint == null || outputTokenMint.isBlank()) {
throw new IllegalArgumentException(
"Output SPL-token mint address must not be blank"
);
}
if (inputTokenMint.equals(outputTokenMint)) {
throw new IllegalArgumentException(
"Input and output SPL-token mints must be different"
);
}
if (inputTokenAmount == null || inputTokenAmount.signum() <= 0) {
throw new IllegalArgumentException(
"Input token amount must be greater than zero"
);
}
if (maxSlippageBps < 0 || maxSlippageBps > MAX_SLIPPAGE_BPS) {
throw new IllegalArgumentException(
"Maximum slippage must be between 0 and "
+ MAX_SLIPPAGE_BPS
+ " basis points"
);
}
}
private MintMetadata resolveMint(ΩSPLMintAddressΩ mintAddress)
throws IOException, InterruptedException {
SolanaAccountInfo mintAccount = solanaBlockChain.getAccountInfo(
mintAddress
);
if (mintAccount == null) {
throw new IllegalStateException(
"SPL-token mint account does not exist: " + mintAddress
);
}
SolanaSPLTokenProgram tokenProgram = null;
for (SolanaSPLTokenProgram candidate
: SolanaSPLTokenProgram.values()) {
if (candidate.getAddress().equals(mintAccount.owner())) {
tokenProgram = candidate;
break;
}
}
if (tokenProgram == null) {
throw new IllegalStateException(
"SPL-token mint is not owned by a supported token "
+ "program: "
+ mintAddress
+ " (owner "
+ mintAccount.owner()
+ ")"
);
}
SPLTokenSupply supply = solanaBlockChain.getSPLTokenSupply(
mintAddress,
tokenProgram
);
if (supply == null) {
throw new IllegalStateException(
"Solana returned no supply metadata for mint "
+ mintAddress
);
}
if (!mintAddress.equals(supply.mintAddress())) {
throw new IllegalStateException(
"Solana returned supply metadata for an unexpected mint: "
+ supply.mintAddress()
);
}
if (!tokenProgram.getAddress().equals(supply.programId())) {
throw new IllegalStateException(
"Solana returned an unexpected token program for mint "
+ mintAddress
);
}
if (supply.decimals() < 0) {
throw new IllegalStateException(
"Solana returned negative decimal precision for mint "
+ mintAddress
);
}
return new MintMetadata(supply.decimals());
}
private static BigInteger toRawInputAmount(
BigDecimal inputTokenAmount,
int decimals
) {
BigInteger rawAmount;
try {
rawAmount = inputTokenAmount
.movePointRight(decimals)
.toBigIntegerExact();
} catch (ArithmeticException e) {
throw new IllegalArgumentException(
"Input token amount cannot be represented exactly with "
+ decimals
+ " decimal places",
e
);
}
if (rawAmount.signum() <= 0) {
throw new IllegalArgumentException(
"Input token amount is smaller than the mint's smallest "
+ "unit"
);
}
return rawAmount;
}
private OrderResponse requestOrder(
ΩSPLMintAddressΩ inputTokenMint,
ΩSPLMintAddressΩ outputTokenMint,
BigInteger rawInputAmount,
ΩSolanaWalletIdΩ taker,
int maxSlippageBps
) throws IOException, InterruptedException {
URI uri = URI.create(
ORDER_ENDPOINT
+ "?inputMint="
+ encode(inputTokenMint)
+ "&outputMint="
+ encode(outputTokenMint)
+ "&amount="
+ rawInputAmount
+ "&taker="
+ encode(taker)
+ "&swapMode=ExactIn"
+ "&slippageBps="
+ maxSlippageBps
+ "&excludeRouters=jupiterz"
);
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.timeout(HTTP_TIMEOUT)
.header("Accept", "application/json")
.header("x-client-platform", "nenjim-hub")
.GET()
.build();
HttpResponse<String> response;
synchronized (ORDER_REQUEST_GATE) {
awaitOrderRequestPermit();
response = HTTP_CLIENT.send(
request,
HttpResponse.BodyHandlers.ofString()
);
}
requireSuccess("Jupiter order", response);
try {
return OBJECT_MAPPER.readValue(
response.body(),
OrderResponse.class
);
} catch (JsonProcessingException e) {
throw new IOException(
"Unable to decode Jupiter order response: "
+ response.body(),
e
);
}
}
private static ValidatedOrder validateOrder(
OrderResponse order,
ΩSPLMintAddressΩ expectedInputMint,
ΩSPLMintAddressΩ expectedOutputMint,
BigInteger expectedInputAmount,
ΩSolanaWalletIdΩ expectedTaker,
int maxSlippageBps
) throws IOException {
if (order == null) {
throw new IOException("Jupiter returned no order");
}
if (order.errorCode() != null
|| (order.error() != null && !order.error().isBlank())) {
throw new IOException(
"Jupiter could not build the order"
+ optionalError(order)
);
}
if (!expectedInputMint.equals(order.inputMint())) {
throw new IOException(
"Jupiter order input mint does not match the request: "
+ order.inputMint()
);
}
if (!expectedOutputMint.equals(order.outputMint())) {
throw new IOException(
"Jupiter order output mint does not match the request: "
+ order.outputMint()
);
}
if (!"ExactIn".equals(order.swapMode())) {
throw new IOException(
"Jupiter order is not an ExactIn swap: "
+ order.swapMode()
);
}
if (order.taker() == null || order.taker().isBlank()) {
throw new IOException(
"Jupiter order did not contain a taker"
);
}
if (!expectedTaker.equals(order.taker())) {
throw new IOException(
"Jupiter order taker does not match the wallet: "
+ order.taker()
);
}
if (order.slippageBps() == null) {
throw new IOException(
"Jupiter order did not contain slippage basis points"
);
}
if (order.slippageBps() < 0
|| order.slippageBps() > MAX_SLIPPAGE_BPS) {
throw new IOException(
"Jupiter order contained invalid slippage basis points: "
+ order.slippageBps()
);
}
if (order.slippageBps() > maxSlippageBps) {
throw new IOException(
"Jupiter order slippage exceeds the caller's maximum: "
+ order.slippageBps()
+ " > "
+ maxSlippageBps
);
}
BigInteger actualInputAmount = parsePositiveRawAmount(
order.inAmount(),
"Jupiter order input amount"
);
if (!expectedInputAmount.equals(actualInputAmount)) {
throw new IOException(
"Jupiter order input amount does not match the request: "
+ order.inAmount()
);
}
if (order.transaction() == null || order.transaction().isBlank()) {
throw new IOException(
"Jupiter order did not contain an unsigned transaction"
+ optionalError(order)
);
}
validateUnsignedTransaction(order.transaction());
if (order.requestId() == null || order.requestId().isBlank()) {
throw new IOException(
"Jupiter order did not contain a request ID"
);
}
long lastValidBlockHeight;
try {
lastValidBlockHeight = Long.parseLong(
order.lastValidBlockHeight()
);
} catch (NumberFormatException | NullPointerException e) {
throw new IOException(
"Jupiter order contained an invalid last valid block "
+ "height: "
+ order.lastValidBlockHeight(),
e
);
}
if (lastValidBlockHeight <= 0) {
throw new IOException(
"Jupiter order contained a non-positive last valid block "
+ "height: "
+ lastValidBlockHeight
);
}
return new ValidatedOrder(
order.transaction(),
order.requestId(),
lastValidBlockHeight
);
}
private static void validateUnsignedTransaction(
ΩBase64StringΩ transaction
) throws IOException {
byte[] decodedTransaction;
try {
decodedTransaction = Base64.getDecoder().decode(transaction);
} catch (IllegalArgumentException e) {
throw new IOException(
"Jupiter order contained an invalid Base64 unsigned "
+ "transaction",
e
);
}
if (decodedTransaction.length == 0) {
throw new IOException(
"Jupiter order unsigned transaction decoded to zero bytes"
);
}
}
private static ExecuteResponse executeOnce(
ΩBase64StringΩ signedTransaction,
String requestId,
long lastValidBlockHeight
) throws IOException, InterruptedException {
ExecuteRequest requestBody = new ExecuteRequest(
signedTransaction,
requestId,
Long.toString(lastValidBlockHeight)
);
String json = OBJECT_MAPPER.writeValueAsString(requestBody);
HttpRequest request = HttpRequest.newBuilder()
.uri(EXECUTE_ENDPOINT)
.timeout(HTTP_TIMEOUT)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("x-client-platform", "nenjim-hub")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response;
try {
response = HTTP_CLIENT.send(
request,
HttpResponse.BodyHandlers.ofString()
);
} catch (InterruptedException e) {
InterruptedException failure = new InterruptedException(
"Jupiter execution was interrupted after submission; "
+ "the swap outcome is unknown and wallet "
+ "balances must be reloaded before another swap"
);
failure.initCause(e);
throw failure;
} catch (IOException e) {
throw new IOException(
"Jupiter execution failed after submission; the swap "
+ "outcome is unknown and wallet balances must be "
+ "reloaded before another swap",
e
);
}
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IOException(
"Jupiter execution returned HTTP "
+ response.statusCode()
+ " after submission; the swap outcome may be "
+ "unknown: "
+ response.body()
);
}
try {
return OBJECT_MAPPER.readValue(
response.body(),
ExecuteResponse.class
);
} catch (JsonProcessingException e) {
throw new IOException(
"Unable to decode Jupiter execution response after "
+ "submission; the swap outcome may be unknown: "
+ response.body(),
e
);
}
}
private static JupiterSwapResult validateExecution(
ExecuteResponse execution,
int inputDecimals,
int outputDecimals
) throws IOException {
if (execution == null) {
throw new IOException(
"Jupiter returned no execution result after submission"
);
}
if (!"Success".equals(execution.status())) {
throw new IOException(
"Jupiter execution failed"
+ executionFailureDetails(execution)
);
}
if (execution.code() == null) {
throw new IOException(
"Successful Jupiter execution did not contain a result "
+ "code"
);
}
if (execution.code() != 0) {
throw new IOException(
"Jupiter execution returned Success with a non-zero "
+ "result code"
+ executionFailureDetails(execution)
);
}
if (execution.signature() == null
|| execution.signature().isBlank()) {
throw new IOException(
"Successful Jupiter execution did not contain a "
+ "transaction signature"
);
}
BigInteger spentRaw = parsePositiveRawAmount(
execution.totalInputAmount(),
"Jupiter execution total input amount"
);
BigInteger receivedRaw = parsePositiveRawAmount(
execution.totalOutputAmount(),
"Jupiter execution total output amount"
);
return new JupiterSwapResult(
execution.signature(),
new BigDecimal(spentRaw).movePointLeft(inputDecimals),
new BigDecimal(receivedRaw).movePointLeft(outputDecimals)
);
}
private static BigInteger parsePositiveRawAmount(
String value,
String description
) throws IOException {
BigInteger amount;
try {
amount = new BigInteger(value);
} catch (NumberFormatException | NullPointerException e) {
throw new IOException(
description + " is invalid: " + value,
e
);
}
if (amount.signum() <= 0) {
throw new IOException(
description + " must be greater than zero: " + value
);
}
return amount;
}
private static void requireSuccess(
String operation,
HttpResponse<String> response
) throws IOException {
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IOException(
operation
+ " request failed with HTTP "
+ response.statusCode()
+ ": "
+ response.body()
);
}
}
private static String optionalError(OrderResponse order) {
StringBuilder detail = new StringBuilder();
if (order.errorCode() != null) {
detail.append(" (error code ").append(order.errorCode()).append(')');
}
if (order.errorMessage() != null && !order.errorMessage().isBlank()) {
detail.append(": ").append(order.errorMessage());
} else if (order.error() != null && !order.error().isBlank()) {
detail.append(": ").append(order.error());
}
return detail.toString();
}
private static String executionFailureDetails(ExecuteResponse response) {
StringBuilder detail = new StringBuilder();
if (response.code() != null) {
detail.append(" (code ").append(response.code()).append(')');
}
if (response.error() != null && !response.error().isBlank()) {
detail.append(": ").append(response.error());
} else if (response.status() != null && !response.status().isBlank()) {
detail.append(": status ").append(response.status());
}
return detail.toString();
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
private static void awaitOrderRequestPermit()
throws InterruptedException {
while (lastOrderRequestStartNanos != NO_REQUEST_YET) {
long elapsed = System.nanoTime()
- lastOrderRequestStartNanos;
long remaining = ORDER_INTERVAL_NANOS - elapsed;
if (remaining <= 0) {
break;
}
TimeUnit.NANOSECONDS.sleep(remaining);
}
lastOrderRequestStartNanos = System.nanoTime();
}
private record MintMetadata(int decimals) {
}
private record ValidatedOrder(
ΩBase64StringΩ transaction,
String requestId,
long lastValidBlockHeight
) {
}
private record OrderResponse(
String inputMint,
String outputMint,
String inAmount,
String swapMode,
String taker,
Integer slippageBps,
ΩBase64StringΩ transaction,
String requestId,
String lastValidBlockHeight,
Integer errorCode,
String errorMessage,
String error
) {
}
private record ExecuteRequest(
ΩBase64StringΩ signedTransaction,
String requestId,
String lastValidBlockHeight
) {
}
private record ExecuteResponse(
String status,
ΩSolanaTransactionSignatureΩ signature,
String totalInputAmount,
String totalOutputAmount,
Integer code,
String error
) {
}
private static final URI ORDER_ENDPOINT = URI.create(
"https://api.jup.ag/swap/v2/order"
);
private static final URI EXECUTE_ENDPOINT = URI.create(
"https://api.jup.ag/swap/v2/execute"
);
private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(30);
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(HTTP_TIMEOUT)
.build();
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false
);
private static final Object ORDER_REQUEST_GATE = new Object();
private static final long ORDER_INTERVAL_NANOS =
TimeUnit.SECONDS.toNanos(2);
private static final long NO_REQUEST_YET = Long.MIN_VALUE;
private static final int MAX_SLIPPAGE_BPS = 10_000;
private static long lastOrderRequestStartNanos = NO_REQUEST_YET;
private final SolanaBlockChain solanaBlockChain;
private final SolanaWallet solanaWallet;
}