91: Reconcile timed-out Evelyn IOU burner swaps in later iterations
This commit is contained in:
@@ -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
|
||||
|
||||
+95
-44
@@ -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;
|
||||
}
|
||||
+189
-123
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user