Compare commits
2
Commits
adb5f0235a
...
49df1ac9d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49df1ac9d0 | ||
|
|
152137ed87 |
+34
-9
@@ -106,18 +106,25 @@ udnyttes at ikke alle investorer sælger på samme tid, da de har forskellige fo
|
|||||||
|
|
||||||
Produktionsprocessen `evelyn.iou-burner.prod` accepterer følgende inputs i rækkefølge:
|
Produktionsprocessen `evelyn.iou-burner.prod` accepterer følgende inputs i rækkefølge:
|
||||||
|
|
||||||
| Token | Reserve | Maksimum pr. iteration |
|
| Token | Reserve | Minimum swap-input | Maksimum pr. iteration |
|
||||||
| --- | ---: | ---: |
|
| --- | ---: | ---: | ---: |
|
||||||
| USDT | 0,1 USDT | 0,25 USDT |
|
| USDT | 0,1 USDT | 0,05 USDT | 0,25 USDT |
|
||||||
| SyrupUSDC | 0,095 SyrupUSDC | 0,2 SyrupUSDC |
|
| SyrupUSDC | 0,095 SyrupUSDC | 0,045 SyrupUSDC | 0,2 SyrupUSDC |
|
||||||
|
|
||||||
Hub'en angiver reserver og maksimummer som `MoneyAmount`. Reservelisten bestemmer
|
Hub'en angiver reserver, minimumsbeløb og maksimummer som `MoneyAmount`.
|
||||||
accepterede valutaer og prioritet; maksimummer matches efter valutaens UUID,
|
Reservelisten bestemmer accepterede valutaer og prioritet; minimumsbeløb og
|
||||||
uanset maksimumlistens rækkefølge. Hver accepteret valuta skal have præcis ét
|
maksimummer matches efter valutaens kanoniske UUID uanset listernes rækkefølge,
|
||||||
positivt maksimum. Begge lister kopieres ved oprettelse af burneren.
|
navn og symbol. Hver accepteret valuta skal have præcis ét positivt minimum og
|
||||||
|
maksimum, og minimum må ikke overstige maksimum. Alle tre lister kopieres ved
|
||||||
|
oprettelse af burneren; tre tomme lister tillader direkte EVE-burn.
|
||||||
|
|
||||||
Burneren vælger det mindste af saldo minus reserve og valutaens eget maksimum,
|
Burneren vælger det mindste af saldo minus reserve og valutaens eget maksimum,
|
||||||
afrundet ned til mintens præcision. Grænserne angives i tokens: SyrupUSDC er ikke
|
afrundet ned til mintens præcision. Er dette faktiske beløb under valutaens
|
||||||
|
minimum, springes inputtet over, og næste input undersøges i samme iteration.
|
||||||
|
Lighed med minimum accepteres; der rundes aldrig op eller bruges af reserven for
|
||||||
|
at nå minimum. For eksempel springes 0,000001 spendabel USDT over, så en
|
||||||
|
SyrupUSDC-saldo på 0,502048 kan give et swap på 0,2 SyrupUSDC. Inputminimummer
|
||||||
|
begrænser ikke burn af eksisterende EVE. Grænserne angives i tokens: SyrupUSDC er ikke
|
||||||
lig med USDC, og 0,2 SyrupUSDC garanterer ikke en bestemt USD-værdi. Der kræves
|
lig med USDC, og 0,2 SyrupUSDC garanterer ikke en bestemt USD-værdi. Der kræves
|
||||||
ingen inputtoken/USDC-pris eller nye Ticker-kilder og historikfiler.
|
ingen inputtoken/USDC-pris eller nye Ticker-kilder og historikfiler.
|
||||||
|
|
||||||
@@ -129,6 +136,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
|
uændrede `TickerService.getLatestPrice()` og bekræftes ved `FINALIZED` før det
|
||||||
eneste notifikationsforsøg. Inputgrænserne begrænser ikke EVE-burn.
|
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
|
### 2.3 Finansiering af udvikling
|
||||||
Udvikling i Evelyn sker gennem en task-baseret model. Alle opgaver oprettes i IntelliJ YouTrack og bliver
|
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
|
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.
|
||||||
+69
@@ -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
|
||||||
|
|
||||||
+59
@@ -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
|
||||||
|
|
||||||
+33
@@ -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.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-06
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
See proposal.md and issue #92. The constructor currently owns ordered reserve configuration and immutable UUID-indexed maxima. Candidate selection already rounds down before invoking Jupiter; pending recovery precedes selection.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:** Extend existing token amount policy safely with a lower bound on actual attempted inputs.
|
||||||
|
|
||||||
|
**Non-Goals:** Introduce a configuration hierarchy, compatibility bypass, precision rounding for minima, or change execution/recovery behavior.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. Add required minima between reserves and maxima. Reuse one private positive per-UUID amount validator for minima and maxima, copy them to immutable maps with canonical CurrencyType values, and reject minimum > maximum after both maps exist. Reserve order alone retains priority. A duplicated independent validator would unnecessarily duplicate the existing identity/set rules.
|
||||||
|
2. Compare the unchanged rounded candidate with its minimum using `compareTo`, preserving numeric equality across decimal scales. A pre-round comparison would permit attempted inputs below minimum; rounding upward or reserve top-up would violate existing spend bounds. Minima need not be representable at mint precision: they remain exact lower bounds, and an unachievable minimum simply makes that candidate ineligible.
|
||||||
|
3. Make production minima explicit MoneyAmount values in the existing Hub factory and update the single current call site. No old-signature overload bypasses the required policy.
|
||||||
|
4. Keep offline verification temporary, covering issue examples, precision/scale edges, config validation/copying, direct EVE and the prior recovery harness with only its constructor adapted.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- Incorrect UUID matching or list reordering → use canonical UUID maps and verify stale display metadata/reordered lists.
|
||||||
|
- Precision makes a configured minimum unachievable → skip the rounded candidate; never promote amount.
|
||||||
|
- Constructor signature changes → update the only current construction site and compile all existing main/test sources.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Implementation adds explicit production configuration; deployment remains a separate task. No persistent data, ticker history or ValueTag migration is required.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Issue #92 prevents unsuitable tiny buybacks above the reserve from blocking useful later inputs. Token-denominated minima let Evelyn skip those candidates while preserving reserve safety, priority and existing recovery.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **BREAKING** Add a required `List<MoneyAmount>` of per-currency minimum swap inputs to the reference constructor beside reserves and maxima; match canonical UUIDs and copy configuration defensively.
|
||||||
|
- Reject missing, extra, unknown, duplicate or null minimum identities/values, non-positive minima and minima above their matched maxima.
|
||||||
|
- Compare the actual downward-rounded candidate with its minimum. Skip smaller candidates and continue input inspection; accept equality without spending reserves or rounding upward.
|
||||||
|
- Configure production USDT minimum 0.05 and SyrupUSDC minimum 0.045, with current reserve/maxima values and USDT priority retained.
|
||||||
|
- Update directly affected documentation/specs and perform focused offline verification without permanent tests or live transactions.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `evelyn-iou-burner-service`: Per-input minimum configuration, validation and qualification after precision rounding.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
The burner reference constructor and selection, the Hub production call site, domain/constructor JavaDoc and Evelyn configuration documentation change. Jupiter, Ticker, token discovery, lifecycle, pending swap/burn recovery, notification and ValueTags remain unchanged. Implementation and deployment remain separate.
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Ordered AssetAZ input configuration
|
||||||
|
The authoritative constructor SHALL accept a non-null ordered `List<MoneyAmount>` named `inputCurrencyMinimumReserves`, defensively copy it without changing order, and interpret inclusion as acceptance, list position as priority, each `currencyType().id()` as the authoritative AssetAZ identity, and each `amount()` as the minimum balance that must remain. It SHALL ignore supplied currency name and symbol metadata by resolving each UUID through the injected Currency Identity Service and using the canonical returned currency for processing. The constructor SHALL also accept a non-null `List<MoneyAmount>` named `inputCurrencyMaximumSwapAmounts`, defensively copy its values, and match each maximum to an accepted input by canonical currency UUID, independently of maximum-list order or supplied metadata. Each maximum SHALL be expressed in that same currency's own units. The constructor SHALL additionally accept `inputCurrencyMinimumSwapAmounts`, defensively copy its values and match strictly positive token-denominated minima by canonical UUID independently of order/name/symbol. Empty reserves, minima and maxima lists SHALL be valid and SHALL still permit directly deposited EVE to be burned.
|
||||||
|
|
||||||
|
#### Scenario: Stale configured metadata
|
||||||
|
- **WHEN** a configured `MoneyAmount` carries a known UUID but stale name or symbol metadata
|
||||||
|
- **THEN** the service processes the current canonical currency resolved for that UUID
|
||||||
|
|
||||||
|
#### Scenario: Priority is preserved
|
||||||
|
- **WHEN** multiple configured entries have rounded amounts at/above their own minimum and usable Solana data
|
||||||
|
- **THEN** the earliest entry in the defensively copied list is the only input selected in that iteration
|
||||||
|
|
||||||
|
#### Scenario: Empty input list
|
||||||
|
- **WHEN** no buyback inputs are configured but the wallet contains EVE
|
||||||
|
- **THEN** the service skips input selection and still evaluates the complete EVE balance for burning
|
||||||
|
|
||||||
|
#### Scenario: Maximum list order is independent
|
||||||
|
- **WHEN** maxima are supplied in a different order from the reserve list or carry stale display metadata for known UUIDs
|
||||||
|
- **THEN** each input receives the maximum for its UUID and reserve-list priority is preserved
|
||||||
|
|
||||||
|
#### Scenario: Caller changes its configuration lists
|
||||||
|
- **WHEN** a caller changes any supplied list after construction
|
||||||
|
- **THEN** the accepted currencies, their priority, reserves, minima and maxima remain as validated during construction
|
||||||
|
|
||||||
|
#### Scenario: Minimum list order and display metadata are independent
|
||||||
|
- **WHEN** minima are reordered or use stale names/symbols for known canonical UUIDs
|
||||||
|
- **THEN** each input receives its UUID's minimum and only reserve-list order controls priority
|
||||||
|
|
||||||
|
### Requirement: Complete constructor validation
|
||||||
|
Construction SHALL require all injected services, all three input configuration lists, and durations to be non-null; every list entry, entry amount, entry currency, and currency UUID to be non-null; each reserve to be zero or greater; each minimum and maximum to be strictly positive; each minimum to be no greater than its matched maximum; each UUID to be known to the injected Currency Identity Service; UUIDs to be unique within each list; both minimum and maximum UUID sets to exactly equal the accepted reserve UUID set; and EVE not to be an input currency. It SHALL require slippage to be from 0 through 10000 basis points inclusive, `maximumPriceAge` to be greater than zero milliseconds, and both finalization timeout and iteration interval to be positive and non-zero.
|
||||||
|
|
||||||
|
Every configured non-SOL UUID SHALL resolve through reverse CIS lookup to exactly one external reference in the `solana-mint` namespace, and that reference's external ID SHALL be non-null and non-blank. Construction SHALL fail for a missing, ambiguous, null, or blank non-SOL mint representation. These burner-specific rules SHALL NOT change the general-purpose `MoneyAmount` contract.
|
||||||
|
|
||||||
|
#### Scenario: Valid configuration
|
||||||
|
- **WHEN** every dependency and value is valid, reserves are non-negative, every accepted input has exactly one positive minimum and maximum with minimum <= maximum, UUIDs are distinct and known within each list, EVE is absent, and every non-SOL input has one non-blank Solana mint reference
|
||||||
|
- **THEN** construction succeeds and preserves the configured priority order
|
||||||
|
|
||||||
|
#### Scenario: Invalid reserve or operating limit
|
||||||
|
- **WHEN** a reserve is negative or null, a token maximum is null or non-positive, slippage is outside its inclusive range, maximum price age is non-positive, or either duration is null or non-positive
|
||||||
|
- **THEN** construction fails before a worker or transaction is created
|
||||||
|
|
||||||
|
#### Scenario: Invalid input identity
|
||||||
|
- **WHEN** an entry or its currency or UUID is null, a UUID is unknown or duplicated, or the EVE UUID is configured as input
|
||||||
|
- **THEN** construction fails with a diagnostic identifying the invalid configuration
|
||||||
|
|
||||||
|
#### Scenario: Non-SOL representation is not unique and usable
|
||||||
|
- **WHEN** a configured non-SOL currency has zero or multiple `solana-mint` references or its sole external ID is null or blank
|
||||||
|
- **THEN** construction fails without adding mint or token-program selection to Evelyn's configuration model
|
||||||
|
|
||||||
|
#### Scenario: Maximum configuration is incomplete or ambiguous
|
||||||
|
- **WHEN** a maximum is missing for an accepted input, duplicated by UUID, or supplied for an unaccepted currency
|
||||||
|
- **THEN** construction fails before a worker or transaction is created
|
||||||
|
|
||||||
|
#### Scenario: Invalid maximum identity or value
|
||||||
|
- **WHEN** a maximum entry, amount, currency or UUID is null, or its UUID is unknown or resolves to an invalid canonical identity
|
||||||
|
- **THEN** construction fails with a diagnostic identifying the invalid maximum configuration
|
||||||
|
|
||||||
|
#### Scenario: Invalid minimum configuration
|
||||||
|
- **WHEN** the minimum list or an entry, amount, currency or UUID is null; a UUID is unknown, duplicated, extra or missing; a minimum is non-positive; or it exceeds that UUID's maximum
|
||||||
|
- **THEN** construction fails before a worker or transaction is created
|
||||||
|
|
||||||
|
#### Scenario: Minimum equals maximum
|
||||||
|
- **WHEN** a strictly positive minimum is numerically equal to its matched maximum
|
||||||
|
- **THEN** that amount configuration is accepted without requiring equal decimal scales
|
||||||
|
|
||||||
|
#### Scenario: Direct EVE only configuration
|
||||||
|
- **WHEN** all three configuration lists are empty
|
||||||
|
- **THEN** construction succeeds and direct EVE remains eligible for burning
|
||||||
|
|
||||||
|
### Requirement: Exact token-capped amount and per-currency reserve
|
||||||
|
The service SHALL select the lesser of positive `balance - minimumReserve` and the input UUID's maximum token amount, then round downward to mint precision. It SHALL skip non-positive results and actual rounded amounts below that UUID's minimum, continuing to later inputs in the same iteration without counting a swap attempt. Equality SHALL qualify. It SHALL never round upward, top up from reserves or exceed either existing bound. Input selection SHALL require no input/USDC Ticker observation. Minima and maxima SHALL constrain buyback inputs only; EVE burns SHALL remain uncapped and unaffected. Native SOL reserves SHALL use observed balance without estimating future fees.
|
||||||
|
|
||||||
|
#### Scenario: SyrupUSDC maximum is lower than spendable balance
|
||||||
|
- **WHEN** SyrupUSDC balance is 0.5, its reserve is 0.095, its minimum is 0.045 and its maximum is 0.2
|
||||||
|
- **THEN** 0.2 SyrupUSDC is selected without requesting a SyrupUSDC/USDC observation
|
||||||
|
|
||||||
|
#### Scenario: SyrupUSDC spendable balance is lower than the maximum
|
||||||
|
- **WHEN** SyrupUSDC balance is 0.245, its reserve is 0.095, its minimum is 0.045 and its maximum is 0.2
|
||||||
|
- **THEN** 0.150 SyrupUSDC is selected before downward mint-precision rounding
|
||||||
|
|
||||||
|
#### Scenario: Reserve consumes the balance
|
||||||
|
- **WHEN** the current balance is equal to or below that entry's minimum reserve
|
||||||
|
- **THEN** no amount of that currency is intentionally swapped
|
||||||
|
|
||||||
|
#### Scenario: USDT maximum preserves the production bound
|
||||||
|
- **WHEN** USDT balance is 0.35, its reserve is 0.1, its minimum is 0.05 and its maximum is 0.25
|
||||||
|
- **THEN** 0.25 USDT is selected before downward mint-precision rounding
|
||||||
|
|
||||||
|
#### Scenario: Precision rounding never increases the spend
|
||||||
|
- **WHEN** the lesser of spendable balance and maximum is 0.1509999, the mint supports six decimals and its minimum is no greater than 0.150999
|
||||||
|
- **THEN** the service selects 0.150999, and if downward rounding instead produces zero the input is skipped
|
||||||
|
|
||||||
|
#### Scenario: Native transaction fees
|
||||||
|
- **WHEN** native SOL is selected with a configured reserve
|
||||||
|
- **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
|
||||||
|
|
||||||
|
#### Scenario: USDT below and at its minimum
|
||||||
|
- **WHEN** USDT reserve is 0.1 and minimum is 0.05
|
||||||
|
- **THEN** balances 0.14 and 0.149999 are skipped, while balance 0.15 selects exactly 0.05
|
||||||
|
|
||||||
|
#### Scenario: Tiny first input permits useful later input
|
||||||
|
- **WHEN** USDT balance is 0.100001 with reserve 0.1/minimum 0.05, and SyrupUSDC balance is 0.502048 with reserve 0.095/minimum 0.045/maximum 0.2
|
||||||
|
- **THEN** the USDT remainder is skipped and 0.2 SyrupUSDC is the only attempted swap
|
||||||
|
|
||||||
|
#### Scenario: SyrupUSDC minimum boundary
|
||||||
|
- **WHEN** SyrupUSDC reserve is 0.095 and minimum is 0.045
|
||||||
|
- **THEN** balance 0.14 selects exactly 0.045 while 0.139999 is skipped
|
||||||
|
|
||||||
|
#### Scenario: Actual rounded candidate falls below a fractional minimum
|
||||||
|
- **WHEN** six-decimal input precision rounds candidate 0.0500009 down to 0.050000 and its minimum is 0.0500001
|
||||||
|
- **THEN** the candidate is skipped without rounding up or using reserves
|
||||||
|
|
||||||
|
#### Scenario: Input minima do not suppress direct EVE burn
|
||||||
|
- **WHEN** every configured input is below its minimum but the wallet contains EVE with a usable EVE/USDT price
|
||||||
|
- **THEN** the complete EVE balance is still evaluated and burned by the existing rules
|
||||||
|
|
||||||
|
### Requirement: Production burner accepts USDT and SyrupUSDC
|
||||||
|
The production Hub SHALL configure USDT first with reserve 0.1, minimum input 0.05 and maximum input 0.25 USDT, and SyrupUSDC second with reserve 0.095, minimum input 0.045 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 exact reserves, minima and maxima as MoneyAmount values in USDT-then-SyrupUSDC priority and retains the other existing production arguments
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Configure and qualify minimum buybacks
|
||||||
|
|
||||||
|
- [x] 1.1 Add immutable canonical-UUID minima and constructor validation; verify reordered/copied configuration, invalid values and minimum <= maximum with a temporary offline harness.
|
||||||
|
- [x] 1.2 Apply minima to actual rounded candidates and update domain/constructor JavaDoc; verify issue examples, precision/scale boundaries, later-input continuation and unchanged reserve/max bounds offline.
|
||||||
|
- [x] 1.3 Add exact production minima and update Evelyn configuration documentation; review USDT priority and all other unchanged production arguments.
|
||||||
|
|
||||||
|
## 2. Verify and finalize integration
|
||||||
|
|
||||||
|
- [x] 2.1 Compile existing main/test sources and run focused temporary selection/config checks plus the adapted #91 recovery harness; record results and review unchanged execution, Ticker, burn and recovery code.
|
||||||
|
- [x] 2.2 Strictly validate the change, sync and validate the main spec, check whitespace and archive the completed change after confirming all requirements/tasks are satisfied.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Verification
|
||||||
|
|
||||||
|
Verified on 2026-10-06 with JDK `/usr/local/software/java/jfx-25`.
|
||||||
|
|
||||||
|
## Compilation and controlled checks
|
||||||
|
|
||||||
|
- Parent compilation: `JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew compileJava compileTestJava --offline --no-daemon` → **BUILD SUCCESSFUL in 6s**; `detagMain`, `compileJava` and `compileTestJava` all executed against the final source.
|
||||||
|
- Temporary harnesses compiled with `javac --enable-preview --release 25 -cp 'build/classes/java/main:build/libs/*' -d /tmp/evelyn-issue92 /tmp/evelyn-issue92/Issue92Verification.java /tmp/evelyn-issue92/Issue91Verification.java`.
|
||||||
|
- `java --enable-preview -cp '/tmp/evelyn-issue92:build/classes/java/main:build/libs/*' Issue92Verification` → **PASS: 89 focused minimum-amount assertions**.
|
||||||
|
- The same invocation for `Issue91Verification` → **PASS: 102 recovery assertions**. Its original temporary file is preserved in `/tmp/evelyn-issue91`; the copy changes only the burner constructor call to add SyrupUSDC minimum 0.045.
|
||||||
|
- All wallet, swap, HTTP and pricing dependencies are controlled. No live financial operation or real RPC request occurs.
|
||||||
|
|
||||||
|
The #92 harness covers every issue example, equality across decimal scales, minimum == maximum, fractional minima above rounded candidates, downward rounding of maximum, zero after precision rounding, next-input continuation, canonical UUID matching with reordered/stale display metadata, caller mutations of all three copied lists, invalid minima, preserved maximum validation, direct EVE when inputs are below minima or all three lists are empty, stale EVE price and at most one attempted qualifying swap even after failure.
|
||||||
|
|
||||||
|
An initial temporary null-UUID fixture attempted to construct an invalid CurrencyType, whose existing constructor already rejects null IDs before burner configuration. The check was moved to that proper pre-burner rejection point, and the #92 harness rerun passed. No production fix was required.
|
||||||
|
|
||||||
|
## Review and scope
|
||||||
|
|
||||||
|
Parent independent source/documentation review found no material findings. Static diff review confirms the changes are confined to the reference constructor/amount configuration validator, rounded-candidate qualification, domain JavaDoc, production minima and Evelyn configuration docs. Reserves/maxima, USDT priority, pending recovery, Jupiter, Ticker, token discovery, burn/notification and lifecycle are preserved. No ValueTag, deployment file or permanent automated test changed.
|
||||||
|
|
||||||
|
Strict validation of the active change and synced main specification passed. All four delta requirements match the main spec exactly; unrelated requirements/scenarios remain intact. Existing long-requirement informational hints remain under the rule that modified requirement blocks stay whole. `git diff --check` passed. All five tasks and four planning artifacts are complete.
|
||||||
|
|
||||||
|
After archival, `openspec list --json` reports no active changes. `openspec validate --archived --json --report findings` passed for all 30 archived changes, with 0 failures and 0 findings.
|
||||||
|
|
||||||
|
No live integration, deployment, push or Gitea write was performed. Temporary harnesses remain outside the repository; no permanent automated tests were added.
|
||||||
@@ -16,14 +16,14 @@ The system SHALL expose an empty lifecycle-free `EvelynIOUBurnerService` under `
|
|||||||
- **THEN** it uses existing `MoneyAmount` values rather than burner-specific Solana, SPL-token, or input-asset types
|
- **THEN** it uses existing `MoneyAmount` values rather than burner-specific Solana, SPL-token, or input-asset types
|
||||||
|
|
||||||
### Requirement: Ordered AssetAZ input configuration
|
### Requirement: Ordered AssetAZ input configuration
|
||||||
The authoritative constructor SHALL accept a non-null ordered `List<MoneyAmount>` named `inputCurrencyMinimumReserves`, defensively copy it without changing order, and interpret inclusion as acceptance, list position as priority, each `currencyType().id()` as the authoritative AssetAZ identity, and each `amount()` as the minimum balance that must remain. It SHALL ignore supplied currency name and symbol metadata by resolving each UUID through the injected Currency Identity Service and using the canonical returned currency for processing. The constructor SHALL also accept a non-null `List<MoneyAmount>` named `inputCurrencyMaximumSwapAmounts`, defensively copy its values, and match each maximum to an accepted input by canonical currency UUID, independently of maximum-list order or supplied metadata. Each maximum SHALL be expressed in that same currency's own units. Empty reserves and maxima lists SHALL be valid and SHALL still permit directly deposited EVE to be burned.
|
The authoritative constructor SHALL accept a non-null ordered `List<MoneyAmount>` named `inputCurrencyMinimumReserves`, defensively copy it without changing order, and interpret inclusion as acceptance, list position as priority, each `currencyType().id()` as the authoritative AssetAZ identity, and each `amount()` as the minimum balance that must remain. It SHALL ignore supplied currency name and symbol metadata by resolving each UUID through the injected Currency Identity Service and using the canonical returned currency for processing. The constructor SHALL also accept a non-null `List<MoneyAmount>` named `inputCurrencyMaximumSwapAmounts`, defensively copy its values, and match each maximum to an accepted input by canonical currency UUID, independently of maximum-list order or supplied metadata. Each maximum SHALL be expressed in that same currency's own units. The constructor SHALL additionally accept `inputCurrencyMinimumSwapAmounts`, defensively copy its values and match strictly positive token-denominated minima by canonical UUID independently of order/name/symbol. Empty reserves, minima and maxima lists SHALL be valid and SHALL still permit directly deposited EVE to be burned.
|
||||||
|
|
||||||
#### Scenario: Stale configured metadata
|
#### Scenario: Stale configured metadata
|
||||||
- **WHEN** a configured `MoneyAmount` carries a known UUID but stale name or symbol metadata
|
- **WHEN** a configured `MoneyAmount` carries a known UUID but stale name or symbol metadata
|
||||||
- **THEN** the service processes the current canonical currency resolved for that UUID
|
- **THEN** the service processes the current canonical currency resolved for that UUID
|
||||||
|
|
||||||
#### Scenario: Priority is preserved
|
#### Scenario: Priority is preserved
|
||||||
- **WHEN** multiple configured entries have spendable balances and usable Solana data
|
- **WHEN** multiple configured entries have rounded amounts at/above their own minimum and usable Solana data
|
||||||
- **THEN** the earliest entry in the defensively copied list is the only input selected in that iteration
|
- **THEN** the earliest entry in the defensively copied list is the only input selected in that iteration
|
||||||
|
|
||||||
#### Scenario: Empty input list
|
#### Scenario: Empty input list
|
||||||
@@ -35,16 +35,20 @@ The authoritative constructor SHALL accept a non-null ordered `List<MoneyAmount>
|
|||||||
- **THEN** each input receives the maximum for its UUID and reserve-list priority is preserved
|
- **THEN** each input receives the maximum for its UUID and reserve-list priority is preserved
|
||||||
|
|
||||||
#### Scenario: Caller changes its configuration lists
|
#### Scenario: Caller changes its configuration lists
|
||||||
- **WHEN** a caller changes either supplied list after construction
|
- **WHEN** a caller changes any supplied list after construction
|
||||||
- **THEN** the accepted currencies, their priority, reserves and maxima remain as validated during construction
|
- **THEN** the accepted currencies, their priority, reserves, minima and maxima remain as validated during construction
|
||||||
|
|
||||||
|
#### Scenario: Minimum list order and display metadata are independent
|
||||||
|
- **WHEN** minima are reordered or use stale names/symbols for known canonical UUIDs
|
||||||
|
- **THEN** each input receives its UUID's minimum and only reserve-list order controls priority
|
||||||
|
|
||||||
### Requirement: Complete constructor validation
|
### Requirement: Complete constructor validation
|
||||||
Construction SHALL require all injected services, both input configuration lists, and durations to be non-null; every list entry, entry amount, entry currency, and currency UUID to be non-null; each reserve to be zero or greater; each maximum to be strictly positive; each UUID to be known to the injected Currency Identity Service; UUIDs to be unique within each list; the maximum UUID set to exactly equal the accepted reserve UUID set; and EVE not to be an input currency. It SHALL require slippage to be from 0 through 10000 basis points inclusive, `maximumPriceAge` to be greater than zero milliseconds, and both finalization timeout and iteration interval to be positive and non-zero.
|
Construction SHALL require all injected services, all three input configuration lists, and durations to be non-null; every list entry, entry amount, entry currency, and currency UUID to be non-null; each reserve to be zero or greater; each minimum and maximum to be strictly positive; each minimum to be no greater than its matched maximum; each UUID to be known to the injected Currency Identity Service; UUIDs to be unique within each list; both minimum and maximum UUID sets to exactly equal the accepted reserve UUID set; and EVE not to be an input currency. It SHALL require slippage to be from 0 through 10000 basis points inclusive, `maximumPriceAge` to be greater than zero milliseconds, and both finalization timeout and iteration interval to be positive and non-zero.
|
||||||
|
|
||||||
Every configured non-SOL UUID SHALL resolve through reverse CIS lookup to exactly one external reference in the `solana-mint` namespace, and that reference's external ID SHALL be non-null and non-blank. Construction SHALL fail for a missing, ambiguous, null, or blank non-SOL mint representation. These burner-specific rules SHALL NOT change the general-purpose `MoneyAmount` contract.
|
Every configured non-SOL UUID SHALL resolve through reverse CIS lookup to exactly one external reference in the `solana-mint` namespace, and that reference's external ID SHALL be non-null and non-blank. Construction SHALL fail for a missing, ambiguous, null, or blank non-SOL mint representation. These burner-specific rules SHALL NOT change the general-purpose `MoneyAmount` contract.
|
||||||
|
|
||||||
#### Scenario: Valid configuration
|
#### Scenario: Valid configuration
|
||||||
- **WHEN** every dependency and value is valid, reserves are non-negative, every accepted input has exactly one positive maximum, UUIDs are distinct and known within each list, EVE is absent, and every non-SOL input has one non-blank Solana mint reference
|
- **WHEN** every dependency and value is valid, reserves are non-negative, every accepted input has exactly one positive minimum and maximum with minimum <= maximum, UUIDs are distinct and known within each list, EVE is absent, and every non-SOL input has one non-blank Solana mint reference
|
||||||
- **THEN** construction succeeds and preserves the configured priority order
|
- **THEN** construction succeeds and preserves the configured priority order
|
||||||
|
|
||||||
#### Scenario: Invalid reserve or operating limit
|
#### Scenario: Invalid reserve or operating limit
|
||||||
@@ -67,6 +71,18 @@ Every configured non-SOL UUID SHALL resolve through reverse CIS lookup to exactl
|
|||||||
- **WHEN** a maximum entry, amount, currency or UUID is null, or its UUID is unknown or resolves to an invalid canonical identity
|
- **WHEN** a maximum entry, amount, currency or UUID is null, or its UUID is unknown or resolves to an invalid canonical identity
|
||||||
- **THEN** construction fails with a diagnostic identifying the invalid maximum configuration
|
- **THEN** construction fails with a diagnostic identifying the invalid maximum configuration
|
||||||
|
|
||||||
|
#### Scenario: Invalid minimum configuration
|
||||||
|
- **WHEN** the minimum list or an entry, amount, currency or UUID is null; a UUID is unknown, duplicated, extra or missing; a minimum is non-positive; or it exceeds that UUID's maximum
|
||||||
|
- **THEN** construction fails before a worker or transaction is created
|
||||||
|
|
||||||
|
#### Scenario: Minimum equals maximum
|
||||||
|
- **WHEN** a strictly positive minimum is numerically equal to its matched maximum
|
||||||
|
- **THEN** that amount configuration is accepted without requiring equal decimal scales
|
||||||
|
|
||||||
|
#### Scenario: Direct EVE only configuration
|
||||||
|
- **WHEN** all three configuration lists are empty
|
||||||
|
- **THEN** construction succeeds and direct EVE remains eligible for burning
|
||||||
|
|
||||||
### Requirement: Fixed Evelyn domain values and internally resolved integration values
|
### Requirement: Fixed Evelyn domain values and internally resolved integration values
|
||||||
The service SHALL fix the EVE mint as `meveYG2iXYSkgSUn1T1uxcthH1EGMZdRHGgCntXZA3Y`, construct the EVE/USDT notification-price pair from canonical CIS resolutions of the stable EVE and USDT UUIDs, and use fixed notification wording. Native SOL SHALL be identified only by the stable SOL UUID; when sent to Jupiter, its input mint SHALL be the canonical CIS Solana-mint reference `So11111111111111111111111111111111111111112`. Mint addresses, token programs, price pairs, notification wording, and a separate SOL reserve SHALL NOT be constructor configuration.
|
The service SHALL fix the EVE mint as `meveYG2iXYSkgSUn1T1uxcthH1EGMZdRHGgCntXZA3Y`, construct the EVE/USDT notification-price pair from canonical CIS resolutions of the stable EVE and USDT UUIDs, and use fixed notification wording. Native SOL SHALL be identified only by the stable SOL UUID; when sent to Jupiter, its input mint SHALL be the canonical CIS Solana-mint reference `So11111111111111111111111111111111111111112`. Mint addresses, token programs, price pairs, notification wording, and a separate SOL reserve SHALL NOT be constructor configuration.
|
||||||
|
|
||||||
@@ -81,7 +97,7 @@ The service SHALL fix the EVE mint as `meveYG2iXYSkgSUn1T1uxcthH1EGMZdRHGgCntXZA
|
|||||||
### Requirement: Immediate sequential fixed-delay lifecycle
|
### 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.
|
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
|
#### Scenario: First and later iterations
|
||||||
- **WHEN** the Hub starts the Burner process and one iteration takes longer than usual
|
- **WHEN** the Hub starts the Burner process and one iteration takes longer than usual
|
||||||
@@ -130,7 +146,7 @@ For each entry the service SHALL subtract its configured reserve and skip non-po
|
|||||||
### Requirement: At most one confirmed swap attempt per iteration
|
### 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.
|
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
|
#### Scenario: First swap fails definitively
|
||||||
- **WHEN** the first attempted input returns a definitive on-chain failure
|
- **WHEN** the first attempted input returns a definitive on-chain failure
|
||||||
@@ -138,7 +154,7 @@ A structured definitive on-chain swap failure SHALL be logged without suspending
|
|||||||
|
|
||||||
#### Scenario: Swap outcome is unknown
|
#### 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
|
- **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
|
#### Scenario: Swap returns normally
|
||||||
- **WHEN** Jupiter returns a successful result whose transaction already reached `CONFIRMED`
|
- **WHEN** Jupiter returns a successful result whose transaction already reached `CONFIRMED`
|
||||||
@@ -215,14 +231,14 @@ The EVE amount SHALL use plain decimal notation without unnecessary trailing zer
|
|||||||
- **THEN** the service logs a non-secret failure, clears completed state, and never retries either notification or burn
|
- **THEN** the service logs a non-secret failure, clears completed state, and never retries either notification or burn
|
||||||
|
|
||||||
### Requirement: Exact token-capped amount and per-currency reserve
|
### Requirement: Exact token-capped amount and per-currency reserve
|
||||||
The service SHALL select the lesser of positive `balance - minimumReserve` and the input UUID's maximum token amount, then round downward to mint precision. It SHALL skip non-positive results and SHALL never exceed either bound through rounding. Input selection SHALL require no input/USDC Ticker observation. Maxima SHALL limit buybacks only; EVE burns SHALL remain uncapped. Native SOL reserves SHALL use observed balance without estimating future fees.
|
The service SHALL select the lesser of positive `balance - minimumReserve` and the input UUID's maximum token amount, then round downward to mint precision. It SHALL skip non-positive results and actual rounded amounts below that UUID's minimum, continuing to later inputs in the same iteration without counting a swap attempt. Equality SHALL qualify. It SHALL never round upward, top up from reserves or exceed either existing bound. Input selection SHALL require no input/USDC Ticker observation. Minima and maxima SHALL constrain buyback inputs only; EVE burns SHALL remain uncapped and unaffected. Native SOL reserves SHALL use observed balance without estimating future fees.
|
||||||
|
|
||||||
#### Scenario: SyrupUSDC maximum is lower than spendable balance
|
#### Scenario: SyrupUSDC maximum is lower than spendable balance
|
||||||
- **WHEN** SyrupUSDC balance is 0.5, its reserve is 0.095, and its maximum is 0.2
|
- **WHEN** SyrupUSDC balance is 0.5, its reserve is 0.095, its minimum is 0.045 and its maximum is 0.2
|
||||||
- **THEN** 0.2 SyrupUSDC is selected without requesting a SyrupUSDC/USDC observation
|
- **THEN** 0.2 SyrupUSDC is selected without requesting a SyrupUSDC/USDC observation
|
||||||
|
|
||||||
#### Scenario: SyrupUSDC spendable balance is lower than the maximum
|
#### Scenario: SyrupUSDC spendable balance is lower than the maximum
|
||||||
- **WHEN** SyrupUSDC balance is 0.245, its reserve is 0.095, and its maximum is 0.2
|
- **WHEN** SyrupUSDC balance is 0.245, its reserve is 0.095, its minimum is 0.045 and its maximum is 0.2
|
||||||
- **THEN** 0.150 SyrupUSDC is selected before downward mint-precision rounding
|
- **THEN** 0.150 SyrupUSDC is selected before downward mint-precision rounding
|
||||||
|
|
||||||
#### Scenario: Reserve consumes the balance
|
#### Scenario: Reserve consumes the balance
|
||||||
@@ -230,20 +246,62 @@ The service SHALL select the lesser of positive `balance - minimumReserve` and t
|
|||||||
- **THEN** no amount of that currency is intentionally swapped
|
- **THEN** no amount of that currency is intentionally swapped
|
||||||
|
|
||||||
#### Scenario: USDT maximum preserves the production bound
|
#### Scenario: USDT maximum preserves the production bound
|
||||||
- **WHEN** USDT balance is 0.35, its reserve is 0.1, and its maximum is 0.25
|
- **WHEN** USDT balance is 0.35, its reserve is 0.1, its minimum is 0.05 and its maximum is 0.25
|
||||||
- **THEN** 0.25 USDT is selected before downward mint-precision rounding
|
- **THEN** 0.25 USDT is selected before downward mint-precision rounding
|
||||||
|
|
||||||
#### Scenario: Precision rounding never increases the spend
|
#### Scenario: Precision rounding never increases the spend
|
||||||
- **WHEN** the lesser of spendable balance and maximum is 0.1509999 and the mint supports six decimals
|
- **WHEN** the lesser of spendable balance and maximum is 0.1509999, the mint supports six decimals and its minimum is no greater than 0.150999
|
||||||
- **THEN** the service selects 0.150999, and if downward rounding instead produces zero the input is skipped
|
- **THEN** the service selects 0.150999, and if downward rounding instead produces zero the input is skipped
|
||||||
|
|
||||||
#### Scenario: Native transaction fees
|
#### Scenario: Native transaction fees
|
||||||
- **WHEN** native SOL is selected with a configured reserve
|
- **WHEN** native SOL is selected with a configured reserve
|
||||||
- **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
|
- **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
|
||||||
|
|
||||||
|
#### Scenario: USDT below and at its minimum
|
||||||
|
- **WHEN** USDT reserve is 0.1 and minimum is 0.05
|
||||||
|
- **THEN** balances 0.14 and 0.149999 are skipped, while balance 0.15 selects exactly 0.05
|
||||||
|
|
||||||
|
#### Scenario: Tiny first input permits useful later input
|
||||||
|
- **WHEN** USDT balance is 0.100001 with reserve 0.1/minimum 0.05, and SyrupUSDC balance is 0.502048 with reserve 0.095/minimum 0.045/maximum 0.2
|
||||||
|
- **THEN** the USDT remainder is skipped and 0.2 SyrupUSDC is the only attempted swap
|
||||||
|
|
||||||
|
#### Scenario: SyrupUSDC minimum boundary
|
||||||
|
- **WHEN** SyrupUSDC reserve is 0.095 and minimum is 0.045
|
||||||
|
- **THEN** balance 0.14 selects exactly 0.045 while 0.139999 is skipped
|
||||||
|
|
||||||
|
#### Scenario: Actual rounded candidate falls below a fractional minimum
|
||||||
|
- **WHEN** six-decimal input precision rounds candidate 0.0500009 down to 0.050000 and its minimum is 0.0500001
|
||||||
|
- **THEN** the candidate is skipped without rounding up or using reserves
|
||||||
|
|
||||||
|
#### Scenario: Input minima do not suppress direct EVE burn
|
||||||
|
- **WHEN** every configured input is below its minimum but the wallet contains EVE with a usable EVE/USDT price
|
||||||
|
- **THEN** the complete EVE balance is still evaluated and burned by the existing rules
|
||||||
|
|
||||||
### Requirement: Production burner accepts USDT and SyrupUSDC
|
### 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, minimum input 0.05 and maximum input 0.25 USDT, and SyrupUSDC second with reserve 0.095, minimum input 0.045 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
|
#### Scenario: Production inputs and settings
|
||||||
- **WHEN** the Hub constructs the production burner
|
- **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
|
- **THEN** it supplies the exact reserves, minima 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
|
||||||
|
|||||||
@@ -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
|
#### Scenario: Provider succeeds but confirmation times out
|
||||||
- **WHEN** a fully validated Jupiter execution response contains a signature whose independent `CONFIRMED` outcome is `TIMED_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
|
### 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.
|
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
|
- **THEN** construction fails before any operation can be submitted
|
||||||
|
|
||||||
### Requirement: Post-submission communication and interruption policy
|
### 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
|
#### Scenario: Confirmation communication fails
|
||||||
- **WHEN** independent confirmation throws an I/O error for a known submitted signature
|
- **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
|
#### Scenario: Confirmation is interrupted
|
||||||
- **WHEN** independent confirmation throws `InterruptedException`
|
- **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
|
### 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.
|
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
|
#### Scenario: Downstream API consumer
|
||||||
- **WHEN** downstream code depends only on the Jupiter swap API package
|
- **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
|
- **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
|
#### Scenario: Repeated awaits through cached decorator
|
||||||
- **WHEN** callers invoke transaction awaiting multiple times through the cached decorator
|
- **WHEN** callers invoke transaction awaiting multiple times through the cached decorator
|
||||||
- **THEN** every invocation reaches the delegate independently
|
- **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
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ import java.util.List;
|
|||||||
* {@link MoneyAmount#currencyType() currency type}'s stable AssetAZ UUID is
|
* {@link MoneyAmount#currencyType() currency type}'s stable AssetAZ UUID is
|
||||||
* the authoritative identity, and each {@link MoneyAmount#amount() amount} is
|
* the authoritative identity, and each {@link MoneyAmount#amount() amount} is
|
||||||
* the minimum balance that must remain. List order is currency priority.
|
* the minimum balance that must remain. List order is currency priority.
|
||||||
* Each accepted input has exactly one strictly positive maximum buyback amount
|
* Each accepted input has exactly one strictly positive minimum and maximum
|
||||||
* supplied as another list of {@link MoneyAmount} values, matched by UUID
|
* buyback amount supplied as two further lists of {@link MoneyAmount} values,
|
||||||
* independently of list order. Both configurations are copied defensively.
|
* matched by UUID independently of list order/name/symbol. The minimum must not
|
||||||
* Maxima are expressed in the input currency's own units, without guaranteeing
|
* exceed its maximum. All three configurations are copied defensively.
|
||||||
|
* Minima and maxima are expressed in the input currency's own units, without guaranteeing
|
||||||
* a particular USD value.
|
* a particular USD value.
|
||||||
* Solana mint addresses and token programs are integration details resolved
|
* Solana mint addresses and token programs are integration details resolved
|
||||||
* internally through the Currency Identity Service and Solana and are not
|
* internally through the Currency Identity Service and Solana and are not
|
||||||
@@ -29,7 +30,11 @@ import java.util.List;
|
|||||||
* iteration; definitive failure is logged without notification; success
|
* iteration; definitive failure is logged without notification; success
|
||||||
* receives the burn's single notification attempt. No new swap or burn is
|
* receives the burn's single notification attempt. No new swap or burn is
|
||||||
* submitted in an iteration that begins with a pending burn.</li>
|
* 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
|
* order. Canonicalize each AssetAZ UUID, resolve its native-SOL or SPL
|
||||||
* representation internally and apply its own minimum reserve without an
|
* representation internally and apply its own minimum reserve without an
|
||||||
* input-price lookup. Missing or unusable candidate data does not
|
* input-price lookup. Missing or unusable candidate data does not
|
||||||
@@ -37,12 +42,17 @@ import java.util.List;
|
|||||||
* <li>Select at most that input's configured token maximum, rounded down
|
* <li>Select at most that input's configured token maximum, rounded down
|
||||||
* to the input currency's supported precision. Native SOL uses its native
|
* to the input currency's supported precision. Native SOL uses its native
|
||||||
* balance and nine-decimal precision; no future transaction fee is
|
* balance and nine-decimal precision; no future transaction fee is
|
||||||
* estimated when applying its reserve.</li>
|
* estimated when applying its reserve. Skip actual rounded candidates
|
||||||
|
* below that input's minimum and continue to later inputs in the same
|
||||||
|
* iteration; equality qualifies. Never round upward or use the reserve
|
||||||
|
* to reach a minimum. Input minima do not suppress EVE burns.</li>
|
||||||
* <li>Attempt no more than one exact-input Jupiter swap. Normal return is
|
* <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
|
* 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
|
* failure or proven pre-submission error permits a later iteration to try
|
||||||
* potentially unknown post-submission result suspends swaps for this
|
* again. A known-signature timeout or communication error retains pending
|
||||||
* lifecycle.</li>
|
* 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
|
* <li>Reload the complete visible Evelyn IOU balance, including tokens
|
||||||
* deposited or acquired before this iteration. Absence or failure of a
|
* deposited or acquired before this iteration. Absence or failure of a
|
||||||
* swap does not suppress this step, and the input buyback limit never caps
|
* swap does not suppress this step, and the input buyback limit never caps
|
||||||
|
|||||||
+149
-97
@@ -9,11 +9,16 @@ import com.r35157.assetaz.valuetypes.CurrencyType;
|
|||||||
import com.r35157.cryptowallet.solana.SolanaWallet;
|
import com.r35157.cryptowallet.solana.SolanaWallet;
|
||||||
import com.r35157.libs.jupiter.JupiterTransactionOutcomeException;
|
import com.r35157.libs.jupiter.JupiterTransactionOutcomeException;
|
||||||
import com.r35157.libs.jupiter.swap.JupiterSwapService;
|
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.SPLTokenSupply;
|
||||||
import com.r35157.libs.solana.SolanaAccountInfo;
|
import com.r35157.libs.solana.SolanaAccountInfo;
|
||||||
import com.r35157.libs.solana.SolanaBlockChain;
|
import com.r35157.libs.solana.SolanaBlockChain;
|
||||||
import com.r35157.libs.solana.SolanaCommitment;
|
import com.r35157.libs.solana.SolanaCommitment;
|
||||||
import com.r35157.libs.solana.SolanaTransactionOutcome;
|
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.solana.valuetypes.economic.SolanaSPLTokenProgram;
|
||||||
import com.r35157.libs.valuetypes.basic.AssetPrice;
|
import com.r35157.libs.valuetypes.basic.AssetPrice;
|
||||||
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
||||||
@@ -73,6 +78,13 @@ public final class EvelynIOUBurnerServiceImpl
|
|||||||
* that input currency's own units, without an input-price conversion or
|
* that input currency's own units, without an input-price conversion or
|
||||||
* a guarantee about its USD value.</p>
|
* a guarantee about its USD value.</p>
|
||||||
*
|
*
|
||||||
|
* <p>{@code inputCurrencyMinimumSwapAmounts} follows the same canonical
|
||||||
|
* UUID matching and immutable-copy rules. Every accepted input needs one
|
||||||
|
* strictly positive minimum no greater than its maximum. A rounded
|
||||||
|
* candidate below the minimum is skipped so later inputs remain eligible;
|
||||||
|
* equality qualifies without rounding up or spending the reserve. Three
|
||||||
|
* empty input lists permit direct-EVE-only burning.</p>
|
||||||
|
*
|
||||||
* @param burnerWallet dedicated wallet whose balances are inspected and
|
* @param burnerWallet dedicated wallet whose balances are inspected and
|
||||||
* whose complete EVE balance is burned
|
* whose complete EVE balance is burned
|
||||||
* @param swapService exact-input Jupiter service; normal return already
|
* @param swapService exact-input Jupiter service; normal return already
|
||||||
@@ -89,6 +101,10 @@ public final class EvelynIOUBurnerServiceImpl
|
|||||||
* non-null reserve amount greater than or equal to zero; every
|
* non-null reserve amount greater than or equal to zero; every
|
||||||
* non-SOL UUID must have exactly one non-blank CIS
|
* non-SOL UUID must have exactly one non-blank CIS
|
||||||
* {@code solana-mint} external reference
|
* {@code solana-mint} external reference
|
||||||
|
* @param inputCurrencyMinimumSwapAmounts exactly one strictly positive
|
||||||
|
* token-denominated buyback minimum per accepted input UUID, no
|
||||||
|
* greater than its maximum; qualification uses the actual rounded
|
||||||
|
* input amount and does not restrict burns
|
||||||
* @param inputCurrencyMaximumSwapAmounts exactly one strictly positive
|
* @param inputCurrencyMaximumSwapAmounts exactly one strictly positive
|
||||||
* token-denominated buyback maximum per accepted input UUID;
|
* token-denominated buyback maximum per accepted input UUID;
|
||||||
* it does not limit burns
|
* it does not limit burns
|
||||||
@@ -101,14 +117,16 @@ public final class EvelynIOUBurnerServiceImpl
|
|||||||
* @param iterationInterval strictly positive fixed delay measured from one
|
* @param iterationInterval strictly positive fixed delay measured from one
|
||||||
* completed iteration to the next
|
* completed iteration to the next
|
||||||
* @throws NullPointerException if an injected service, configuration
|
* @throws NullPointerException if an injected service, configuration
|
||||||
* object, list entry, reserve or maximum amount,
|
* object, list entry, reserve, minimum or maximum amount,
|
||||||
* configured currency, or UUID is null
|
* configured currency, or UUID is null
|
||||||
* @throws IllegalArgumentException if a reserve or operating limit is out of
|
* @throws IllegalArgumentException if a reserve or operating limit is out of
|
||||||
* range, a duration is non-positive, a
|
* range, a duration is non-positive, a
|
||||||
* UUID is unknown or duplicated, EVE is
|
* UUID is unknown or duplicated, EVE is
|
||||||
* configured as input, or a non-SOL input
|
* configured as input, or a non-SOL input
|
||||||
* lacks exactly one usable Solana mint,
|
* lacks exactly one usable Solana mint,
|
||||||
* or maximum UUIDs do not match inputs
|
* or minimum/maximum UUIDs do not match inputs,
|
||||||
|
* a swap bound is non-positive, or a
|
||||||
|
* minimum exceeds its maximum
|
||||||
*/
|
*/
|
||||||
public EvelynIOUBurnerServiceImpl(
|
public EvelynIOUBurnerServiceImpl(
|
||||||
@NotNull SolanaWallet burnerWallet,
|
@NotNull SolanaWallet burnerWallet,
|
||||||
@@ -118,6 +136,7 @@ public final class EvelynIOUBurnerServiceImpl
|
|||||||
@NotNull CurrencyIdentityService currencyIdentityService,
|
@NotNull CurrencyIdentityService currencyIdentityService,
|
||||||
@NotNull BoundNotificationService notificationService,
|
@NotNull BoundNotificationService notificationService,
|
||||||
@NotNull List<MoneyAmount> inputCurrencyMinimumReserves,
|
@NotNull List<MoneyAmount> inputCurrencyMinimumReserves,
|
||||||
|
@NotNull List<MoneyAmount> inputCurrencyMinimumSwapAmounts,
|
||||||
@NotNull List<MoneyAmount> inputCurrencyMaximumSwapAmounts,
|
@NotNull List<MoneyAmount> inputCurrencyMaximumSwapAmounts,
|
||||||
int maximumSlippageBps,
|
int maximumSlippageBps,
|
||||||
ΩmilliSecondsΩ maximumPriceAge,
|
ΩmilliSecondsΩ maximumPriceAge,
|
||||||
@@ -199,9 +218,19 @@ public final class EvelynIOUBurnerServiceImpl
|
|||||||
this.inputCurrencyMinimumReserves = validateAndCopyInputs(
|
this.inputCurrencyMinimumReserves = validateAndCopyInputs(
|
||||||
inputCurrencyMinimumReserves
|
inputCurrencyMinimumReserves
|
||||||
);
|
);
|
||||||
this.inputCurrencyMaximumSwapAmounts = validateAndCopyMaximums(
|
this.inputCurrencyMaximumSwapAmounts = validateAndCopySwapAmounts(
|
||||||
inputCurrencyMaximumSwapAmounts
|
inputCurrencyMaximumSwapAmounts, "inputCurrencyMaximumSwapAmounts"
|
||||||
);
|
);
|
||||||
|
this.inputCurrencyMinimumSwapAmounts = validateAndCopySwapAmounts(
|
||||||
|
inputCurrencyMinimumSwapAmounts, "inputCurrencyMinimumSwapAmounts"
|
||||||
|
);
|
||||||
|
for (Map.Entry<UUID, MoneyAmount> minimum : this.inputCurrencyMinimumSwapAmounts.entrySet()) {
|
||||||
|
if (minimum.getValue().amount().compareTo(
|
||||||
|
this.inputCurrencyMaximumSwapAmounts.get(minimum.getKey()).amount()) > 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Minimum swap amount exceeds maximum for input currency UUID: " + minimum.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -271,7 +300,10 @@ public final class EvelynIOUBurnerServiceImpl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!swapsSuspended) {
|
if (pendingSwap != null) {
|
||||||
|
reconcilePendingSwap();
|
||||||
|
}
|
||||||
|
if (!swapsSuspended && pendingSwap == null) {
|
||||||
attemptAtMostOneSwap();
|
attemptAtMostOneSwap();
|
||||||
}
|
}
|
||||||
processCompleteEveBalance();
|
processCompleteEveBalance();
|
||||||
@@ -377,7 +409,8 @@ public final class EvelynIOUBurnerServiceImpl
|
|||||||
ΩAmountΩ amountToSwap = availableForSwap
|
ΩAmountΩ amountToSwap = availableForSwap
|
||||||
.min(maximumInputAmount)
|
.min(maximumInputAmount)
|
||||||
.setScale(decimals, RoundingMode.DOWN);
|
.setScale(decimals, RoundingMode.DOWN);
|
||||||
if (amountToSwap.signum() <= 0) {
|
if (amountToSwap.signum() <= 0
|
||||||
|
|| amountToSwap.compareTo(inputCurrencyMinimumSwapAmounts.get(currencyId).amount()) < 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,61 +429,94 @@ public final class EvelynIOUBurnerServiceImpl
|
|||||||
ΩAmountΩ amountToSwap
|
ΩAmountΩ amountToSwap
|
||||||
) throws InterruptedException {
|
) throws InterruptedException {
|
||||||
try {
|
try {
|
||||||
swapService.swap(
|
swapService.swap(inputMint, amountToSwap, EVELYN_IOU_MINT, maximumSlippageBps);
|
||||||
inputMint,
|
|
||||||
amountToSwap,
|
|
||||||
EVELYN_IOU_MINT,
|
|
||||||
maximumSlippageBps
|
|
||||||
);
|
|
||||||
} catch (JupiterTransactionOutcomeException exception) {
|
} catch (JupiterTransactionOutcomeException exception) {
|
||||||
SolanaTransactionOutcome outcome = exception.outcome();
|
SolanaTransactionOutcome outcome = exception.outcome();
|
||||||
switch (outcome.status()) {
|
switch (outcome.status()) {
|
||||||
case FAILED -> log.error(
|
case FAILED -> log.error(
|
||||||
"Evelyn IOU buyback swap failed on-chain: "
|
"Evelyn IOU buyback failed on-chain: currency={}/{}, amount={}, signature={}, slot={}, failureDetails={}; nextAction=fresh selection next iteration",
|
||||||
+ "currencyId={}, signature={}, slot={}, "
|
canonicalCurrency.symbol(), canonicalCurrency.id(), amountToSwap,
|
||||||
+ "failureDetails={}",
|
exception.transactionSignature(), outcome.slot(), outcome.failureDetails());
|
||||||
canonicalCurrency.id(),
|
case TIMED_OUT -> retainUnknownSwap(canonicalCurrency, amountToSwap,
|
||||||
exception.transactionSignature(),
|
exception.transactionSignature(), exception.validity(), "confirmation timeout; outcome unknown", exception);
|
||||||
outcome.slot(),
|
case SUCCEEDED -> throw new IllegalStateException("A successful Jupiter outcome was reported as an exception");
|
||||||
outcome.failureDetails()
|
|
||||||
);
|
|
||||||
case TIMED_OUT -> {
|
|
||||||
swapsSuspended = true;
|
|
||||||
log.error(
|
|
||||||
"Evelyn IOU buyback swap outcome is unknown after "
|
|
||||||
+ "timeout; later swaps are suspended: "
|
|
||||||
+ "currencyId={}, signature={}",
|
|
||||||
canonicalCurrency.id(),
|
|
||||||
exception.transactionSignature()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
case SUCCEEDED -> throw new IllegalStateException(
|
} catch (JupiterSwapSubmissionException exception) {
|
||||||
"A successful Jupiter outcome was reported as an "
|
if (exception.submissionMayHaveOccurred()) {
|
||||||
+ "exception"
|
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) {
|
} catch (IOException exception) {
|
||||||
swapsSuspended = true;
|
retainUnknownSwap(canonicalCurrency, amountToSwap, null, null,
|
||||||
log.error(
|
"unclassified I/O; submission outcome unknown", exception);
|
||||||
"Evelyn IOU buyback swap may have an unknown outcome; "
|
|
||||||
+ "later swaps are suspended: currencyId={}, "
|
|
||||||
+ "failureType={}",
|
|
||||||
canonicalCurrency.id(),
|
|
||||||
exception.getClass().getName()
|
|
||||||
);
|
|
||||||
} catch (InterruptedException exception) {
|
} catch (InterruptedException exception) {
|
||||||
swapsSuspended = true;
|
retainUnknownSwap(canonicalCurrency, amountToSwap, null, null,
|
||||||
|
"unclassified interruption; submission outcome unknown", exception);
|
||||||
throw exception;
|
throw exception;
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
log.error(
|
retainUnknownSwap(canonicalCurrency, amountToSwap, null, null,
|
||||||
"Evelyn IOU buyback swap failed before a confirmed "
|
"unclassified runtime failure; submission outcome unknown", exception);
|
||||||
+ "result: currencyId={}, failureType={}",
|
|
||||||
canonicalCurrency.id(),
|
|
||||||
exception.getClass().getName()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
private void processCompleteEveBalance() throws InterruptedException {
|
||||||
ΩAmountΩ eveBalance;
|
ΩAmountΩ eveBalance;
|
||||||
try {
|
try {
|
||||||
@@ -818,70 +884,45 @@ public final class EvelynIOUBurnerServiceImpl
|
|||||||
return List.copyOf(validatedInputs);
|
return List.copyOf(validatedInputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<UUID, MoneyAmount> validateAndCopyMaximums(
|
private Map<UUID, MoneyAmount> validateAndCopySwapAmounts(
|
||||||
List<MoneyAmount> configuredMaximums
|
List<MoneyAmount> configuredAmounts,
|
||||||
|
String configurationName
|
||||||
) {
|
) {
|
||||||
Objects.requireNonNull(
|
Objects.requireNonNull(configuredAmounts, configurationName);
|
||||||
configuredMaximums,
|
|
||||||
"inputCurrencyMaximumSwapAmounts"
|
|
||||||
);
|
|
||||||
Set<UUID> acceptedCurrencyIds = new HashSet<>();
|
Set<UUID> acceptedCurrencyIds = new HashSet<>();
|
||||||
for (MoneyAmount minimumReserve : inputCurrencyMinimumReserves) {
|
for (MoneyAmount minimumReserve : inputCurrencyMinimumReserves) {
|
||||||
acceptedCurrencyIds.add(minimumReserve.currencyType().id());
|
acceptedCurrencyIds.add(minimumReserve.currencyType().id());
|
||||||
}
|
}
|
||||||
Map<UUID, MoneyAmount> validatedMaximums = new HashMap<>();
|
Map<UUID, MoneyAmount> validatedAmounts = new HashMap<>();
|
||||||
|
|
||||||
for (int index = 0; index < configuredMaximums.size(); index++) {
|
for (int index = 0; index < configuredAmounts.size(); index++) {
|
||||||
MoneyAmount maximum = Objects.requireNonNull(
|
String entryName = configurationName + "[" + index + "]";
|
||||||
configuredMaximums.get(index),
|
MoneyAmount bound = Objects.requireNonNull(configuredAmounts.get(index), entryName);
|
||||||
"inputCurrencyMaximumSwapAmounts[" + index + "]"
|
Objects.requireNonNull(bound.amount(), entryName + ".amount");
|
||||||
);
|
if (bound.amount().signum() <= 0) {
|
||||||
Objects.requireNonNull(
|
throw new IllegalArgumentException(entryName + " amount must be greater than zero");
|
||||||
maximum.amount(),
|
|
||||||
"inputCurrencyMaximumSwapAmounts[" + index + "].amount"
|
|
||||||
);
|
|
||||||
if (maximum.amount().signum() <= 0) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"Input currency maximum swap amount must be greater "
|
|
||||||
+ "than zero at index " + index
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CurrencyType configuredCurrency = Objects.requireNonNull(
|
CurrencyType configuredCurrency = Objects.requireNonNull(
|
||||||
maximum.currencyType(),
|
bound.currencyType(), entryName + ".currencyType");
|
||||||
"inputCurrencyMaximumSwapAmounts[" + index
|
UUID currencyId = Objects.requireNonNull(configuredCurrency.id(), entryName + ".currencyType.id");
|
||||||
+ "].currencyType"
|
CurrencyType canonicalCurrency = requireCanonicalCurrency(currencyId, configurationName);
|
||||||
);
|
|
||||||
UUID currencyId = Objects.requireNonNull(
|
|
||||||
configuredCurrency.id(),
|
|
||||||
"inputCurrencyMaximumSwapAmounts[" + index
|
|
||||||
+ "].currencyType.id"
|
|
||||||
);
|
|
||||||
requireCanonicalCurrency(currencyId, "configured input maximum");
|
|
||||||
|
|
||||||
if (!acceptedCurrencyIds.contains(currencyId)) {
|
if (!acceptedCurrencyIds.contains(currencyId)) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(configurationName
|
||||||
"Maximum swap amount configured for unaccepted input "
|
+ " configured for unaccepted input currency UUID: " + currencyId);
|
||||||
+ "currency UUID: " + currencyId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (validatedMaximums.putIfAbsent(currencyId, maximum) != null) {
|
if (validatedAmounts.putIfAbsent(currencyId,
|
||||||
throw new IllegalArgumentException(
|
new MoneyAmount(bound.amount(), canonicalCurrency)) != null) {
|
||||||
"Duplicate maximum swap amount currency UUID: "
|
throw new IllegalArgumentException("Duplicate " + configurationName
|
||||||
+ currencyId
|
+ " currency UUID: " + currencyId);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (UUID currencyId : acceptedCurrencyIds) {
|
for (UUID currencyId : acceptedCurrencyIds) {
|
||||||
if (!validatedMaximums.containsKey(currencyId)) {
|
if (!validatedAmounts.containsKey(currencyId)) {
|
||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException("Missing " + configurationName
|
||||||
"Missing maximum swap amount for input currency UUID: "
|
+ " for input currency UUID: " + currencyId);
|
||||||
+ currencyId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Map.copyOf(validatedMaximums);
|
return Map.copyOf(validatedAmounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private CurrencyType requireCanonicalCurrency(
|
private CurrencyType requireCanonicalCurrency(
|
||||||
@@ -990,6 +1031,7 @@ public final class EvelynIOUBurnerServiceImpl
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void resetProcessLocalState() {
|
private void resetProcessLocalState() {
|
||||||
|
pendingSwap = null;
|
||||||
pendingBurn = null;
|
pendingBurn = null;
|
||||||
swapsSuspended = false;
|
swapsSuspended = false;
|
||||||
burnsSuspended = false;
|
burnsSuspended = false;
|
||||||
@@ -1015,6 +1057,7 @@ public final class EvelynIOUBurnerServiceImpl
|
|||||||
private final CurrencyIdentityService currencyIdentityService;
|
private final CurrencyIdentityService currencyIdentityService;
|
||||||
private final BoundNotificationService notificationService;
|
private final BoundNotificationService notificationService;
|
||||||
private final List<MoneyAmount> inputCurrencyMinimumReserves;
|
private final List<MoneyAmount> inputCurrencyMinimumReserves;
|
||||||
|
private final Map<UUID, MoneyAmount> inputCurrencyMinimumSwapAmounts;
|
||||||
private final Map<UUID, MoneyAmount> inputCurrencyMaximumSwapAmounts;
|
private final Map<UUID, MoneyAmount> inputCurrencyMaximumSwapAmounts;
|
||||||
private final int maximumSlippageBps;
|
private final int maximumSlippageBps;
|
||||||
private final ΩmilliSecondsΩ maximumPriceAge;
|
private final ΩmilliSecondsΩ maximumPriceAge;
|
||||||
@@ -1024,6 +1067,15 @@ public final class EvelynIOUBurnerServiceImpl
|
|||||||
private final TradingPair eveUsdtTradingPair;
|
private final TradingPair eveUsdtTradingPair;
|
||||||
|
|
||||||
private ScheduledExecutorService worker;
|
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 swapsSuspended;
|
||||||
private boolean burnsSuspended;
|
private boolean burnsSuspended;
|
||||||
private PendingBurn pendingBurn;
|
private PendingBurn pendingBurn;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.r35157.libs.jupiter;
|
|||||||
|
|
||||||
import com.r35157.libs.solana.SolanaCommitment;
|
import com.r35157.libs.solana.SolanaCommitment;
|
||||||
import com.r35157.libs.solana.SolanaTransactionOutcome;
|
import com.r35157.libs.solana.SolanaTransactionOutcome;
|
||||||
|
import com.r35157.libs.solana.SolanaTransactionValidity;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -29,6 +30,16 @@ public final class JupiterTransactionOutcomeException extends Exception {
|
|||||||
@NotNull ΩSolanaTransactionSignatureΩ transactionSignature,
|
@NotNull ΩSolanaTransactionSignatureΩ transactionSignature,
|
||||||
@NotNull SolanaCommitment requestedCommitment,
|
@NotNull SolanaCommitment requestedCommitment,
|
||||||
@NotNull SolanaTransactionOutcome outcome
|
@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(
|
super(buildMessage(
|
||||||
requireSignature(transactionSignature),
|
requireSignature(transactionSignature),
|
||||||
@@ -41,6 +52,7 @@ public final class JupiterTransactionOutcomeException extends Exception {
|
|||||||
this.transactionSignature = transactionSignature;
|
this.transactionSignature = transactionSignature;
|
||||||
this.requestedCommitment = requestedCommitment;
|
this.requestedCommitment = requestedCommitment;
|
||||||
this.outcome = outcome;
|
this.outcome = outcome;
|
||||||
|
this.validity = validity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +82,9 @@ public final class JupiterTransactionOutcomeException extends Exception {
|
|||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Original validity, or null for callers without that context. */
|
||||||
|
public SolanaTransactionValidity validity() { return validity; }
|
||||||
|
|
||||||
private static ΩSolanaTransactionSignatureΩ requireSignature(
|
private static ΩSolanaTransactionSignatureΩ requireSignature(
|
||||||
ΩSolanaTransactionSignatureΩ transactionSignature
|
ΩSolanaTransactionSignatureΩ transactionSignature
|
||||||
) {
|
) {
|
||||||
@@ -127,4 +142,5 @@ public final class JupiterTransactionOutcomeException extends Exception {
|
|||||||
private final ΩSolanaTransactionSignatureΩ transactionSignature;
|
private final ΩSolanaTransactionSignatureΩ transactionSignature;
|
||||||
private final SolanaCommitment requestedCommitment;
|
private final SolanaCommitment requestedCommitment;
|
||||||
private final SolanaTransactionOutcome outcome;
|
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
|
* basis points
|
||||||
* @return confirmed transaction signature and actual amounts spent and
|
* @return confirmed transaction signature and actual amounts spent and
|
||||||
* received
|
* 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
|
* @throws IOException if Solana or Jupiter communication, response
|
||||||
* decoding, signing, execution, or confirmation fails;
|
* 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
|
* after submission the on-chain outcome can be unknown
|
||||||
* @throws JupiterTransactionOutcomeException if the submitted transaction
|
* @throws JupiterTransactionOutcomeException if the submitted transaction
|
||||||
* definitively fails on-chain or confirmation times out with an
|
* 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;
|
||||||
|
}
|
||||||
+181
-115
@@ -7,6 +7,8 @@ import com.r35157.cryptowallet.solana.SolanaWallet;
|
|||||||
import com.r35157.libs.jupiter.JupiterTransactionOutcomeException;
|
import com.r35157.libs.jupiter.JupiterTransactionOutcomeException;
|
||||||
import com.r35157.libs.jupiter.swap.JupiterSwapResult;
|
import com.r35157.libs.jupiter.swap.JupiterSwapResult;
|
||||||
import com.r35157.libs.jupiter.swap.JupiterSwapService;
|
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.SPLTokenSupply;
|
||||||
import com.r35157.libs.solana.SolanaAccountInfo;
|
import com.r35157.libs.solana.SolanaAccountInfo;
|
||||||
import com.r35157.libs.solana.SolanaBlockChain;
|
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.SolanaSignedTransaction;
|
||||||
import com.r35157.libs.solana.SolanaTransactionOutcome;
|
import com.r35157.libs.solana.SolanaTransactionOutcome;
|
||||||
import com.r35157.libs.solana.SolanaUnsignedTransaction;
|
import com.r35157.libs.solana.SolanaUnsignedTransaction;
|
||||||
|
import com.r35157.libs.solana.SolanaTransactionValidity;
|
||||||
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
|
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
|
||||||
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
import com.r35157.libs.valuetypes.basic.MoneyAmount;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
@@ -29,6 +32,7 @@ import java.net.http.HttpResponse;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
@@ -91,6 +95,12 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
|
|||||||
int maxSlippageBps
|
int maxSlippageBps
|
||||||
) throws IOException, JupiterTransactionOutcomeException,
|
) throws IOException, JupiterTransactionOutcomeException,
|
||||||
InterruptedException {
|
InterruptedException {
|
||||||
|
MintMetadata inputMetadata;
|
||||||
|
MintMetadata outputMetadata;
|
||||||
|
ValidatedOrder validatedOrder;
|
||||||
|
SolanaSignedTransaction signedTransaction;
|
||||||
|
SolanaTransactionValidity validity;
|
||||||
|
try {
|
||||||
validateArguments(
|
validateArguments(
|
||||||
inputTokenMint,
|
inputTokenMint,
|
||||||
inputTokenAmount,
|
inputTokenAmount,
|
||||||
@@ -98,94 +108,182 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
|
|||||||
maxSlippageBps
|
maxSlippageBps
|
||||||
);
|
);
|
||||||
|
|
||||||
MintMetadata inputMetadata = resolveMint(inputTokenMint);
|
inputMetadata = resolveMint(inputTokenMint);
|
||||||
MintMetadata outputMetadata = resolveMint(outputTokenMint);
|
outputMetadata = resolveMint(outputTokenMint);
|
||||||
BigInteger rawInputAmount = toRawInputAmount(
|
BigInteger rawInputAmount = toRawInputAmount(inputTokenAmount, inputMetadata.decimals());
|
||||||
inputTokenAmount,
|
|
||||||
inputMetadata.decimals()
|
|
||||||
);
|
|
||||||
ΩSolanaWalletIdΩ taker = solanaWallet.getAddress();
|
ΩSolanaWalletIdΩ taker = solanaWallet.getAddress();
|
||||||
|
// Capture before obtaining the order: even a signer delay must not
|
||||||
OrderResponse order = requestOrder(
|
// move the anchor past a transaction's earliest possible execution.
|
||||||
inputTokenMint,
|
ΩSolanaSlotΩ submissionAnchor = solanaBlockChain.getFinalizedSlot();
|
||||||
outputTokenMint,
|
OrderResponse order = requestOrder(inputTokenMint, outputTokenMint,
|
||||||
rawInputAmount,
|
rawInputAmount, taker, maxSlippageBps);
|
||||||
taker,
|
validatedOrder = validateOrder(order, inputTokenMint, outputTokenMint,
|
||||||
maxSlippageBps
|
rawInputAmount, taker, maxSlippageBps);
|
||||||
);
|
ΩSolanaBlockhashΩ originalHash = extractRecentBlockhash(validatedOrder.transaction(), taker);
|
||||||
ValidatedOrder validatedOrder = validateOrder(
|
validity = new SolanaTransactionValidity(originalHash,
|
||||||
order,
|
validatedOrder.lastValidBlockHeight(), taker, submissionAnchor);
|
||||||
inputTokenMint,
|
signedTransaction = solanaWallet.signTransaction(new SolanaUnsignedTransaction(
|
||||||
outputTokenMint,
|
validatedOrder.transaction(), originalHash, validatedOrder.lastValidBlockHeight()));
|
||||||
rawInputAmount,
|
if (signedTransaction == null || !originalHash.equals(
|
||||||
taker,
|
extractRecentBlockhash(signedTransaction.serializedTransaction(), taker))) {
|
||||||
maxSlippageBps
|
throw new IOException("Signer changed the original Jupiter blockhash");
|
||||||
);
|
}
|
||||||
|
} catch (IOException | RuntimeException exception) {
|
||||||
SolanaUnsignedTransaction unsignedTransaction =
|
throw new JupiterSwapSubmissionException(false, null, null, exception,
|
||||||
new SolanaUnsignedTransaction(
|
exception instanceof SafeProviderFailure ? exception.getMessage() : "preparation");
|
||||||
validatedOrder.transaction(),
|
} catch (InterruptedException exception) {
|
||||||
null,
|
throw new JupiterSwapInterruptedException(false, null, null, exception);
|
||||||
validatedOrder.lastValidBlockHeight()
|
|
||||||
);
|
|
||||||
SolanaSignedTransaction signedTransaction =
|
|
||||||
solanaWallet.signTransaction(unsignedTransaction);
|
|
||||||
|
|
||||||
if (signedTransaction == null
|
|
||||||
|| signedTransaction.serializedTransaction() == null
|
|
||||||
|| signedTransaction.serializedTransaction().isBlank()) {
|
|
||||||
throw new IOException(
|
|
||||||
"Solana wallet returned a blank signed Jupiter "
|
|
||||||
+ "transaction"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ExecuteResponse execution = executeOnce(
|
ΩSolanaTransactionSignatureΩ localSignature = firstSignedSignature(signedTransaction.serializedTransaction());
|
||||||
signedTransaction.serializedTransaction(),
|
ΩSolanaTransactionSignatureΩ knownSignature = localSignature;
|
||||||
validatedOrder.requestId(),
|
if (localSignature != null) {
|
||||||
validatedOrder.lastValidBlockHeight()
|
validity = new SolanaTransactionValidity(validity.recentBlockhash(), validity.lastValidBlockHeight(),
|
||||||
);
|
validity.signerAddress(), validity.earliestSubmissionSlot(), true);
|
||||||
|
}
|
||||||
JupiterSwapResult result = validateExecution(
|
try {
|
||||||
execution,
|
ExecuteResponse execution = executeOnce(signedTransaction.serializedTransaction(),
|
||||||
inputMetadata.decimals(),
|
validatedOrder.requestId(), validatedOrder.lastValidBlockHeight());
|
||||||
outputMetadata.decimals()
|
// A malformed result must not discard a usable submission identity.
|
||||||
);
|
ΩSolanaTransactionSignatureΩ providerSignature = execution == null ? null : trustworthySignature(execution.signature());
|
||||||
awaitConfirmed(result.transactionSignature());
|
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;
|
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(
|
private void awaitConfirmed(
|
||||||
ΩSolanaTransactionSignatureΩ transactionSignature
|
ΩSolanaTransactionSignatureΩ transactionSignature,
|
||||||
) throws IOException, JupiterTransactionOutcomeException,
|
SolanaTransactionValidity validity
|
||||||
InterruptedException {
|
) throws IOException, JupiterTransactionOutcomeException, InterruptedException {
|
||||||
SolanaTransactionOutcome outcome;
|
SolanaTransactionOutcome outcome;
|
||||||
try {
|
try {
|
||||||
outcome = solanaBlockChain.awaitTransaction(
|
outcome = solanaBlockChain.awaitTransaction(transactionSignature,
|
||||||
transactionSignature,
|
SolanaCommitment.CONFIRMED, transactionConfirmationTimeout);
|
||||||
SolanaCommitment.CONFIRMED,
|
} catch (IOException exception) {
|
||||||
transactionConfirmationTimeout
|
throw new JupiterSwapSubmissionException(true, transactionSignature, validity,
|
||||||
);
|
exception, "confirmation");
|
||||||
} catch (IOException e) {
|
} catch (InterruptedException exception) {
|
||||||
throw new IOException(
|
throw new JupiterSwapInterruptedException(true, transactionSignature, validity, exception);
|
||||||
"Jupiter Swap confirmation failed for transaction "
|
} catch (RuntimeException exception) {
|
||||||
+ transactionSignature
|
throw new JupiterSwapSubmissionException(true, transactionSignature, validity, exception, "confirmation");
|
||||||
+ "; the on-chain outcome is unknown",
|
}
|
||||||
e
|
switch (outcome.status()) {
|
||||||
);
|
case SUCCEEDED -> { return; }
|
||||||
|
case FAILED, TIMED_OUT -> throw new JupiterTransactionOutcomeException(
|
||||||
|
transactionSignature, SolanaCommitment.CONFIRMED, outcome, validity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (outcome.status()) {
|
private static ΩSolanaTransactionSignatureΩ trustworthySignature(String signature) {
|
||||||
case SUCCEEDED -> {
|
if (signature == null || signature.isBlank()) { return null; }
|
||||||
return;
|
try {
|
||||||
|
return decodeBase58(signature).length == 64 ? signature : null;
|
||||||
|
} catch (IllegalArgumentException exception) { return null; }
|
||||||
}
|
}
|
||||||
case FAILED, TIMED_OUT -> throw
|
|
||||||
new JupiterTransactionOutcomeException(
|
/** Parses only the static header/accounts/hash needed to bind validity to the signer. */
|
||||||
transactionSignature,
|
private static ΩSolanaBlockhashΩ extractRecentBlockhash(String serialized, String taker)
|
||||||
SolanaCommitment.CONFIRMED,
|
throws IOException {
|
||||||
outcome
|
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) {
|
private static Duration requirePositiveTimeout(Duration timeout) {
|
||||||
@@ -368,11 +466,7 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
|
|||||||
OrderResponse.class
|
OrderResponse.class
|
||||||
);
|
);
|
||||||
} catch (JsonProcessingException e) {
|
} catch (JsonProcessingException e) {
|
||||||
throw new IOException(
|
throw new SafeProviderFailure("order invalid JSON", e);
|
||||||
"Unable to decode Jupiter order response: "
|
|
||||||
+ response.body(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,10 +483,7 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
|
|||||||
}
|
}
|
||||||
if (order.errorCode() != null
|
if (order.errorCode() != null
|
||||||
|| (order.error() != null && !order.error().isBlank())) {
|
|| (order.error() != null && !order.error().isBlank())) {
|
||||||
throw new IOException(
|
throw new SafeProviderFailure("order rejected; providerCode=" + order.errorCode());
|
||||||
"Jupiter could not build the order"
|
|
||||||
+ optionalError(order)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!expectedInputMint.equals(order.inputMint())) {
|
if (!expectedInputMint.equals(order.inputMint())) {
|
||||||
throw new IOException(
|
throw new IOException(
|
||||||
@@ -559,13 +650,7 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||||
throw new IOException(
|
throw new SafeProviderFailure("execution HTTP " + response.statusCode());
|
||||||
"Jupiter execution returned HTTP "
|
|
||||||
+ response.statusCode()
|
|
||||||
+ " after submission; the swap outcome may be "
|
|
||||||
+ "unknown: "
|
|
||||||
+ response.body()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -574,12 +659,7 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
|
|||||||
ExecuteResponse.class
|
ExecuteResponse.class
|
||||||
);
|
);
|
||||||
} catch (JsonProcessingException e) {
|
} catch (JsonProcessingException e) {
|
||||||
throw new IOException(
|
throw new SafeProviderFailure("execution invalid JSON", e);
|
||||||
"Unable to decode Jupiter execution response after "
|
|
||||||
+ "submission; the swap outcome may be unknown: "
|
|
||||||
+ response.body(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,10 +674,7 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!"Success".equals(execution.status())) {
|
if (!"Success".equals(execution.status())) {
|
||||||
throw new IOException(
|
throw new SafeProviderFailure("execution rejected; providerCode=" + execution.code());
|
||||||
"Jupiter execution failed"
|
|
||||||
+ executionFailureDetails(execution)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (execution.code() == null) {
|
if (execution.code() == null) {
|
||||||
throw new IOException(
|
throw new IOException(
|
||||||
@@ -606,14 +683,9 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (execution.code() != 0) {
|
if (execution.code() != 0) {
|
||||||
throw new IOException(
|
throw new SafeProviderFailure("execution contradictory status; providerCode=" + execution.code());
|
||||||
"Jupiter execution returned Success with a non-zero "
|
|
||||||
+ "result code"
|
|
||||||
+ executionFailureDetails(execution)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (execution.signature() == null
|
if (trustworthySignature(execution.signature()) == null) {
|
||||||
|| execution.signature().isBlank()) {
|
|
||||||
throw new IOException(
|
throw new IOException(
|
||||||
"Successful Jupiter execution did not contain a "
|
"Successful Jupiter execution did not contain a "
|
||||||
+ "transaction signature"
|
+ "transaction signature"
|
||||||
@@ -662,13 +734,7 @@ public final class JupiterSwapServiceImpl implements JupiterSwapService {
|
|||||||
HttpResponse<String> response
|
HttpResponse<String> response
|
||||||
) throws IOException {
|
) throws IOException {
|
||||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||||
throw new IOException(
|
throw new SafeProviderFailure("order HTTP " + response.statusCode());
|
||||||
operation
|
|
||||||
+ " request failed with HTTP "
|
|
||||||
+ response.statusCode()
|
|
||||||
+ ": "
|
|
||||||
+ response.body()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -341,4 +341,28 @@ public interface SolanaBlockChain extends NenjimComponent {
|
|||||||
SolanaCommitment commitment,
|
SolanaCommitment commitment,
|
||||||
Duration timeout
|
Duration timeout
|
||||||
) throws IOException, InterruptedException;
|
) 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.SolanaProgramAddressSeed;
|
||||||
import com.r35157.libs.solana.SolanaSignedTransaction;
|
import com.r35157.libs.solana.SolanaSignedTransaction;
|
||||||
import com.r35157.libs.solana.SolanaTransactionOutcome;
|
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.SolanaUnsignedTransaction;
|
||||||
import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress;
|
import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress;
|
||||||
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
|
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
|
||||||
@@ -213,6 +215,21 @@ public final class CachedSolanaBlockChain implements SolanaBlockChain {
|
|||||||
return delegate.sendTransaction(transaction);
|
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
|
@Override
|
||||||
public SolanaTransactionOutcome awaitTransaction(
|
public SolanaTransactionOutcome awaitTransaction(
|
||||||
ΩSolanaTransactionSignatureΩ signature,
|
ΩSolanaTransactionSignatureΩ signature,
|
||||||
|
|||||||
@@ -906,6 +906,160 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
|
|||||||
return signature;
|
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
|
@Override
|
||||||
public SolanaTransactionOutcome awaitTransaction(
|
public SolanaTransactionOutcome awaitTransaction(
|
||||||
ΩSolanaTransactionSignatureΩ signature,
|
ΩSolanaTransactionSignatureΩ signature,
|
||||||
|
|||||||
@@ -307,6 +307,10 @@ public final class NenjimHubImpl implements NenjimHub {
|
|||||||
new MoneyAmount(new ΩAmountΩ("0.1"), cis.resolve(CurrencyTypeIds.USDT_ID)),
|
new MoneyAmount(new ΩAmountΩ("0.1"), cis.resolve(CurrencyTypeIds.USDT_ID)),
|
||||||
new MoneyAmount(new ΩAmountΩ("0.095"), cis.resolve(CurrencyTypeIds.SYRUPUSDC_ID))
|
new MoneyAmount(new ΩAmountΩ("0.095"), cis.resolve(CurrencyTypeIds.SYRUPUSDC_ID))
|
||||||
);
|
);
|
||||||
|
List<MoneyAmount> inputCurrencyMinimumSwapAmounts = List.of(
|
||||||
|
new MoneyAmount(new ΩAmountΩ("0.05"), cis.resolve(CurrencyTypeIds.USDT_ID)),
|
||||||
|
new MoneyAmount(new ΩAmountΩ("0.045"), cis.resolve(CurrencyTypeIds.SYRUPUSDC_ID))
|
||||||
|
);
|
||||||
List<MoneyAmount> inputCurrencyMaximumSwapAmounts = List.of(
|
List<MoneyAmount> inputCurrencyMaximumSwapAmounts = List.of(
|
||||||
new MoneyAmount(new ΩAmountΩ("0.25"), cis.resolve(CurrencyTypeIds.USDT_ID)),
|
new MoneyAmount(new ΩAmountΩ("0.25"), cis.resolve(CurrencyTypeIds.USDT_ID)),
|
||||||
new MoneyAmount(new ΩAmountΩ("0.2"), cis.resolve(CurrencyTypeIds.SYRUPUSDC_ID))
|
new MoneyAmount(new ΩAmountΩ("0.2"), cis.resolve(CurrencyTypeIds.SYRUPUSDC_ID))
|
||||||
@@ -323,6 +327,7 @@ public final class NenjimHubImpl implements NenjimHub {
|
|||||||
cis,
|
cis,
|
||||||
discordAssetAZTokenNotificationService,
|
discordAssetAZTokenNotificationService,
|
||||||
inputCurrencyMinimumReserves,
|
inputCurrencyMinimumReserves,
|
||||||
|
inputCurrencyMinimumSwapAmounts,
|
||||||
inputCurrencyMaximumSwapAmounts,
|
inputCurrencyMaximumSwapAmounts,
|
||||||
200,
|
200,
|
||||||
2*1000*60L,
|
2*1000*60L,
|
||||||
|
|||||||
Reference in New Issue
Block a user