91: Reconcile timed-out Evelyn IOU burner swaps in later iterations

This commit is contained in:
2026-10-06 07:47:34 +02:00
parent adb5f0235a
commit 152137ed87
25 changed files with 1057 additions and 184 deletions
@@ -29,7 +29,11 @@ import java.util.List;
* iteration; definitive failure is logged without notification; success
* receives the burn's single notification attempt. No new swap or burn is
* submitted in an iteration that begins with a pending burn.</li>
* <li>Unless swaps are suspended, inspect accepted currencies in priority
* <li>After pending-burn priority, reconcile any pending swap read-only by
* its original signature at CONFIRMED or stronger. Unresolved swaps block
* new swaps but still permit EVE burns. Definitive success/failure or proven
* non-executed expiry clears pending state before reading fresh balances.</li>
* <li>Unless swaps are suspended or pending, inspect accepted currencies in priority
* order. Canonicalize each AssetAZ UUID, resolve its native-SOL or SPL
* representation internally and apply its own minimum reserve without an
* input-price lookup. Missing or unusable candidate data does not
@@ -40,9 +44,11 @@ import java.util.List;
* estimated when applying its reserve.</li>
* <li>Attempt no more than one exact-input Jupiter swap. Normal return is
* already {@code CONFIRMED}; it is not awaited a second time. A definitive
* failure permits a later iteration to try again, while a timeout or other
* potentially unknown post-submission result suspends swaps for this
* lifecycle.</li>
* failure or proven pre-submission error permits a later iteration to try
* again. A known-signature timeout or communication error retains pending
* context for later scheduled checks. Unknown submission without a
* trustworthy signature suspends swaps for manual reconciliation. No
* unknown swap is rebuilt, re-signed or resubmitted automatically.</li>
* <li>Reload the complete visible Evelyn IOU balance, including tokens
* deposited or acquired before this iteration. Absence or failure of a
* swap does not suppress this step, and the input buyback limit never caps
@@ -9,11 +9,16 @@ import com.r35157.assetaz.valuetypes.CurrencyType;
import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.libs.jupiter.JupiterTransactionOutcomeException;
import com.r35157.libs.jupiter.swap.JupiterSwapService;
import com.r35157.libs.jupiter.swap.JupiterSwapSubmissionException;
import com.r35157.libs.jupiter.swap.JupiterSwapInterruptedException;
import com.r35157.libs.solana.SPLTokenSupply;
import com.r35157.libs.solana.SolanaAccountInfo;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.SolanaCommitment;
import com.r35157.libs.solana.SolanaTransactionOutcome;
import com.r35157.libs.solana.SolanaTransactionReconciliation;
import com.r35157.libs.solana.SolanaTransactionValidity;
import com.r35157.libs.solana.SolanaReconciliationException;
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
import com.r35157.libs.valuetypes.basic.AssetPrice;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
@@ -271,7 +276,10 @@ public final class EvelynIOUBurnerServiceImpl
return;
}
if (!swapsSuspended) {
if (pendingSwap != null) {
reconcilePendingSwap();
}
if (!swapsSuspended && pendingSwap == null) {
attemptAtMostOneSwap();
}
processCompleteEveBalance();
@@ -396,61 +404,94 @@ public final class EvelynIOUBurnerServiceImpl
ΩAmountΩ amountToSwap
) throws InterruptedException {
try {
swapService.swap(
inputMint,
amountToSwap,
EVELYN_IOU_MINT,
maximumSlippageBps
);
swapService.swap(inputMint, amountToSwap, EVELYN_IOU_MINT, maximumSlippageBps);
} catch (JupiterTransactionOutcomeException exception) {
SolanaTransactionOutcome outcome = exception.outcome();
switch (outcome.status()) {
case FAILED -> log.error(
"Evelyn IOU buyback swap failed on-chain: "
+ "currencyId={}, signature={}, slot={}, "
+ "failureDetails={}",
canonicalCurrency.id(),
exception.transactionSignature(),
outcome.slot(),
outcome.failureDetails()
);
case TIMED_OUT -> {
swapsSuspended = true;
log.error(
"Evelyn IOU buyback swap outcome is unknown after "
+ "timeout; later swaps are suspended: "
+ "currencyId={}, signature={}",
canonicalCurrency.id(),
exception.transactionSignature()
);
}
case SUCCEEDED -> throw new IllegalStateException(
"A successful Jupiter outcome was reported as an "
+ "exception"
);
"Evelyn IOU buyback failed on-chain: currency={}/{}, amount={}, signature={}, slot={}, failureDetails={}; nextAction=fresh selection next iteration",
canonicalCurrency.symbol(), canonicalCurrency.id(), amountToSwap,
exception.transactionSignature(), outcome.slot(), outcome.failureDetails());
case TIMED_OUT -> retainUnknownSwap(canonicalCurrency, amountToSwap,
exception.transactionSignature(), exception.validity(), "confirmation timeout; outcome unknown", exception);
case SUCCEEDED -> throw new IllegalStateException("A successful Jupiter outcome was reported as an exception");
}
} catch (JupiterSwapSubmissionException exception) {
if (exception.submissionMayHaveOccurred()) {
retainUnknownSwap(canonicalCurrency, amountToSwap, exception.transactionSignature(),
exception.validity(), exception.diagnostic(), exception);
} else {
log.warn("Evelyn IOU buyback failed before submission: currency={}/{}, amount={}, phase={}, causeTypes={}; nextAction=fresh selection next iteration",
canonicalCurrency.symbol(), canonicalCurrency.id(), amountToSwap,
exception.diagnostic(), safeCauseTypes(exception));
}
} catch (JupiterSwapInterruptedException exception) {
if (exception.submissionMayHaveOccurred()) {
retainUnknownSwap(canonicalCurrency, amountToSwap, exception.transactionSignature(),
exception.validity(), "post-submission interruption; outcome unknown", exception);
}
throw exception;
} catch (IOException exception) {
swapsSuspended = true;
log.error(
"Evelyn IOU buyback swap may have an unknown outcome; "
+ "later swaps are suspended: currencyId={}, "
+ "failureType={}",
canonicalCurrency.id(),
exception.getClass().getName()
);
retainUnknownSwap(canonicalCurrency, amountToSwap, null, null,
"unclassified I/O; submission outcome unknown", exception);
} catch (InterruptedException exception) {
swapsSuspended = true;
retainUnknownSwap(canonicalCurrency, amountToSwap, null, null,
"unclassified interruption; submission outcome unknown", exception);
throw exception;
} catch (RuntimeException exception) {
log.error(
"Evelyn IOU buyback swap failed before a confirmed "
+ "result: currencyId={}, failureType={}",
canonicalCurrency.id(),
exception.getClass().getName()
);
retainUnknownSwap(canonicalCurrency, amountToSwap, null, null,
"unclassified runtime failure; submission outcome unknown", exception);
}
}
private void retainUnknownSwap(CurrencyType currency, ΩAmountΩ amount,
ΩSolanaTransactionSignatureΩ signature, SolanaTransactionValidity validity,
String phase, Throwable cause) {
if (signature != null && !signature.isBlank()) {
pendingSwap = new PendingSwap(signature, currency, amount, validity);
log.warn("Evelyn IOU buyback outcome unknown: currency={}/{}, amount={}, signature={}, phase={}, causeTypes={}; nextAction=reconcile same signature next iteration",
currency.symbol(), currency.id(), amount, signature, phase, safeCauseTypes(cause));
} else {
swapsSuspended = true;
log.error("Evelyn IOU buyback outcome unknown without trustworthy signature: currency={}/{}, amount={}, phase={}, causeTypes={}; nextAction=manual reconciliation; new swaps suspended",
currency.symbol(), currency.id(), amount, phase, safeCauseTypes(cause));
}
}
private void reconcilePendingSwap() throws InterruptedException {
PendingSwap swap = pendingSwap;
try {
SolanaTransactionReconciliation result = solanaBlockChain.reconcileTransaction(
swap.transactionSignature(), swap.validity(), SolanaCommitment.CONFIRMED,
PENDING_SWAP_RECONCILIATION_TIMEOUT);
if (result.status() == SolanaTransactionReconciliation.Status.UNRESOLVED) {
log.warn("Evelyn IOU buyback remains pending: currency={}/{}, amount={}, signature={}, status={}, detail={}; nextAction=recheck next iteration; EVE burns permitted",
swap.currency().symbol(), swap.currency().id(), swap.amount(),
swap.transactionSignature(), result.status(), result.detail());
return;
}
pendingSwap = null;
log.info("Evelyn IOU pending buyback resolved: currency={}/{}, amount={}, signature={}, status={}, slot={}, failureDetails={}, detail={}; nextAction=fresh balance selection",
swap.currency().symbol(), swap.currency().id(), swap.amount(), swap.transactionSignature(), result.status(),
result.outcome() == null ? null : result.outcome().slot(),
result.outcome() == null ? null : result.outcome().failureDetails(), result.detail());
} catch (IOException | RuntimeException exception) {
log.warn("Evelyn IOU buyback reconciliation remains unknown: currency={}/{}, amount={}, signature={}, diagnostic={}, causeTypes={}; nextAction=recheck next iteration; EVE burns permitted",
swap.currency().symbol(), swap.currency().id(), swap.amount(), swap.transactionSignature(),
exception instanceof SolanaReconciliationException safe ? safe.safeDiagnostic() : "communication/protocol failure",
safeCauseTypes(exception));
}
}
private static String safeCauseTypes(Throwable failure) {
StringBuilder types = new StringBuilder();
for (int depth = 0; failure != null && depth < 5; depth++, failure = failure.getCause()) {
if (depth > 0) { types.append(" -> "); }
types.append(failure.getClass().getName());
}
return types.toString();
}
private void processCompleteEveBalance() throws InterruptedException {
ΩAmountΩ eveBalance;
try {
@@ -990,6 +1031,7 @@ public final class EvelynIOUBurnerServiceImpl
}
private void resetProcessLocalState() {
pendingSwap = null;
pendingBurn = null;
swapsSuspended = false;
burnsSuspended = false;
@@ -1024,6 +1066,15 @@ public final class EvelynIOUBurnerServiceImpl
private final TradingPair eveUsdtTradingPair;
private ScheduledExecutorService worker;
private static final Duration PENDING_SWAP_RECONCILIATION_TIMEOUT = Duration.ofSeconds(30);
private PendingSwap pendingSwap;
private record PendingSwap(
ΩSolanaTransactionSignatureΩ transactionSignature,
CurrencyType currency,
ΩAmountΩ amount,
SolanaTransactionValidity validity
) { }
private boolean swapsSuspended;
private boolean burnsSuspended;
private PendingBurn pendingBurn;
@@ -2,6 +2,7 @@ package com.r35157.libs.jupiter;
import com.r35157.libs.solana.SolanaCommitment;
import com.r35157.libs.solana.SolanaTransactionOutcome;
import com.r35157.libs.solana.SolanaTransactionValidity;
import org.jetbrains.annotations.NotNull;
import java.util.Objects;
@@ -29,6 +30,16 @@ public final class JupiterTransactionOutcomeException extends Exception {
@NotNull ΩSolanaTransactionSignatureΩ transactionSignature,
@NotNull SolanaCommitment requestedCommitment,
@NotNull SolanaTransactionOutcome outcome
) {
this(transactionSignature, requestedCommitment, outcome, null);
}
/** Creates an exceptional outcome retaining the original swap validity. */
public JupiterTransactionOutcomeException(
@NotNull ΩSolanaTransactionSignatureΩ transactionSignature,
@NotNull SolanaCommitment requestedCommitment,
@NotNull SolanaTransactionOutcome outcome,
SolanaTransactionValidity validity
) {
super(buildMessage(
requireSignature(transactionSignature),
@@ -41,6 +52,7 @@ public final class JupiterTransactionOutcomeException extends Exception {
this.transactionSignature = transactionSignature;
this.requestedCommitment = requestedCommitment;
this.outcome = outcome;
this.validity = validity;
}
/**
@@ -70,6 +82,9 @@ public final class JupiterTransactionOutcomeException extends Exception {
return outcome;
}
/** Original validity, or null for callers without that context. */
public SolanaTransactionValidity validity() { return validity; }
private static ΩSolanaTransactionSignatureΩ requireSignature(
ΩSolanaTransactionSignatureΩ transactionSignature
) {
@@ -127,4 +142,5 @@ public final class JupiterTransactionOutcomeException extends Exception {
private final ΩSolanaTransactionSignatureΩ transactionSignature;
private final SolanaCommitment requestedCommitment;
private final SolanaTransactionOutcome outcome;
private final SolanaTransactionValidity validity;
}
@@ -0,0 +1,33 @@
package com.r35157.libs.jupiter.swap;
import com.r35157.libs.solana.SolanaTransactionValidity;
/** Interruption that retains submission certainty and known transaction identity. */
public final class JupiterSwapInterruptedException extends InterruptedException {
public JupiterSwapInterruptedException(
boolean submissionMayHaveOccurred,
ΩSolanaTransactionSignatureΩ transactionSignature,
SolanaTransactionValidity validity,
InterruptedException cause
) {
super(submissionMayHaveOccurred
? "Jupiter swap interrupted with unknown submission outcome"
: "Jupiter swap interrupted before submission");
initCause(cause);
if (transactionSignature != null
&& (transactionSignature.isBlank() || !submissionMayHaveOccurred)) {
throw new IllegalArgumentException("Invalid submitted signature");
}
this.submissionMayHaveOccurred = submissionMayHaveOccurred;
this.transactionSignature = transactionSignature;
this.validity = validity;
}
public boolean submissionMayHaveOccurred() { return submissionMayHaveOccurred; }
public ΩSolanaTransactionSignatureΩ transactionSignature() { return transactionSignature; }
public SolanaTransactionValidity validity() { return validity; }
private final boolean submissionMayHaveOccurred;
private final ΩSolanaTransactionSignatureΩ transactionSignature;
private final SolanaTransactionValidity validity;
}
@@ -50,13 +50,10 @@ public interface JupiterSwapService extends NenjimComponent {
* 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, execution, or confirmation fails;
* the reference implementation retains pre/post-submission
* certainty, known identity and cause in structured form;
* after submission the on-chain outcome can be unknown
* @throws JupiterTransactionOutcomeException if the submitted transaction
* definitively fails on-chain or confirmation times out with an
@@ -0,0 +1,50 @@
package com.r35157.libs.jupiter.swap;
import com.r35157.libs.solana.SolanaTransactionValidity;
import java.io.IOException;
/** Structured I/O failure with submission certainty and any known identity. */
public final class JupiterSwapSubmissionException extends IOException {
public JupiterSwapSubmissionException(
boolean submissionMayHaveOccurred,
ΩSolanaTransactionSignatureΩ transactionSignature,
SolanaTransactionValidity validity,
Throwable cause
) {
this(submissionMayHaveOccurred, transactionSignature, validity, cause,
submissionMayHaveOccurred ? "execution" : "preparation");
}
/** Diagnostic must be controlled phase/code text, never an upstream body. */
public JupiterSwapSubmissionException(
boolean submissionMayHaveOccurred,
ΩSolanaTransactionSignatureΩ transactionSignature,
SolanaTransactionValidity validity,
Throwable cause,
String diagnostic
) {
super(submissionMayHaveOccurred
? "Jupiter swap submission outcome is unknown"
: "Jupiter swap failed before submission", cause);
if (transactionSignature != null
&& (transactionSignature.isBlank() || !submissionMayHaveOccurred)) {
throw new IllegalArgumentException("Invalid submitted signature");
}
this.submissionMayHaveOccurred = submissionMayHaveOccurred;
this.transactionSignature = transactionSignature;
this.validity = validity;
this.diagnostic = diagnostic;
}
public boolean submissionMayHaveOccurred() { return submissionMayHaveOccurred; }
public ΩSolanaTransactionSignatureΩ transactionSignature() { return transactionSignature; }
public SolanaTransactionValidity validity() { return validity; }
public String diagnostic() { return diagnostic; }
private final String diagnostic;
private final boolean submissionMayHaveOccurred;
private final ΩSolanaTransactionSignatureΩ transactionSignature;
private final SolanaTransactionValidity validity;
}
@@ -7,6 +7,8 @@ import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.libs.jupiter.JupiterTransactionOutcomeException;
import com.r35157.libs.jupiter.swap.JupiterSwapResult;
import com.r35157.libs.jupiter.swap.JupiterSwapService;
import com.r35157.libs.jupiter.swap.JupiterSwapSubmissionException;
import com.r35157.libs.jupiter.swap.JupiterSwapInterruptedException;
import com.r35157.libs.solana.SPLTokenSupply;
import com.r35157.libs.solana.SolanaAccountInfo;
import com.r35157.libs.solana.SolanaBlockChain;
@@ -14,6 +16,7 @@ import com.r35157.libs.solana.SolanaCommitment;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaTransactionOutcome;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import com.r35157.libs.solana.SolanaTransactionValidity;
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import org.jetbrains.annotations.NotNull;
@@ -29,6 +32,7 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@@ -91,103 +95,197 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
int maxSlippageBps
) throws IOException, JupiterTransactionOutcomeException,
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"
MintMetadata inputMetadata;
MintMetadata outputMetadata;
ValidatedOrder validatedOrder;
SolanaSignedTransaction signedTransaction;
SolanaTransactionValidity validity;
try {
validateArguments(
inputTokenMint,
inputTokenAmount,
outputTokenMint,
maxSlippageBps
);
inputMetadata = resolveMint(inputTokenMint);
outputMetadata = resolveMint(outputTokenMint);
BigInteger rawInputAmount = toRawInputAmount(inputTokenAmount, inputMetadata.decimals());
ΩSolanaWalletIdΩ taker = solanaWallet.getAddress();
// Capture before obtaining the order: even a signer delay must not
// move the anchor past a transaction's earliest possible execution.
ΩSolanaSlotΩ submissionAnchor = solanaBlockChain.getFinalizedSlot();
OrderResponse order = requestOrder(inputTokenMint, outputTokenMint,
rawInputAmount, taker, maxSlippageBps);
validatedOrder = validateOrder(order, inputTokenMint, outputTokenMint,
rawInputAmount, taker, maxSlippageBps);
ΩSolanaBlockhashΩ originalHash = extractRecentBlockhash(validatedOrder.transaction(), taker);
validity = new SolanaTransactionValidity(originalHash,
validatedOrder.lastValidBlockHeight(), taker, submissionAnchor);
signedTransaction = solanaWallet.signTransaction(new SolanaUnsignedTransaction(
validatedOrder.transaction(), originalHash, validatedOrder.lastValidBlockHeight()));
if (signedTransaction == null || !originalHash.equals(
extractRecentBlockhash(signedTransaction.serializedTransaction(), taker))) {
throw new IOException("Signer changed the original Jupiter blockhash");
}
} catch (IOException | RuntimeException exception) {
throw new JupiterSwapSubmissionException(false, null, null, exception,
exception instanceof SafeProviderFailure ? exception.getMessage() : "preparation");
} catch (InterruptedException exception) {
throw new JupiterSwapInterruptedException(false, null, null, exception);
}
ExecuteResponse execution = executeOnce(
signedTransaction.serializedTransaction(),
validatedOrder.requestId(),
validatedOrder.lastValidBlockHeight()
);
ΩSolanaTransactionSignatureΩ localSignature = firstSignedSignature(signedTransaction.serializedTransaction());
ΩSolanaTransactionSignatureΩ knownSignature = localSignature;
if (localSignature != null) {
validity = new SolanaTransactionValidity(validity.recentBlockhash(), validity.lastValidBlockHeight(),
validity.signerAddress(), validity.earliestSubmissionSlot(), true);
}
try {
ExecuteResponse execution = executeOnce(signedTransaction.serializedTransaction(),
validatedOrder.requestId(), validatedOrder.lastValidBlockHeight());
// A malformed result must not discard a usable submission identity.
ΩSolanaTransactionSignatureΩ providerSignature = execution == null ? null : trustworthySignature(execution.signature());
if (providerSignature != null && localSignature != null && !localSignature.equals(providerSignature)) {
knownSignature = null;
throw new SafeProviderFailure("execution signature contradicts signed transaction");
}
if (providerSignature != null) { knownSignature = providerSignature; }
JupiterSwapResult result = validateExecution(execution,
inputMetadata.decimals(), outputMetadata.decimals());
awaitConfirmed(result.transactionSignature(), validity);
return result;
} catch (JupiterSwapSubmissionException | JupiterSwapInterruptedException exception) {
throw exception;
} catch (IOException | RuntimeException exception) {
throw new JupiterSwapSubmissionException(true, knownSignature, validity, exception,
exception instanceof SafeProviderFailure ? exception.getMessage() : "execution");
} catch (InterruptedException exception) {
throw new JupiterSwapInterruptedException(true, knownSignature, validity, exception);
}
}
JupiterSwapResult result = validateExecution(
execution,
inputMetadata.decimals(),
outputMetadata.decimals()
);
awaitConfirmed(result.transactionSignature());
return result;
private static ΩSolanaTransactionSignatureΩ firstSignedSignature(String serialized) {
byte[] bytes = Base64.getDecoder().decode(serialized);
int[] cursor = {0};
if (readShortVector(bytes, cursor) < 1 || cursor[0] + 64 > bytes.length) { return null; }
byte[] first = Arrays.copyOfRange(bytes, cursor[0], cursor[0] + 64);
for (byte value : first) { if (value != 0) { return encodeBase58(first); } }
// Jupiter may complete a sponsor's blank first signature. Its returned
// identity can resolve status, but cannot prove expiry of this payload.
return null;
}
private void awaitConfirmed(
ΩSolanaTransactionSignatureΩ transactionSignature
) throws IOException, JupiterTransactionOutcomeException,
InterruptedException {
ΩSolanaTransactionSignatureΩ transactionSignature,
SolanaTransactionValidity validity
) throws IOException, JupiterTransactionOutcomeException, InterruptedException {
SolanaTransactionOutcome outcome;
try {
outcome = solanaBlockChain.awaitTransaction(
transactionSignature,
SolanaCommitment.CONFIRMED,
transactionConfirmationTimeout
);
} catch (IOException e) {
throw new IOException(
"Jupiter Swap confirmation failed for transaction "
+ transactionSignature
+ "; the on-chain outcome is unknown",
e
);
outcome = solanaBlockChain.awaitTransaction(transactionSignature,
SolanaCommitment.CONFIRMED, transactionConfirmationTimeout);
} catch (IOException exception) {
throw new JupiterSwapSubmissionException(true, transactionSignature, validity,
exception, "confirmation");
} catch (InterruptedException exception) {
throw new JupiterSwapInterruptedException(true, transactionSignature, validity, exception);
} catch (RuntimeException exception) {
throw new JupiterSwapSubmissionException(true, transactionSignature, validity, exception, "confirmation");
}
switch (outcome.status()) {
case SUCCEEDED -> {
return;
}
case FAILED, TIMED_OUT -> throw
new JupiterTransactionOutcomeException(
transactionSignature,
SolanaCommitment.CONFIRMED,
outcome
);
case SUCCEEDED -> { return; }
case FAILED, TIMED_OUT -> throw new JupiterTransactionOutcomeException(
transactionSignature, SolanaCommitment.CONFIRMED, outcome, validity);
}
}
private static ΩSolanaTransactionSignatureΩ trustworthySignature(String signature) {
if (signature == null || signature.isBlank()) { return null; }
try {
return decodeBase58(signature).length == 64 ? signature : null;
} catch (IllegalArgumentException exception) { return null; }
}
/** Parses only the static header/accounts/hash needed to bind validity to the signer. */
private static ΩSolanaBlockhashΩ extractRecentBlockhash(String serialized, String taker)
throws IOException {
try {
byte[] bytes = Base64.getDecoder().decode(Objects.requireNonNull(serialized));
int[] cursor = {0};
int signatures = readShortVector(bytes, cursor);
cursor[0] = Math.addExact(cursor[0], Math.multiplyExact(signatures, 64));
int header = bytes[cursor[0]++] & 255;
if ((header & 128) != 0) {
if (header != 128) { throw new IllegalArgumentException("Unsupported message version"); }
header = bytes[cursor[0]++] & 255;
}
int requiredSigners = header;
cursor[0] += 2;
int accounts = readShortVector(bytes, cursor);
if (requiredSigners < 1 || signatures != requiredSigners || requiredSigners > accounts) {
throw new IllegalArgumentException("Invalid signer header");
}
int accountOffset = cursor[0];
int hashOffset = Math.addExact(accountOffset, Math.multiplyExact(accounts, 32));
if (hashOffset + 32 > bytes.length) { throw new IllegalArgumentException("Truncated message"); }
boolean takerIsSigner = false;
for (int i = 0; i < requiredSigners; i++) {
takerIsSigner |= taker.equals(encodeBase58(Arrays.copyOfRange(bytes,
accountOffset + i * 32, accountOffset + (i + 1) * 32)));
}
if (!takerIsSigner) { throw new IllegalArgumentException("Taker is not a required signer"); }
return encodeBase58(Arrays.copyOfRange(bytes, hashOffset, hashOffset + 32));
} catch (RuntimeException exception) {
throw new IOException("Invalid Jupiter transaction validity payload", exception);
}
}
private static int readShortVector(byte[] bytes, int[] cursor) {
int value = 0;
for (int i = 0; i < 3; i++) {
int next = bytes[cursor[0]++] & 255;
if (i == 2 && (next & 252) != 0) { throw new IllegalArgumentException("Invalid short vector"); }
value |= (next & 127) << (7 * i);
if ((next & 128) == 0) { return value; }
}
throw new IllegalArgumentException("Invalid short vector");
}
private static String encodeBase58(byte[] bytes) {
String alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
BigInteger value = new BigInteger(1, bytes);
StringBuilder text = new StringBuilder();
while (value.signum() > 0) {
BigInteger[] parts = value.divideAndRemainder(BigInteger.valueOf(58));
text.append(alphabet.charAt(parts[1].intValue()));
value = parts[0];
}
for (byte b : bytes) { if (b != 0) { break; } text.append('1'); }
return text.reverse().toString();
}
private static byte[] decodeBase58(String text) {
String alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
BigInteger value = BigInteger.ZERO;
int zeros = 0;
while (zeros < text.length() && text.charAt(zeros) == '1') { zeros++; }
for (int i = 0; i < text.length(); i++) {
int digit = alphabet.indexOf(text.charAt(i));
if (digit < 0) { throw new IllegalArgumentException("Invalid Base58"); }
value = value.multiply(BigInteger.valueOf(58)).add(BigInteger.valueOf(digit));
}
byte[] encoded = value.toByteArray();
int offset = encoded[0] == 0 ? 1 : 0;
byte[] decoded = new byte[zeros + encoded.length - offset];
System.arraycopy(encoded, offset, decoded, zeros, encoded.length - offset);
return decoded;
}
private static final class SafeProviderFailure extends IOException {
private SafeProviderFailure(String detail) { super(detail); }
private SafeProviderFailure(String detail, Throwable cause) { super(detail, cause); }
}
private static Duration requirePositiveTimeout(Duration timeout) {
Objects.requireNonNull(timeout, "transactionConfirmationTimeout");
if (timeout.isZero() || timeout.isNegative()) {
@@ -368,11 +466,7 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
OrderResponse.class
);
} catch (JsonProcessingException e) {
throw new IOException(
"Unable to decode Jupiter order response: "
+ response.body(),
e
);
throw new SafeProviderFailure("order invalid JSON", e);
}
}
@@ -389,10 +483,7 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
}
if (order.errorCode() != null
|| (order.error() != null && !order.error().isBlank())) {
throw new IOException(
"Jupiter could not build the order"
+ optionalError(order)
);
throw new SafeProviderFailure("order rejected; providerCode=" + order.errorCode());
}
if (!expectedInputMint.equals(order.inputMint())) {
throw new IOException(
@@ -559,13 +650,7 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
}
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()
);
throw new SafeProviderFailure("execution HTTP " + response.statusCode());
}
try {
@@ -574,12 +659,7 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
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
);
throw new SafeProviderFailure("execution invalid JSON", e);
}
}
@@ -594,10 +674,7 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
);
}
if (!"Success".equals(execution.status())) {
throw new IOException(
"Jupiter execution failed"
+ executionFailureDetails(execution)
);
throw new SafeProviderFailure("execution rejected; providerCode=" + execution.code());
}
if (execution.code() == null) {
throw new IOException(
@@ -606,14 +683,9 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
);
}
if (execution.code() != 0) {
throw new IOException(
"Jupiter execution returned Success with a non-zero "
+ "result code"
+ executionFailureDetails(execution)
);
throw new SafeProviderFailure("execution contradictory status; providerCode=" + execution.code());
}
if (execution.signature() == null
|| execution.signature().isBlank()) {
if (trustworthySignature(execution.signature()) == null) {
throw new IOException(
"Successful Jupiter execution did not contain a "
+ "transaction signature"
@@ -662,13 +734,7 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
HttpResponse<String> response
) throws IOException {
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IOException(
operation
+ " request failed with HTTP "
+ response.statusCode()
+ ": "
+ response.body()
);
throw new SafeProviderFailure("order HTTP " + response.statusCode());
}
}
@@ -341,4 +341,28 @@ public interface SolanaBlockChain extends NenjimComponent {
SolanaCommitment commitment,
Duration timeout
) throws IOException, InterruptedException;
/** Finalized slot observed before submission, or null when unsupported. */
default ΩSolanaSlotΩ getFinalizedSlot() throws IOException, InterruptedException {
return null;
}
/**
* Reconciles an existing signature read-only. Missing original validity or
* reliable finalized history must never establish non-executed expiry.
* The conservative default resolves outcomes but cannot prove expiry.
*/
default SolanaTransactionReconciliation reconcileTransaction(
ΩSolanaTransactionSignatureΩ signature,
SolanaTransactionValidity validity,
SolanaCommitment commitment,
Duration timeout
) throws IOException, InterruptedException {
if (commitment == SolanaCommitment.PROCESSED) {
throw new IllegalArgumentException("Reconciliation requires CONFIRMED or stronger");
}
return SolanaTransactionReconciliation.fromOutcome(
awaitTransaction(signature, commitment, timeout));
}
}
@@ -0,0 +1,19 @@
package com.r35157.libs.solana;
import java.io.IOException;
/** I/O/protocol failure exposing only controlled RPC phase and numeric codes. */
public final class SolanaReconciliationException extends IOException {
public SolanaReconciliationException(String safeDiagnostic) {
this(safeDiagnostic, null);
}
public SolanaReconciliationException(String safeDiagnostic, Throwable cause) {
super(safeDiagnostic, cause);
this.safeDiagnostic = safeDiagnostic;
}
public String safeDiagnostic() { return safeDiagnostic; }
private final String safeDiagnostic;
}
@@ -0,0 +1,41 @@
package com.r35157.libs.solana;
import java.util.Objects;
/** A read-only reconciliation result; expiry is distinct from an await timeout.
* Detail is an implementation-defined safe diagnostic, never a provider body.
*/
public record SolanaTransactionReconciliation(
Status status,
SolanaTransactionOutcome outcome,
String detail
) {
public SolanaTransactionReconciliation {
Objects.requireNonNull(status, "status");
Objects.requireNonNull(detail, "detail");
if ((status == Status.SUCCEEDED || status == Status.FAILED)
!= (outcome != null)) {
throw new IllegalArgumentException("Invalid reconciliation outcome");
}
if (outcome != null && !outcome.status().name().equals(status.name())) {
throw new IllegalArgumentException("Contradictory reconciliation outcome");
}
}
public static SolanaTransactionReconciliation fromOutcome(
SolanaTransactionOutcome outcome
) {
Objects.requireNonNull(outcome, "outcome");
return outcome.status() == SolanaTransactionOutcome.Status.TIMED_OUT
? unresolved("outcome unknown at deadline")
: new SolanaTransactionReconciliation(
Status.valueOf(outcome.status().name()), outcome,
"definitive transaction status");
}
public static SolanaTransactionReconciliation unresolved(String detail) {
return new SolanaTransactionReconciliation(Status.UNRESOLVED, null, detail);
}
public enum Status { SUCCEEDED, FAILED, EXPIRED, UNRESOLVED }
}
@@ -0,0 +1,32 @@
package com.r35157.libs.solana;
import java.util.Objects;
/** Original transaction validity, never substituted with a newer blockhash.
* The optional finalized submission anchor allows implementations without
* anchor support to resolve known outcomes, but never infer expiry.
*/
public record SolanaTransactionValidity(
ΩSolanaBlockhashΩ recentBlockhash,
long lastValidBlockHeight,
ΩSolanaAddressΩ signerAddress,
ΩSolanaSlotΩ earliestSubmissionSlot,
boolean signatureBoundToTransaction
) {
public SolanaTransactionValidity(ΩSolanaBlockhashΩ recentBlockhash,
long lastValidBlockHeight, ΩSolanaAddressΩ signerAddress,
ΩSolanaSlotΩ earliestSubmissionSlot) {
this(recentBlockhash, lastValidBlockHeight, signerAddress, earliestSubmissionSlot, false);
}
public SolanaTransactionValidity {
Objects.requireNonNull(recentBlockhash, "recentBlockhash");
Objects.requireNonNull(signerAddress, "signerAddress");
if (recentBlockhash.isBlank() || signerAddress.isBlank()
|| lastValidBlockHeight < 0
|| (earliestSubmissionSlot != null
&& earliestSubmissionSlot < 0)) {
throw new IllegalArgumentException("Invalid transaction validity");
}
}
}
@@ -12,6 +12,8 @@ import com.r35157.libs.solana.SolanaProgramAccountMemcmpFilter;
import com.r35157.libs.solana.SolanaProgramAddressSeed;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaTransactionOutcome;
import com.r35157.libs.solana.SolanaTransactionReconciliation;
import com.r35157.libs.solana.SolanaTransactionValidity;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress;
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
@@ -213,6 +215,21 @@ public final class CachedSolanaBlockChain implements SolanaBlockChain {
return delegate.sendTransaction(transaction);
}
@Override
public ΩSolanaSlotΩ getFinalizedSlot() throws IOException, InterruptedException {
return delegate.getFinalizedSlot();
}
@Override
public SolanaTransactionReconciliation reconcileTransaction(
ΩSolanaTransactionSignatureΩ signature,
SolanaTransactionValidity validity,
SolanaCommitment commitment,
Duration timeout
) throws IOException, InterruptedException {
return delegate.reconcileTransaction(signature, validity, commitment, timeout);
}
@Override
public SolanaTransactionOutcome awaitTransaction(
ΩSolanaTransactionSignatureΩ signature,
@@ -906,6 +906,160 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
return signature;
}
@Override
public ΩSolanaSlotΩ getFinalizedSlot() throws IOException, InterruptedException {
Deadline deadline = Deadline.startingAt(System.nanoTime(), Duration.ofSeconds(30));
ArrayNode params = objectMapper.createArrayNode();
params.addObject().put("commitment", "finalized");
JsonNode result = reconciliationRpc("getSlot", params, deadline);
if (result == null) { throw new SolanaReconciliationException("Finalized submission anchor deadline exceeded"); }
return nonnegativeLong(result, "finalized submission slot");
}
@Override
public SolanaTransactionReconciliation reconcileTransaction(
ΩSolanaTransactionSignatureΩ signature,
SolanaTransactionValidity validity,
SolanaCommitment commitment,
Duration timeout
) throws IOException, InterruptedException {
long started = System.nanoTime();
validateAwaitTransactionArguments(signature, commitment, timeout);
if (commitment == SolanaCommitment.PROCESSED) {
throw new IllegalArgumentException("Reconciliation requires CONFIRMED or stronger");
}
Deadline deadline = Deadline.startingAt(started, timeout);
HttpResponse<String> response = sendThrottled(createGetSignatureStatusesBody(signature), deadline);
if (response == null || deadline.hasExpired()) {
return SolanaTransactionReconciliation.unresolved("reconciliation deadline exceeded");
}
// Read safe transport/protocol diagnostics before the existing status parser.
JsonNode initialResult = readReconciliationResult(response, "getSignatureStatuses");
SolanaTransactionOutcome outcome;
try { outcome = parseSignatureStatus(response, commitment); }
catch (IOException exception) { throw new SolanaReconciliationException("getSignatureStatuses invalid status", exception); }
if (outcome != null) { return SolanaTransactionReconciliation.fromOutcome(outcome); }
JsonNode status = initialResult.path("value").get(0);
// Remember an observed transaction for this whole check: absence on a
// different RPC backend must never turn a lower commitment into expiry.
if (status != null && !status.isNull()) {
return SolanaTransactionReconciliation.unresolved("transaction observed below requested commitment");
}
if (validity == null || validity.earliestSubmissionSlot() == null
|| !validity.signatureBoundToTransaction()) {
return SolanaTransactionReconciliation.unresolved("original validity, signature binding or submission anchor unavailable");
}
ArrayNode finalized = objectMapper.createArrayNode();
finalized.addObject().put("commitment", "finalized");
JsonNode epoch = reconciliationRpc("getEpochInfo", finalized, deadline);
if (epoch == null) { return SolanaTransactionReconciliation.unresolved("finalized height deadline exceeded"); }
long finalizedSlot = nonnegativeLong(epoch.get("absoluteSlot"), "finalized absolute slot");
long blockHeight = nonnegativeLong(epoch.get("blockHeight"), "finalized block height");
if (blockHeight > finalizedSlot) {
return SolanaTransactionReconciliation.unresolved("finalized height and slot are inconsistent");
}
if (blockHeight <= validity.lastValidBlockHeight()
|| finalizedSlot < validity.earliestSubmissionSlot()) {
return SolanaTransactionReconciliation.unresolved("original validity height not demonstrably exceeded");
}
ArrayNode hashParams = objectMapper.createArrayNode();
hashParams.add(validity.recentBlockhash());
hashParams.addObject().put("commitment", "finalized").put("minContextSlot", finalizedSlot);
JsonNode hash = reconciliationRpc("isBlockhashValid", hashParams, deadline);
if (hash == null) { return SolanaTransactionReconciliation.unresolved("original blockhash deadline exceeded"); }
long hashContext = nonnegativeLong(hash.path("context").get("slot"), "blockhash context slot");
JsonNode hashValid = hash.get("value");
if (hashValid == null || !hashValid.isBoolean()) {
throw new SolanaReconciliationException("Solana isBlockhashValid returned an invalid value");
}
if (hashContext < finalizedSlot || hashValid.booleanValue()) {
return SolanaTransactionReconciliation.unresolved("original blockhash validity or context prevents expiry");
}
JsonNode retained = reconciliationRpc("minimumLedgerSlot", objectMapper.createArrayNode(), deadline);
if (retained == null) { return SolanaTransactionReconciliation.unresolved("local history deadline exceeded"); }
if (nonnegativeLong(retained, "minimum local ledger slot") > validity.earliestSubmissionSlot()) {
return SolanaTransactionReconciliation.unresolved("local ledger history pruned beyond submission anchor");
}
ArrayNode historyParams = objectMapper.createArrayNode();
historyParams.add(validity.signerAddress());
historyParams.addObject().put("commitment", "finalized")
.put("minContextSlot", hashContext).put("limit", 1000);
JsonNode history = reconciliationRpc("getSignaturesForAddress", historyParams, deadline);
if (history == null) { return SolanaTransactionReconciliation.unresolved("finalized history deadline exceeded"); }
if (!history.isArray() || history.size() > 1000) {
throw new SolanaReconciliationException("Solana finalized address history returned an invalid page");
}
long oldestSlot = Long.MAX_VALUE;
long previousSlot = Long.MAX_VALUE;
for (JsonNode entry : history) {
long slot = nonnegativeLong(entry.get("slot"), "address history slot");
if (slot > previousSlot || !entry.path("signature").isTextual()
|| !isValidHistorySignature(entry.path("signature").asText())
|| !"finalized".equals(entry.path("confirmationStatus").asText())
|| !entry.has("err")) {
return SolanaTransactionReconciliation.unresolved("finalized address history is inconsistent");
}
previousSlot = slot;
oldestSlot = Math.min(oldestSlot, slot);
if (signature.equals(entry.path("signature").asText())) {
JsonNode error = entry.get("err");
return SolanaTransactionReconciliation.fromOutcome(new SolanaTransactionOutcome(
error.isNull() ? SolanaTransactionOutcome.Status.SUCCEEDED : SolanaTransactionOutcome.Status.FAILED,
slot, error.isNull() ? null : error.toString()));
}
}
// One page only: a busy account whose history does not reach the
// anchor stays pending rather than paging without a bounded budget.
if (history.size() == 1000 && oldestSlot >= validity.earliestSubmissionSlot()) {
return SolanaTransactionReconciliation.unresolved("finalized address history page does not cover submission anchor");
}
if (deadline.hasExpired()) { return SolanaTransactionReconciliation.unresolved("reconciliation deadline exceeded"); }
return new SolanaTransactionReconciliation(SolanaTransactionReconciliation.Status.EXPIRED,
null, "original validity expired; finalized retained signer history proves absence");
}
private JsonNode reconciliationRpc(String method, ArrayNode params, Deadline deadline)
throws IOException, InterruptedException {
ObjectNode request = objectMapper.createObjectNode();
request.put("jsonrpc", "2.0").put("id", 1).put("method", method);
request.set("params", params);
HttpResponse<String> response = sendThrottled(objectMapper.writeValueAsString(request), deadline);
if (response == null || deadline.hasExpired()) { return null; }
return readReconciliationResult(response, method);
}
private JsonNode readReconciliationResult(HttpResponse<String> response, String method) throws IOException {
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new SolanaReconciliationException("Solana reconciliation " + method + " HTTP " + response.statusCode());
}
JsonNode root;
try { root = objectMapper.readTree(response.body()); }
catch (IOException exception) { throw new SolanaReconciliationException("Solana reconciliation " + method + " invalid JSON", exception); }
if (root == null || !root.isObject()) { throw new SolanaReconciliationException("Invalid Solana reconciliation " + method + " response"); }
if (root.hasNonNull("error")) {
JsonNode code = root.path("error").get("code");
throw new SolanaReconciliationException("Solana reconciliation " + method + " RPC error code "
+ (code != null && code.isIntegralNumber() ? code.longValue() : "unavailable"));
}
if (!root.hasNonNull("result")) { throw new SolanaReconciliationException("Missing Solana reconciliation " + method + " result"); }
return root.get("result");
}
private boolean isValidHistorySignature(String signature) {
try { return signature != null && !signature.isBlank() && base58Decode(signature).length == 64; }
catch (IllegalArgumentException exception) { return false; }
}
private static long nonnegativeLong(JsonNode node, String description) throws IOException {
if (node == null || !node.isIntegralNumber() || !node.canConvertToLong() || node.longValue() < 0) {
throw new SolanaReconciliationException("Invalid Solana " + description);
}
return node.longValue();
}
@Override
public SolanaTransactionOutcome awaitTransaction(
ΩSolanaTransactionSignatureΩ signature,