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
+18
View File
@@ -129,6 +129,24 @@ EVE-indbetalinger. Burn kræver stadig en frisk EVE/USDT-observation gennem det
uændrede `TickerService.getLatestPrice()` og bekræftes ved `FINALIZED` før det
eneste notifikationsforsøg. Inputgrænserne begrænser ikke EVE-burn.
Ved ukendt swap-resultat gemmes den oprindelige signatur, inputvaluta, beløb og
validitetsoplysninger i processen. Senere iterationer undersøger samme signatur
uden at gensende transaktionen. Bekræftet succes eller fejl ved mindst `CONFIRMED`
frigiver nye swaps med frisklæste saldi. Udløb frigiver kun et swap, når den
oprindelige transaktions validitet er udløbet, og frisk finalized historik for
signerens adresse med tilstrækkelig lokal dækning beviser, at den ikke blev udført.
En begrænset historikside, der ikke dækker hele perioden, er fortsat uafklaret.
Jupiter-udfyldte sponsorsignaturer kan afklares ved status, men bruges ikke til at
bevise udløb af den lokalt signerede transaktion.
Mens et swap er uafklaret, fortsætter eksisterende EVE-burn. Et allerede ventende
burn har førsteprioritet og afklares alene. Fejl bevist før indsendelse tillader et
nyt forsøg i en senere iteration; mulig indsendelse uden troværdig signatur kræver
manuel afklaring. Loggen viser valuta, beløb, signatur, fase/årsagsklasser og næste
handling uden signerdata eller transaktionspayloads. Der findes ingen persistent
journal: genstart mister pending swap/burn og suspensioner, så ukendte
transaktioner skal afklares eksternt før genstart og genoptagelse.
### 2.3 Finansiering af udvikling
Udvikling i Evelyn sker gennem en task-baseret model. Alle opgaver oprettes i IntelliJ YouTrack og bliver
individuelt prissat i 'Evelyn IOU'. Når en opgave er funded – enten af Evelyn selv (via overskud) eller af en
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-05
@@ -0,0 +1,40 @@
# Design
## Context
See proposal.md and issue #91. The burner currently loses a known timed-out swap's identity behind `swapsSuspended`. Jupiter retains its order's last valid block height internally, but structured outcomes expose no validity metadata and confirmation I/O failures expose a signature only in text. Solana awaiting already uses historical status lookup and a shared five-second gate.
## Goals / Non-Goals
**Goals:** Resume buybacks after a safely resolved original transaction while maintaining duplicate prevention, process-local state and the existing burn path.
**Non-Goals:** Build persistent submission infrastructure or change token amount policy, provider selection, burn confirmation or worker scheduling.
## Decisions
1. Keep the shared transaction outcome enum unchanged. Add a small separate reconciliation result and original transaction-validity value in the Solana package, and a read-only reconciliation method. The cached decorator delegates it directly. An additive conservative default permits existing blockchain implementations to await but never infer expiry.
2. Extend the existing structured Jupiter outcome exception with optional validity metadata while preserving the three-argument constructor used by perps. Add typed IOException and InterruptedException subclasses carrying submission-stage certainty, optional trustworthy signature, original validity and the original cause. Preparation failures are explicitly pre-submission; ambiguous execution failures without a signature retain manual suspension. A known confirmation signature is retained even when confirmation I/O or interruption fails.
3. Capture a finalized pre-submission slot, taker address and the order's last valid block height. Extract the original recent blockhash from the serialized transaction message and reject a signer response that changes it. Bind the execution signature to the signed transaction's first signature when it is populated. A sponsor signature completed by Jupiter retains provider identity for status checks, but cannot prove expiry of the local payload. No unrelated latest blockhash participates in expiry evidence.
4. Reconcile in a bounded thirty-second operation using the existing shared RPC gate/deadline. A historical status at CONFIRMED or stronger resolves success/failure. Any lower-commitment observation remains pending. For an absent signature, require finalized epoch height above the original validity height, the original blockhash to be invalid at that finalized context, local `minimumLedgerSlot` at/before the original submission anchor, and a subsequent finalized signer-address history lookup with `minContextSlot` at/after that finalized context and no original signature. One page (limit 1000) must extend strictly before the submission anchor or exhaust the covered retained history; a full page ending at/after the anchor remains unresolved. Missing, pruned, stale, inconsistent or failed evidence remains unresolved. This trusts the configured RPC's retained-history contract; it does not treat a remote archive's earliest block as contiguous local coverage.
5. Pending burns retain absolute priority. Otherwise pending swaps are reconciled before input selection; resolution clears state and selection reloads wallet balances. Unresolved swaps block only new swaps and still reach the existing complete-EVE burn path. Never call `swap()` as reconciliation or resubmit/re-sign an unresolved operation.
6. Log canonical symbol/UUID, attempted amount, known signature, status and next action. Log safe phase/cause classes instead of transport body, signer payload, transaction bytes or arbitrary cause messages.
## Risks / Trade-offs
- History pruned before the retained anchor or incomplete/contradictory evidence → keep pending and diagnose the missing evidence; no speculative recovery.
- A process restart discards pending state → document external reconciliation before restart and retain no persistent journal.
- Additional read-only preparation/reconciliation calls add gate latency → use shared pacing and a bounded reconciliation deadline; add no immediate retry loop.
- RPC evidence cannot be independently audited against the entire chain → rely only on the configured history-enabled RPC's local-retention contract and preserve pending on detected uncertainty.
## Primary references
- [Solana historical signature status](https://solana.com/docs/rpc/http/getsignaturestatuses).
- [Solana transaction retry guidance](https://solana.com/developers/cookbook/transactions/retry).
- [minimumLedgerSlot](https://solana.com/docs/rpc/http/minimumledgerslot) identifies the lowest retained local ledger slot, which may advance as history is purged.
- [isBlockhashValid](https://solana.com/docs/rpc/http/isblockhashvalid) supports commitment and minimum context slot.
- [Finalized address history](https://solana.com/docs/rpc/http/getsignaturesforaddress) binds absence to a minimum finalized context; Agave rejects a finalized request if its highest super-majority root has not reached that context.
- [Agave RPC implementation](https://github.com/anza-xyz/agave/blob/master/rpc/src/rpc.rs) checks transaction-history availability for historical signature lookup; getFirstAvailableBlock may include BigTable and is not used as local coverage evidence.
## Migration Plan
Deployment remains separate. Replace the application only after reconciling unknown transactions in any old process; restarting either version loses in-memory pending state. No configuration or history migration is required, and current reserves/maxima remain unchanged.
@@ -0,0 +1,30 @@
# Proposal
## Why
Issue #91 addresses known Jupiter swaps that time out and currently suspend Evelyn buybacks until a Hub restart. Retaining and reconciling the original transaction safely lets normal hourly buybacks resume after its outcome is resolved, without risking duplicate economic operations.
## What Changes
- Retain process-local pending swap signature, input, attempted amount and original validity metadata after an unknown outcome.
- Reconcile that same signature read-only in later iterations at CONFIRMED or stronger; resume selection only after success, on-chain failure or demonstrated non-executed expiry, using freshly read balances.
- Bind original validity to the actual signed transaction identity where available; sponsor-completed identity can resolve status but cannot prove local-payload expiry.
- Preserve known signatures and original causes in structured confirmation errors; classify proven preparation failures as retryable and retain manual handling for uncertain failures without a trustworthy signature.
- Preserve pending-burn priority, existing EVE burns while a swap remains unresolved, one new swap per iteration, all current reserves/maxima and Ticker behavior.
- Add safe operational diagnostics and focused offline verification.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `evelyn-iou-burner-service`: Later-iteration reconciliation and safe recovery of pending swaps.
- `jupiter-swap-service`: Structured submission stage/signature/cause and original validity context.
- `solana-transaction-awaiting`: Small read-only reconciliation support with conservative expiry/history evidence.
## Impact
The burner, Jupiter swap integration, shared structured Jupiter outcome exception, Solana API/reference implementation/cached delegate, and directly affected documentation/specifications are involved. Existing outcome statuses and perps callers remain compatible. No persistent journal, rebroadcast, automatic retry/re-signing, provider routing, live transaction, deployment, #92 minimum configuration, ValueTag change or unrelated burn/lifecycle work is included.
@@ -0,0 +1,69 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Immediate sequential fixed-delay lifecycle
Each reference implementation instance SHALL own at most one dedicated background worker. A successful Hub-owned `NenjimProcess.start()` SHALL begin the first iteration immediately; every later iteration SHALL begin only after the previous iteration completes and the configured fixed delay elapses; and iterations SHALL never overlap. A repeated process start while active SHALL NOT create another worker. If scheduling the worker fails during startup, the implementation SHALL stop the newly created executor, clear its worker reference and process-local state, and report startup failure.
Pending swap/burn information and manual swap/burn suspension flags SHALL be process-local only. A complete process restart SHALL clear those states. The public JavaDoc SHALL disclose that restart loses reconciliation state and can weaken duplicate prevention until unknown transactions have been reconciled externally. Neither the service interface nor the implementation SHALL expose ordinary stop or restart operations.
#### Scenario: First and later iterations
- **WHEN** the Hub starts the Burner process and one iteration takes longer than usual
- **THEN** the first iteration starts without initial delay and the next starts only after completion plus the configured interval
#### Scenario: Active service is started again
- **WHEN** `NenjimProcess.start()` is called while the instance already owns an active worker
- **THEN** the call is rejected without creating an overlapping worker
#### Scenario: Explicit lifecycle reset
- **WHEN** a complete process restart occurs after pending or suspended process-local state has been externally reconciled
- **THEN** the new process begins without that prior in-memory state and reloads wallet and chain state normally
### Requirement: At most one confirmed swap attempt per iteration
After selecting a positive exact amount, the service SHALL invoke the existing Jupiter exact-input operation once with the selected CIS-resolved input mint, fixed EVE output mint, selected amount, and configured slippage. Normal return SHALL be treated as already `CONFIRMED` and SHALL NOT trigger a second confirmation wait. Once invoked, no later input currency SHALL be attempted in that iteration regardless of success or failure, and the service SHALL proceed to reload the complete EVE balance whenever processing was not interrupted.
A structured definitive on-chain swap failure SHALL be logged without suspending later-process swaps. A timed-out outcome or known-signature confirmation communication error SHALL retain process-local pending swap signature, canonical input, attempted amount and original validity metadata for later read-only reconciliation. Proven pre-submission errors SHALL permit a fresh attempt in a later scheduled iteration. An ambiguous post-submission error without a trustworthy signature SHALL retain manual suspension. Interruption SHALL preserve available pending context and propagate normally. No unknown outcome SHALL cause automatic rebuilding, re-signing or resubmission; pending swaps SHALL still permit existing EVE burn evaluation.
#### Scenario: First swap fails definitively
- **WHEN** the first attempted input returns a definitive on-chain failure
- **THEN** no second input is attempted in that iteration, the EVE balance is still reloaded, and a later iteration may attempt a new swap
#### Scenario: Swap outcome is unknown
- **WHEN** the attempted swap times out, throws an I/O failure that permits unknown post-submission status, or is interrupted
- **THEN** no equivalent or alternative swap is attempted while its outcome is unresolved; a known signature is checked in later iterations and a signature-less ambiguous submission requires manual reconciliation
#### Scenario: Swap returns normally
- **WHEN** Jupiter returns a successful result whose transaction already reached `CONFIRMED`
- **THEN** the burner performs no additional confirmation wait and reloads the wallet instead of deriving its burn amount only from the returned output amount
### Requirement: Production burner accepts USDT and SyrupUSDC
The production Hub SHALL configure USDT first with reserve 0.1 and maximum input 0.25 USDT, and SyrupUSDC second with reserve 0.095 and maximum input 0.2 SyrupUSDC. These SHALL be token-denominated limits without a USD-value guarantee. The existing burner wallet, Jupiter route selection, registration, hourly interval, slippage, EVE/USDT freshness, confirmation settings and burn recovery behavior SHALL be preserved without new Ticker sources or history storage.
#### Scenario: Production inputs and settings
- **WHEN** the Hub constructs the production burner
- **THEN** it supplies the two exact reserves and maxima as MoneyAmount values in USDT-then-SyrupUSDC priority and retains the other existing production arguments
## ADDED Requirements
### Requirement: Pending swaps reconcile before new input selection
After pending-burn priority, a retained swap SHALL be checked read-only by its original signature at CONFIRMED or stronger in a bounded operation. Success, on-chain failure or demonstrated non-executed expiry SHALL clear it and permit newly read balances to drive at most one new swap. Unknown, absent without expiry proof or I/O outcomes SHALL remain pending without a new swap while allowing existing EVE burns. Interruption SHALL propagate with pending state intact.
#### Scenario: Original swap resolves
- **WHEN** a later check resolves the original signature as successful, definitively failed or proven non-executed and expired
- **THEN** pending swap state is cleared, complete failure details are logged when applicable, and any new selection uses newly read balances
#### Scenario: Original swap remains unresolved
- **WHEN** a later check times out, lacks trustworthy expiry/history evidence, observes only lower commitment or encounters I/O failure
- **THEN** the same pending signature is retained for the next scheduled iteration, no new swap occurs and the existing EVE burn path still runs
#### Scenario: Burn remains pending
- **WHEN** both a swap and a burn are pending at iteration start
- **THEN** only the burn signature is awaited and no swap reconciliation or new transaction is performed
### Requirement: Safe swap recovery diagnostics
Swap diagnostics SHALL identify canonical input symbol/UUID, attempted amount, known signature, submission or reconciliation state, safe cause details and next action. Timeout SHALL be described as unknown. Credentials, arbitrary provider/signer bodies and serialized transaction payloads SHALL NOT be logged. Pending swap state SHALL remain process-local; restart SHALL lose it and require external reconciliation before safely resuming uncertain activity.
#### Scenario: Unknown outcome is diagnosed
- **WHEN** a swap outcome remains unknown
- **THEN** diagnostics identify whether its original signature will be checked next iteration or manual investigation is required, without exposing secret or payload data
@@ -0,0 +1,59 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Confirmed execution result validation
The service SHALL first accept a Jupiter execution response only when it has status `Success`, explicitly has result code `0`, contains a non-blank transaction signature, and contains positive valid actual total input and output raw amounts. It SHALL convert each actual amount using the independently resolved precision of its mint. After this provider-response validation, the service SHALL independently await the returned signature on Solana at `CONFIRMED` using its configured confirmation timeout and SHALL return the already validated swap result only for a `SUCCEEDED` outcome. Non-success HTTP responses, malformed responses, expired or rejected swaps, failed or contradictory status/code combinations, missing result codes, blank signatures, and invalid result amounts SHALL remain provider failures and SHALL not be represented as successful swaps.
#### Scenario: Provider response and on-chain confirmation succeed
- **WHEN** Jupiter reports status `Success`, code `0`, a signature, and valid actual total input and output amounts, and that signature independently reaches `CONFIRMED` with a `SUCCEEDED` outcome
- **THEN** the service returns the signature and those actual amounts in human-readable units
#### Scenario: Jupiter rejects or fails execution
- **WHEN** Jupiter returns a failed status, a missing or non-zero result code, a contradictory status/code combination, expiration, rejection, non-success HTTP status, malformed body, blank signature, or invalid actual amount
- **THEN** the service reports an I/O failure without treating provider validation as an on-chain confirmation
#### Scenario: Provider succeeds but transaction fails on-chain
- **WHEN** a fully validated Jupiter execution response contains a signature whose independent `CONFIRMED` outcome is `FAILED`
- **THEN** the service throws a structured checked outcome exception preserving the signature, requested commitment, slot, and complete compact Solana failure details
#### Scenario: Provider succeeds but confirmation times out
- **WHEN** a fully validated Jupiter execution response contains a signature whose independent `CONFIRMED` outcome is `TIMED_OUT`
- **THEN** the service throws a structured checked outcome exception preserving the signature, requested commitment, original validity metadata and unknown outcome warning and does not automatically resubmit an equivalent transaction
### Requirement: Post-submission communication and interruption policy
If independent confirmation fails with an I/O error after the signature is known, the service SHALL throw a structured `IOException` preserving the original signature, original transaction-validity metadata, submission uncertainty and underlying cause. Interruption SHALL propagate as `InterruptedException` while preserving known signature/validity context in structured form without another RPC or Jupiter request. Neither condition SHALL trigger rebuilding, re-signing, retrying, or resubmitting a transaction.
#### Scenario: Confirmation communication fails
- **WHEN** independent confirmation throws an I/O error for a known submitted signature
- **THEN** the service reports the structured signature, original validity metadata and unknown on-chain outcome while preserving the original error and performs no retry or resubmission
#### Scenario: Confirmation is interrupted
- **WHEN** independent confirmation throws `InterruptedException`
- **THEN** that interruption retains its known signature and validity context, propagates normally and the service performs no subsequent request
## ADDED Requirements
### Requirement: Submission certainty and original validity remain structured
The integration SHALL expose submission certainty, known signature, original cause and original validity in structured form. Original validity SHALL retain the order height, actual blockhash, taker and finalized submission anchor. Signing SHALL preserve the hash and required taker signer. Expiry evidence SHALL be bound to a populated local first signature; contradictory provider identity SHALL require manual handling. Existing perps outcome construction SHALL remain compatible.
#### Scenario: Preparation fails
- **WHEN** mint/order acquisition, validation, signing or pre-submission validity preparation fails before execution submission
- **THEN** the error is structured as pre-submission and no execution or automatic retry occurs
#### Scenario: Execution response is lost
- **WHEN** execution may have been submitted and no trustworthy signature is available
- **THEN** the error remains structured as uncertain submission and does not authorize automatic recovery or resubmission
#### Scenario: Provider identity contradicts the signed transaction
- **WHEN** an execution response signature differs from the populated first signature of the signed payload
- **THEN** the result is not treated as confirmed and uncertain submission requires manual reconciliation
#### Scenario: Sponsor identity is completed by Jupiter
- **WHEN** Jupiter supplies a valid provider signature while the local first signature is blank
- **THEN** the signature can be awaited and reconciled by status, but original local validity cannot establish non-executed expiry
#### Scenario: Timeout retains original validity
- **WHEN** confirmation of a known submitted signature times out
- **THEN** the exceptional outcome exposes its original validity data without substituting a new transaction or blockhash
@@ -0,0 +1,33 @@
# Spec Delta
## ADDED Requirements
### Requirement: Read-only transaction reconciliation
The blockchain SHALL support bounded reconciliation of an existing signature at CONFIRMED or stronger, retaining normal await behavior and outcome statuses. Reconciliation SHALL distinguish definitive success/failure, demonstrated non-executed expiry and unresolved evidence. It SHALL use the existing shared RPC gate/deadline and historical status lookup without submission, caching, immediate retries or a new pacing mechanism. Unsupported expiry evidence SHALL remain unresolved.
#### Scenario: Definitive historical status
- **WHEN** the original signature is observed at or above requested commitment
- **THEN** reconciliation returns its successful or failed outcome with slot and complete failure details as applicable
#### Scenario: Below commitment or interrupted
- **WHEN** the signature is only observed below commitment or the check is interrupted
- **THEN** expiry is not inferred; an unresolved result is returned for lower commitment and interruption propagates normally
#### Scenario: Cached decorator
- **WHEN** reconciliation is invoked through the cached decorator
- **THEN** it reaches the delegate independently on every call
### Requirement: Expiry requires original validity and reliable local history absence
Expiry SHALL require signature-bound original validity, finalized height above its last valid height, original blockhash invalidity, retained local history through its submission anchor and fresh finalized signer-history absence. Missing, pruned, stale, lower-commitment or inconsistent evidence SHALL remain unresolved. Absence, elapsed time or blockhash invalidity alone SHALL NOT authorize clearing a pending transaction.
#### Scenario: Non-executed expiry is established
- **WHEN** all original-validity, finalized-height, original-blockhash, local-history-coverage and finalized signer-history absence checks succeed, with `minContextSlot` at/after the expired finalized context and a bounded page reaching before the submission anchor or exhausting covered retained history
- **THEN** reconciliation identifies a non-executed expired transaction without resubmitting it
#### Scenario: Evidence is incomplete
- **WHEN** original metadata is missing, history is unavailable/pruned, a lookup fails, a response is inconsistent/stale or the original blockhash is still valid
- **THEN** no expiry result is returned and the diagnostic explains the missing evidence
#### Scenario: Bounded history page is incomplete
- **WHEN** a full history page ends at or after the submission anchor
- **THEN** the transaction remains unresolved instead of treating partial absence as proof
@@ -0,0 +1,17 @@
# Tasks
## 1. Preserve submitted swap identity
- [x] 1.1 Add compatible structured validity, I/O and interruption metadata; verify original signature/cause and pre-submission certainty offline.
- [x] 1.2 Retain the actual transaction blockhash, original last-valid height and pre-submission anchor; verify signer hash consistency without live submission.
## 2. Reconcile pending swaps safely
- [x] 2.1 Add bounded read-only Solana reconciliation and direct cached delegation; verify confirmed outcomes and conservative finalized-history expiry evidence with controlled RPC responses.
- [x] 2.2 Preserve process-local pending swaps and safe diagnostics in the burner; verify scheduled recovery, unresolved suppression, fresh balances, EVE burn continuation and pending-burn priority offline.
## 3. Complete the documented change
- [x] 3.1 Update service documentation and operational restart limitations; review unchanged amount policy, transaction submission and burn recovery behavior.
- [x] 3.2 Compile existing main/test sources, run the temporary focused verification harness, record evidence and validate OpenSpec.
- [x] 3.3 Sync main specifications and archive the completed change after checking implementation and verification evidence.
@@ -0,0 +1,24 @@
# Verification
Verified on 2026-10-06 with JDK `/usr/local/software/java/jfx-25`.
## Compilation and offline checks
- Final parent compilation: `JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew compileJava compileTestJava --offline --no-daemon` → **BUILD SUCCESSFUL in 7s**; `detagMain` and `compileJava` executed against the final safe order-diagnostic correction and `compileTestJava` was up-to-date. The preceding successful 9s build also executed `compileTestJava`.
- Temporary harness compiled with `javac --enable-preview --release 25 -cp 'build/classes/java/main:build/libs/*' -d /tmp/evelyn-issue91 /tmp/evelyn-issue91/Issue91Verification.java`.
- `java --enable-preview -cp '/tmp/evelyn-issue91:build/classes/java/main:build/libs/*' Issue91Verification` → **PASS: 102 focused offline assertions**. Every wallet, swap and HTTP dependency is controlled; no live signing, submission or remote RPC occurs.
- The harness's initial deadline fixture omitted the gate's completed-call flag. Correcting that temporary fixture produced the successful run; no implementation change was necessary. An earlier harness compilation preceded the parent build and saw old classes; it is superseded by this final compilation/run.
Coverage includes later-iteration success/failure/expiry recovery using fresh balances; same-signature bounded CONFIRMED checks; unresolved/I/O/interruption suppression; pre-submission retry and manual signature-less uncertainty; EVE burn continuation, stale-price gate and pending-burn priority; original timeout/signature/validity/cause retention; compatible perps construction; actual legacy/v0 blockhash and required-taker extraction; blank sponsor identity; invalid provider signature rejection; and finalized expiry evidence with pruning, full history pages, minContextSlot, lower commitment, stale/inconsistent evidence and deadline handling. Safe numeric RPC and Jupiter order HTTP/provider codes are retained without provider bodies/error messages.
## Review and OpenSpec
- `git diff --check`: passed.
- Strict validation of the active change and all three synced main specifications: passed. All ten delta requirements match the main specs exactly. After archive, `openspec list --json` reports no active changes and `openspec validate --archived --json --report findings` passes all 29 archived changes with no findings. Existing long-requirement informational hints remain. After intentional main-spec synchronization the change validator may note that ADDED requirements already exist; the semantic archive workflow preserves that synchronized state.
- Independent parent review found no remaining material findings; its safety corrections were implemented before the successful final build.
- Static diff review confirms current reserves/maxima, Hub factory, input selection, Ticker behavior, EVE balance/price gate, burn submission/await/notification and fixed-delay scheduling are unchanged. The provider/local signature contradiction branch retains manual handling. The cached decorator delegates reconciliation directly.
- There remains one managed execution call and no automatic resubmission, persistent journal, ValueTag change or issue #92 minimum-input policy.
## Limits
The harness is temporary and outside the repository; no permanent automated test was added. Full live Jupiter/provider integration was not exercised. Expiry relies on the configured RPC's reliable retained-history contract and remains pending when original signature binding, history coverage or finalized evidence is insufficient. Sponsor-completed signatures can resolve by status but cannot establish local-payload expiry. Restart loses process-local pending state, requiring external reconciliation before safely resuming unknown activity. No deployment, push or Gitea change was made.
@@ -81,7 +81,7 @@ The service SHALL fix the EVE mint as `meveYG2iXYSkgSUn1T1uxcthH1EGMZdRHGgCntXZA
### Requirement: Immediate sequential fixed-delay lifecycle
Each reference implementation instance SHALL own at most one dedicated background worker. A successful Hub-owned `NenjimProcess.start()` SHALL begin the first iteration immediately; every later iteration SHALL begin only after the previous iteration completes and the configured fixed delay elapses; and iterations SHALL never overlap. A repeated process start while active SHALL NOT create another worker. If scheduling the worker fails during startup, the implementation SHALL stop the newly created executor, clear its worker reference and process-local state, and report startup failure.
Pending burn information and swap/burn suspension flags SHALL be process-local only. A complete process restart SHALL clear those states. The public JavaDoc SHALL disclose that restart loses reconciliation state and can weaken duplicate prevention until unknown transactions have been reconciled externally. Neither the service interface nor the implementation SHALL expose ordinary stop or restart operations.
Pending swap/burn information and manual swap/burn suspension flags SHALL be process-local only. A complete process restart SHALL clear those states. The public JavaDoc SHALL disclose that restart loses reconciliation state and can weaken duplicate prevention until unknown transactions have been reconciled externally. Neither the service interface nor the implementation SHALL expose ordinary stop or restart operations.
#### Scenario: First and later iterations
- **WHEN** the Hub starts the Burner process and one iteration takes longer than usual
@@ -130,7 +130,7 @@ For each entry the service SHALL subtract its configured reserve and skip non-po
### Requirement: At most one confirmed swap attempt per iteration
After selecting a positive exact amount, the service SHALL invoke the existing Jupiter exact-input operation once with the selected CIS-resolved input mint, fixed EVE output mint, selected amount, and configured slippage. Normal return SHALL be treated as already `CONFIRMED` and SHALL NOT trigger a second confirmation wait. Once invoked, no later input currency SHALL be attempted in that iteration regardless of success or failure, and the service SHALL proceed to reload the complete EVE balance whenever processing was not interrupted.
A structured definitive on-chain swap failure SHALL be logged without suspending later-process swaps. A timed-out structured outcome SHALL suspend all later swaps in that process. Because the Jupiter `IOException` and interruption contracts can represent an unknown post-submission result, either SHALL conservatively suspend later swaps. The service SHALL never automatically rebuild, re-sign, retry, or resubmit such a swap; while swaps are suspended, later iterations SHALL continue only their burn path. Recovery SHALL require external reconciliation followed by a complete process restart, not a Burner service stop/start operation.
A structured definitive on-chain swap failure SHALL be logged without suspending later-process swaps. A timed-out outcome or known-signature confirmation communication error SHALL retain process-local pending swap signature, canonical input, attempted amount and original validity metadata for later read-only reconciliation. Proven pre-submission errors SHALL permit a fresh attempt in a later scheduled iteration. An ambiguous post-submission error without a trustworthy signature SHALL retain manual suspension. Interruption SHALL preserve available pending context and propagate normally. No unknown outcome SHALL cause automatic rebuilding, re-signing or resubmission; pending swaps SHALL still permit existing EVE burn evaluation.
#### Scenario: First swap fails definitively
- **WHEN** the first attempted input returns a definitive on-chain failure
@@ -138,7 +138,7 @@ A structured definitive on-chain swap failure SHALL be logged without suspending
#### Scenario: Swap outcome is unknown
- **WHEN** the attempted swap times out, throws an I/O failure that permits unknown post-submission status, or is interrupted
- **THEN** no equivalent or alternative swap is attempted and all later swaps remain suspended until external reconciliation and a complete process restart
- **THEN** no equivalent or alternative swap is attempted while its outcome is unresolved; a known signature is checked in later iterations and a signature-less ambiguous submission requires manual reconciliation
#### Scenario: Swap returns normally
- **WHEN** Jupiter returns a successful result whose transaction already reached `CONFIRMED`
@@ -242,8 +242,30 @@ The service SHALL select the lesser of positive `balance - minimumReserve` and t
- **THEN** the service enforces the reserve against the observed balance only and does not estimate fees, even though later fees may reduce the final balance or cause execution to fail
### Requirement: Production burner accepts USDT and SyrupUSDC
The production Hub SHALL configure USDT first with reserve 0.1 and maximum input 0.25 USDT, and SyrupUSDC second with reserve 0.095 and maximum input 0.2 SyrupUSDC. These SHALL be token-denominated limits without a USD-value guarantee. The existing burner wallet, Jupiter route selection, registration, hourly interval, slippage, EVE/USDT freshness, confirmation and recovery behavior SHALL be preserved without new Ticker sources or history storage.
The production Hub SHALL configure USDT first with reserve 0.1 and maximum input 0.25 USDT, and SyrupUSDC second with reserve 0.095 and maximum input 0.2 SyrupUSDC. These SHALL be token-denominated limits without a USD-value guarantee. The existing burner wallet, Jupiter route selection, registration, hourly interval, slippage, EVE/USDT freshness, confirmation settings and burn recovery behavior SHALL be preserved without new Ticker sources or history storage.
#### Scenario: Production inputs and settings
- **WHEN** the Hub constructs the production burner
- **THEN** it supplies the two exact reserves and maxima as MoneyAmount values in USDT-then-SyrupUSDC priority and retains the other existing production arguments
### Requirement: Pending swaps reconcile before new input selection
After pending-burn priority, a retained swap SHALL be checked read-only by its original signature at CONFIRMED or stronger in a bounded operation. Success, on-chain failure or demonstrated non-executed expiry SHALL clear it and permit newly read balances to drive at most one new swap. Unknown, absent without expiry proof or I/O outcomes SHALL remain pending without a new swap while allowing existing EVE burns. Interruption SHALL propagate with pending state intact.
#### Scenario: Original swap resolves
- **WHEN** a later check resolves the original signature as successful, definitively failed or proven non-executed and expired
- **THEN** pending swap state is cleared, complete failure details are logged when applicable, and any new selection uses newly read balances
#### Scenario: Original swap remains unresolved
- **WHEN** a later check times out, lacks trustworthy expiry/history evidence, observes only lower commitment or encounters I/O failure
- **THEN** the same pending signature is retained for the next scheduled iteration, no new swap occurs and the existing EVE burn path still runs
#### Scenario: Burn remains pending
- **WHEN** both a swap and a burn are pending at iteration start
- **THEN** only the burn signature is awaited and no swap reconciliation or new transaction is performed
### Requirement: Safe swap recovery diagnostics
Swap diagnostics SHALL identify canonical input symbol/UUID, attempted amount, known signature, submission or reconciliation state, safe cause details and next action. Timeout SHALL be described as unknown. Credentials, arbitrary provider/signer bodies and serialized transaction payloads SHALL NOT be logged. Pending swap state SHALL remain process-local; restart SHALL lose it and require external reconciliation before safely resuming uncertain activity.
#### Scenario: Unknown outcome is diagnosed
- **WHEN** a swap outcome remains unknown
- **THEN** diagnostics identify whether its original signature will be checked next iteration or manual investigation is required, without exposing secret or payload data
+27 -4
View File
@@ -102,7 +102,7 @@ The service SHALL first accept a Jupiter execution response only when it has sta
#### Scenario: Provider succeeds but confirmation times out
- **WHEN** a fully validated Jupiter execution response contains a signature whose independent `CONFIRMED` outcome is `TIMED_OUT`
- **THEN** the service throws a structured checked outcome exception preserving the signature, requested commitment, and unknown outcome warning and does not automatically resubmit an equivalent transaction
- **THEN** the service throws a structured checked outcome exception preserving the signature, requested commitment, original validity metadata and unknown outcome warning and does not automatically resubmit an equivalent transaction
### Requirement: Swap confirmation timeout policy
The reference service SHALL accept a constructor-injected, non-null, strictly positive confirmation timeout and SHALL preserve its existing constructor with a two-minute default. The configured duration SHALL be passed unchanged to independent confirmation after submission and SHALL begin governing only when awaiting the known signature starts.
@@ -116,15 +116,15 @@ The reference service SHALL accept a constructor-injected, non-null, strictly po
- **THEN** construction fails before any operation can be submitted
### Requirement: Post-submission communication and interruption policy
If independent confirmation fails with an I/O error after the signature is known, the service SHALL throw an `IOException` whose message identifies the swap operation, includes the signature, and states that the on-chain outcome is unknown, with the original error retained as its cause. Interruption SHALL propagate directly as `InterruptedException` without another RPC or Jupiter request. Neither condition SHALL trigger rebuilding, re-signing, retrying, or resubmitting a transaction.
If independent confirmation fails with an I/O error after the signature is known, the service SHALL throw a structured `IOException` preserving the original signature, original transaction-validity metadata, submission uncertainty and underlying cause. Interruption SHALL propagate as `InterruptedException` while preserving known signature/validity context in structured form without another RPC or Jupiter request. Neither condition SHALL trigger rebuilding, re-signing, retrying, or resubmitting a transaction.
#### Scenario: Confirmation communication fails
- **WHEN** independent confirmation throws an I/O error for a known submitted signature
- **THEN** the service reports the signature and unknown on-chain outcome while preserving the original error and performs no retry or resubmission
- **THEN** the service reports the structured signature, original validity metadata and unknown on-chain outcome while preserving the original error and performs no retry or resubmission
#### Scenario: Confirmation is interrupted
- **WHEN** independent confirmation throws `InterruptedException`
- **THEN** that interruption propagates directly and the service performs no subsequent request
- **THEN** that interruption retains its known signature and validity context, propagates normally and the service performs no subsequent request
### Requirement: API and implementation separation
The stable service interface and result value SHALL reside in the Jupiter swap API package, while HTTP DTOs, endpoint handling, throttling, and the reference implementation SHALL remain in the reference-implementation package.
@@ -132,3 +132,26 @@ The stable service interface and result value SHALL reside in the Jupiter swap A
#### Scenario: Downstream API consumer
- **WHEN** downstream code depends only on the Jupiter swap API package
- **THEN** it can invoke swaps and inspect results without depending on private wire DTOs or reference-implementation mechanics
### Requirement: Submission certainty and original validity remain structured
The integration SHALL expose submission certainty, known signature, original cause and original validity in structured form. Original validity SHALL retain the order height, actual blockhash, taker and finalized submission anchor. Signing SHALL preserve the hash and required taker signer. Expiry evidence SHALL be bound to a populated local first signature; contradictory provider identity SHALL require manual handling. Existing perps outcome construction SHALL remain compatible.
#### Scenario: Preparation fails
- **WHEN** mint/order acquisition, validation, signing or pre-submission validity preparation fails before execution submission
- **THEN** the error is structured as pre-submission and no execution or automatic retry occurs
#### Scenario: Execution response is lost
- **WHEN** execution may have been submitted and no trustworthy signature is available
- **THEN** the error remains structured as uncertain submission and does not authorize automatic recovery or resubmission
#### Scenario: Provider identity contradicts the signed transaction
- **WHEN** an execution response signature differs from the populated first signature of the signed payload
- **THEN** the result is not treated as confirmed and uncertain submission requires manual reconciliation
#### Scenario: Sponsor identity is completed by Jupiter
- **WHEN** Jupiter supplies a valid provider signature while the local first signature is blank
- **THEN** the signature can be awaited and reconciled by status, but original local validity cannot establish non-executed expiry
#### Scenario: Timeout retains original validity
- **WHEN** confirmation of a known submitted signature times out
- **THEN** the exceptional outcome exposes its original validity data without substituting a new transaction or blockhash
@@ -96,3 +96,33 @@ The cached Solana blockchain decorator SHALL delegate every transaction-await ca
#### Scenario: Repeated awaits through cached decorator
- **WHEN** callers invoke transaction awaiting multiple times through the cached decorator
- **THEN** every invocation reaches the delegate independently
### Requirement: Read-only transaction reconciliation
The blockchain SHALL support bounded reconciliation of an existing signature at CONFIRMED or stronger, retaining normal await behavior and outcome statuses. Reconciliation SHALL distinguish definitive success/failure, demonstrated non-executed expiry and unresolved evidence. It SHALL use the existing shared RPC gate/deadline and historical status lookup without submission, caching, immediate retries or a new pacing mechanism. Unsupported expiry evidence SHALL remain unresolved.
#### Scenario: Definitive historical status
- **WHEN** the original signature is observed at or above requested commitment
- **THEN** reconciliation returns its successful or failed outcome with slot and complete failure details as applicable
#### Scenario: Below commitment or interrupted
- **WHEN** the signature is only observed below commitment or the check is interrupted
- **THEN** expiry is not inferred; an unresolved result is returned for lower commitment and interruption propagates normally
#### Scenario: Cached decorator
- **WHEN** reconciliation is invoked through the cached decorator
- **THEN** it reaches the delegate independently on every call
### Requirement: Expiry requires original validity and reliable local history absence
Expiry SHALL require signature-bound original validity, finalized height above its last valid height, original blockhash invalidity, retained local history through its submission anchor and fresh finalized signer-history absence. Missing, pruned, stale, lower-commitment or inconsistent evidence SHALL remain unresolved. Absence, elapsed time or blockhash invalidity alone SHALL NOT authorize clearing a pending transaction.
#### Scenario: Non-executed expiry is established
- **WHEN** all original-validity, finalized-height, original-blockhash, local-history-coverage and finalized signer-history absence checks succeed, with `minContextSlot` at/after the expired finalized context and a bounded page reaching before the submission anchor or exhausting covered retained history
- **THEN** reconciliation identifies a non-executed expired transaction without resubmitting it
#### Scenario: Evidence is incomplete
- **WHEN** original metadata is missing, history is unavailable/pruned, a lookup fails, a response is inconsistent/stale or the original blockhash is still valid
- **THEN** no expiry result is returned and the diagnostic explains the missing evidence
#### Scenario: Bounded history page is incomplete
- **WHEN** a full history page ends at or after the submission anchor
- **THEN** the transaction remains unresolved instead of treating partial absence as proof
@@ -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()
);
"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");
}
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,6 +95,12 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
int maxSlippageBps
) throws IOException, JupiterTransactionOutcomeException,
InterruptedException {
MintMetadata inputMetadata;
MintMetadata outputMetadata;
ValidatedOrder validatedOrder;
SolanaSignedTransaction signedTransaction;
SolanaTransactionValidity validity;
try {
validateArguments(
inputTokenMint,
inputTokenAmount,
@@ -98,94 +108,182 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
maxSlippageBps
);
MintMetadata inputMetadata = resolveMint(inputTokenMint);
MintMetadata outputMetadata = resolveMint(outputTokenMint);
BigInteger rawInputAmount = toRawInputAmount(
inputTokenAmount,
inputMetadata.decimals()
);
inputMetadata = resolveMint(inputTokenMint);
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"
);
// 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()
);
JupiterSwapResult result = validateExecution(
execution,
inputMetadata.decimals(),
outputMetadata.decimals()
);
awaitConfirmed(result.transactionSignature());
Ω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);
}
}
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, validity);
}
}
switch (outcome.status()) {
case SUCCEEDED -> {
return;
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; }
}
case FAILED, TIMED_OUT -> throw
new JupiterTransactionOutcomeException(
transactionSignature,
SolanaCommitment.CONFIRMED,
outcome
);
/** 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) {
@@ -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,