8 Commits
61 changed files with 2785 additions and 293 deletions
+101 -4
View File
@@ -99,10 +99,107 @@ sikre tokenens pris eller muliggøre ny funktionalitet.
### 2.2 Buybacks og burn ### 2.2 Buybacks og burn
En af de vigtigste mekanismer i Evelyn er det automatiske tilbagekøb og efterfølgende burn af 'Evelyn IOU' tokens. En af de vigtigste mekanismer i Evelyn er det automatiske tilbagekøb og efterfølgende burn af 'Evelyn IOU' tokens.
En fast procentdel af overskuddet anvendes på at købe tokens på markedet – uanset den aktuelle pris – og brænde En fast procentdel af overskuddet anvendes på at købe tokens på markedet, når
dem permanent. Dette reducerer det samlede udbud og skaber et kontinuerligt deflationært pres, hvilket typisk er Evelyn Price Index er under nul, og brænde dem permanent. Det reducerer udbuddet,
positivt for investorerne. Buyback-strategien sikrer, at investorerne får en god pris for deres tokens, når det når den aktuelle EVE-pris er lavere end Evelyns referencepris.
udnyttes at ikke alle investorer sælger på samme tid, da de har forskellige forventninger til prisen.
Når et input ellers er klar til køb, læser burneren en frisk kanonisk EVE/USDT-pris
fra `TickerService` og kalder den tilsvarende `EvelynStatusService.getReferencePrice()`
med det aktuelle beslutningstidspunkt. Køb tillades kun ved
`aktuel pris < referencepris`; lighed giver intet køb. Der bruges samme eksisterende
maksimale prisalder som til burn-værdiansættelsen, uden nye prisindstillinger.
Referencekurven beregnes af status-servicen; hverken den sidst gemte historikværdi
eller grafens punkter bruges til købsbeslutningen.
Ved lighed, højere pris eller manglende, ugyldige, for gamle eller utilgængelige
prisdata springes alle nye køb over i iterationen, og årsagen logges. Forløbet
fortsætter til EVE-saldo og mulig burn efter de selvstændige burn-regler. En fejl i
referenceopslaget blokerer derfor ikke burn, når dens egen friske værdiansættelse
og øvrige krav er opfyldt. Priserne vurderes igen ved næste ellers berettigede
iteration; et prisskip sætter ingen permanent suspension.
Med referencepris 15 USDT/EVE tillader 14,99 et ellers berettiget køb; 15,00 og
15,01 springer købet over. I alle tilfælde fortsætter EVE-burn-vurderingen.
Produktionsprocessen `evelyn.iou-burner.prod` accepterer følgende inputs i rækkefølge:
| Token | Reserve | Minimum swap-input | Maksimum pr. iteration |
| --- | ---: | ---: | ---: |
| USDT | 0,1 USDT | 0,05 USDT | 0,25 USDT |
| SyrupUSDC | 0,095 SyrupUSDC | 0,045 SyrupUSDC | 0,2 SyrupUSDC |
Hub'en angiver reserver, minimumsbeløb og maksimummer som `MoneyAmount`.
Reservelisten bestemmer accepterede valutaer og prioritet; minimumsbeløb og
maksimummer matches efter valutaens kanoniske UUID uanset listernes rækkefølge,
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,
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
ingen inputtoken/USDC-pris eller nye Ticker-kilder og historikfiler.
Højst ét input forsøges vekslet pr. iteration, med en times pause efter hver
afsluttet iteration. Jupiter vælger pool og rute til EVE. USDT har fortsat første
prioritet, så en vedvarende spendabel USDT-saldo kan udsætte SyrupUSDC-veksling.
Efter det mulige buyback, også når prisbetingelsen springer købet over,
genlæses hele den synlige EVE-saldo, også ved direkte
EVE-indbetalinger. Ejeren har valgt et produktionsminimum på **0,1 EVE pr. burn**
og en **reserve på 0,01 EVE**. Hub'en angiver begge som `MoneyAmount` med EVE's
kanoniske UUID. Minimum skal være positivt, reserve skal være ikke-negativ;
en reserve på nul er gyldig. Null-værdier og andre valutaer afvises ved oprettelse.
Burnbeløbet beregnes som `max(EVE-saldo - EVE-reserve, 0)`, afrundet ned til
EVE-mintens præcision. Der brændes kun, når dette faktiske beløb er positivt og
mindst minimum. Når tærsklen nås, brændes hele det berettigede beløb over
reserven; minimum er en tærskel, og der er intet burn-maksimum. Eventuelle
afrundingsrester bliver i wallet'en sammen med reserven. Små buybacks fortsætter,
når købspris og inputkrav tillader det, og samler EVE op mellem burns.
Under tærsklen sker hverken burn eller
burn-notifikation.
| EVE-saldo | Produktionshandling | EVE tilbage efter succes |
| ---: | --- | ---: |
| 0,105 | Ingen burn | 0,105 |
| 0,11 | Burn 0,1 EVE | 0,01 |
| 0,135 | Burn 0,125 EVE | 0,01 |
Issue #93 bruger minimum 1 EVE og reserve 0,1 EVE som adfærdseksempler:
1,05 EVE giver ingen burn, 1,1 EVE giver burn af 1 EVE, og 1,35 EVE giver burn af
1,25 EVE. Produktionsværdierne ovenfor blev valgt særskilt den 2026-10-06.
Burn kræver stadig en frisk EVE/USDT-observation gennem det uændrede
`TickerService.getLatestPrice()` og bekræftes ved `FINALIZED` før det eneste
notifikationsforsøg. Pending burn og notifikationens EVE-beløb og dollarværdi
bruger det faktisk indsendte burnbeløb uden reserven. Et pending burn afklares
med sin oprindelige signatur uden ny tærskelvurdering eller genindsendelse.
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 mulig ny inputvurdering med frisklæste saldi; ethvert nyt køb skal stadig
bestå den aktuelle prisbetingelse. Afklaringen er read-only og fortsætter også
ved pris lig med eller over referenceprisen. 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
+3 -1
View File
@@ -177,7 +177,9 @@ nenjim.journal.service</code></pre>
<code>start()</code>, and recorded only after successful return. An omitted process is therefore neither <code>start()</code>, and recorded only after successful return. An omitted process is therefore neither
constructed, registered, started, nor displayed by Process Manager.</p> constructed, registered, started, nor displayed by Process Manager.</p>
<p><code>[AUTO_START]</code> is the complete manually ordered process dependency closure. The Hub does not infer, <p><code>[AUTO_START]</code> is the complete manually ordered process dependency closure. The Hub does not infer,
insert, recursively construct, or reorder dependencies; JavaFX runtime must precede selected JavaFX insert, recursively construct, or reorder dependencies. Ticker and Production Evelyn status must precede
a selected Production IOU Burner; the Hub injects that status service for reference-price decisions before
new buybacks and fails clearly if the predecessor is unavailable. JavaFX runtime must precede selected JavaFX
applications. Construction, registration, and startup remain fail-fast without rollback. Earlier successful applications. Construction, registration, and startup remain fail-fast without rollback. Earlier successful
snapshot entries remain truthful after a later failure, and the online point plus blocking wait are reached snapshot entries remain truthful after a later failure, and the online point plus blocking wait are reached
only after every configured process succeeds.</p> only after every configured process succeeds.</p>
+5
View File
@@ -283,6 +283,11 @@ navngivne `Production`- og `Test`-instanser gennem `EvelynStatusServiceImpl` og
`EvelynStatusService`-views i Mission Control. Process Manager viser de nye ID'er fra Hub'ens startup-snapshot. `EvelynStatusService`-views i Mission Control. Process Manager viser de nye ID'er fra Hub'ens startup-snapshot.
Historik ligger fortsat i `data/evelyn/<instance-name>/status.log` med formatversion 1 og kræver ingen migration. Historik ligger fortsat i `data/evelyn/<instance-name>/status.log` med formatversion 1 og kræver ingen migration.
Hvis `evelyn.iou-burner.prod` vælges, skal både `assetaz.ticker.default` og
`evelyn.status.prod` stå før den. Hub'en injicerer Production status-servicen til
referenceprisopslag før nye buybacks; en manglende eller senere status-forgænger
giver en tydelig konstruktionsfejl uden implicit oprettelse eller omordning.
Efter validering konstruerer og starter Hub'en Registry, registrerer det som `nenjim.registry.service` og registrerer Efter validering konstruerer og starter Hub'en Registry, registrerer det som `nenjim.registry.service` og registrerer
startup-milepælen. Derefter konstrueres og registreres hele den nuværende passive graph uden at starte workers eller startup-milepælen. Derefter konstrueres og registreres hele den nuværende passive graph uden at starte workers eller
processer. Først herefter behandles resten af `[AUTO_START]` i den angivne rækkefølge, begyndende med Journal. Hver processer. Først herefter behandles resten af `[AUTO_START]` i den angivne rækkefølge, begyndende med Journal. Hver
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-05
@@ -0,0 +1,30 @@
# Design
## Context
See proposal.md for motivation and issue #90 for the agreed operating values. The existing burner already supports ordered SOL/SPL inputs, downward precision rounding and Jupiter exact-input swaps. Its reserve list is the authoritative accepted-input configuration. The constructor's scalar USDC cap and input-price lookup are the only blockers to selecting SyrupUSDC without additional Ticker observations.
## Goals / Non-Goals
**Goals:** Keep selection independent of input prices while rejecting incomplete or ambiguous limits before any worker starts. Change the amount policy without changing transaction outcomes, recovery or burning.
**Non-Goals:** Introduce another input model, a general configuration subsystem, or change how the latest EVE/USDT observation is selected.
## Decisions
1. Accept maxima as a second `List<MoneyAmount>` and copy their validated values into an immutable UUID-keyed map. The reserve list keeps its original defensive copy and order. UUID lookup permits the maxima list to arrive in any order; matching positions or metadata would create avoidable ambiguity. Existing immutable `MoneyAmount` values suffice without new types or ValueTags.
2. Validate maxima entries, amounts, currencies and UUIDs, canonical CIS identity, positivity and uniqueness, then require exact agreement with the accepted reserve UUIDs. Keep existing reserve and Solana-representation validation unchanged. Empty lists match and retain direct-EVE handling. A compatibility scalar constructor would preserve obsolete price semantics, so it is not retained.
3. Select `min(balance - reserve, maximum.amount())` only for positive spendable balance, then use the existing downward mint-precision rounding and zero skip. Remove the USDC canonical field and all input price construction/queries. Keep EVE/USDT canonical construction and fresh-price enforcement intact.
4. Add the agreed two reserve and maximum entries in Hub construction. Retain every other argument and the existing wallet/Jupiter bindings. Jupiter continues to choose pool and route; no Raydium transaction path is added.
5. Compile through the integrated Gradle/Detag build, and verify the compiled implementation with temporary offline controlled dependencies outside the repository. Do not add a permanent test artifact. Verify amount boundaries, UUID matching, invalid configuration, rounding, and existing failure/pending-burn behavior without network or real transaction submission.
## Risks / Trade-offs
- Token limits vary in USD value → document that 0.2 SyrupUSDC is a token amount and retain explicit small limits.
- Constructor signature changes → update the single repository call site and compile all relevant sources.
- USDT can defer SyrupUSDC indefinitely while spendable → preserve the agreed priority and one-attempt rule rather than changing scheduling.
- Unknown transaction outcomes must not be retried → leave outcome handlers unchanged and verify suspension/pending behavior offline.
## Migration Plan
Implementation is local and reviewable; deployment is a separate task. A later deployment replaces the application with the compiled change while preserving wallet, startup configuration and existing Ticker history. External callers must replace a scalar cap with exactly one token-denominated maximum per accepted reserve UUID. Rollback restores the prior implementation and USDT-only Hub configuration after reconciling any unknown transaction outcomes.
@@ -0,0 +1,26 @@
# Proposal
## Why
The production Evelyn IOU burner accepts only USDT, and its common USDC-equivalent ceiling needs input prices for other tokens. Issue #90 requires deposited SyrupUSDC to fund the same Jupiter buyback and burn pipeline using a small, explicit token-denominated limit.
## What Changes
- **BREAKING**: Replace the constructor's scalar `maximumSwapAmountInUSDC` with `List<MoneyAmount> inputCurrencyMaximumSwapAmounts`, validated and matched to accepted inputs by canonical currency UUID.
- Calculate each exact input amount from spendable balance and that currency's maximum, rounding downward to mint precision without querying an input/USDC price.
- Keep USDT first with reserve 0.1 and maximum 0.25; add SyrupUSDC second with reserve 0.095 and maximum 0.2 in `constructEvelynIOUBurnerProdProcess()`.
- Preserve Jupiter routing, EVE/USDT freshness before burning, all transaction/recovery behavior, and Hub-owned lifecycle.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `evelyn-iou-burner-service`: Token-denominated maxima, UUID-matched configuration validation, price-independent input selection, and production SyrupUSDC support.
## Impact
Changes are limited to the burner reference constructor/selection logic, its Hub construction, directly affected documentation and OpenSpec requirements. Verification compiles normal sources and uses controlled offline dependencies without live financial transactions. No Ticker sources, API behavior, history paths, ValueTags, direct Raydium swaps, deployment, or unrelated refactoring are included.
@@ -0,0 +1,129 @@
# 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. Empty reserves 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 spendable balances 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 either supplied list after construction
- **THEN** the accepted currencies, their priority, reserves and maxima remain as validated during construction
### 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.
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 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
### 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.
#### Scenario: Native SOL configuration
- **WHEN** a configured input UUID is the stable SOL UUID
- **THEN** the service reads the native wallet balance and later supplies Jupiter with the CIS-resolved canonical SOL/WSOL mint rather than treating SOL as an SPL-token holding
#### Scenario: Fixed EVE behavior
- **WHEN** the service evaluates a burn and notification
- **THEN** it uses the fixed EVE mint and the canonical EVE/USDT pair without accepting replacements through construction
### Requirement: Prioritized input inspection with internal Solana translation
Unless swaps are suspended, an iteration SHALL inspect configured input entries in order until it finds the first usable candidate. For every entry it SHALL resolve the configured UUID to its canonical currency. Native SOL SHALL use `getSolanaBalance().amount()` and nine-decimal precision. Every other input SHALL use its unique CIS-resolved Solana mint; the service SHALL inspect the mint account owner to detect the original SPL Token Program or Token-2022, use that program for wallet balance lookup, and obtain precision from Solana mint metadata. A missing SPL-token account SHALL equal zero balance.
For each entry the service SHALL subtract its configured reserve and skip non-positive spendable balance. Missing or unusable balance, mint account, supported program, or mint metadata SHALL produce a warning and allow later configured entries to be examined. Interruption SHALL preserve the thread interrupt state and end processing rather than becoming a skipped candidate. Failure to select an input SHALL NOT prevent the later complete-EVE burn evaluation.
#### Scenario: Missing token account
- **WHEN** a configured non-SOL mint is valid but the wallet has no token account under its detected program
- **THEN** that input is treated as a zero balance and later inputs are considered
#### Scenario: Missing or unsupported mint account
- **WHEN** a configured token's mint account is absent or owned by an unsupported program
- **THEN** the service warns, skips that input, considers later priorities, and still reaches the EVE-balance step
#### Scenario: Candidate read is interrupted
- **WHEN** wallet, CIS-adjacent processing, or Solana access is interrupted while inspecting an input
- **THEN** the service preserves interruption and ends processing without treating the input as an ordinary failure
## ADDED Requirements
### 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.
#### 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
- **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, 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, 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 and the mint supports six decimals
- **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
### 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.
#### 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
## REMOVED Requirements
### Requirement: Fresh canonical input prices
**Reason**: Buyback limits are expressed in each accepted input's own units, so input/USDC observations are no longer needed.
**Migration**: Configure exactly one token-denominated maximum per accepted input; keep the existing fresh EVE/USDT observation before burning.
### Requirement: Exact USDC-capped amount and per-currency reserve
**Reason**: Replaced by exact token-capped amount and per-currency reserve.
**Migration**: Replace the scalar USDC-equivalent constructor maximum with the UUID-matched per-currency MoneyAmount list.
@@ -0,0 +1,17 @@
# Tasks
## 1. Token-denominated constructor and selection
- [x] 1.1 Replace the scalar maximum with an immutable UUID-matched configuration, preserve reserve validation, and verify reordered maxima, defensive copying, empty lists and invalid maximum configurations with offline controlled dependencies.
- [x] 1.2 Remove input/USDC resolution and pricing, select the lesser spendable/token-maximum amount with existing downward precision rounding, and verify the SyrupUSDC balance examples, reserve boundary and zero-rounding skip offline.
- [x] 1.3 Update constructor and domain-service JavaDoc for token limits and EVE-only price freshness; verify no obsolete input-price or scalar-limit contract remains in current source documentation.
## 2. Production configuration and documentation
- [x] 2.1 Configure USDT reserve 0.1/maximum 0.25 first and SyrupUSDC reserve 0.095/maximum 0.2 second in Hub construction; verify exact values, unchanged remaining arguments and successful Gradle compilation.
- [x] 2.2 Document the production limits, Jupiter routing, unchanged latest-price API and direct-EVE behavior; verify documentation matches the source configuration and delta requirements.
## 3. Integration verification and OpenSpec completion
- [x] 3.1 Verify one-attempt priority, confirmed/failed/unknown swap handling, pending FINALIZED burns, complete EVE burning and stale/missing EVE observations offline without financial transactions; record results and limitations in the change.
- [x] 3.2 Validate the OpenSpec change, perform diff/whitespace checks, synchronize directly affected main requirements and verify the change is ready for archive with all implementation tasks and requirements complete.
@@ -0,0 +1,33 @@
# Verification
## Completed checks
- `openspec validate 90-add-syrupusdc-burner-input-with-per-currency-limits --strict`: passed.
- `openspec validate evelyn-iou-burner-service --type spec --strict`: passed, with informational long-description hints for existing/modified requirements.
- `git diff --check`: passed.
- Main-spec merge comparison: all four modified requirements and both added requirements match the delta, both removed requirements are absent, and unaffected requirements are preserved.
- Source comparison against HEAD confirms that lifecycle and iteration sequencing, swap execution and outcome handling, complete EVE reload/burn, pending burn recovery, FINALIZED waiting, notification behavior, fresh-price validation, Solana precision resolution and reserve validation are unchanged byte for byte.
- Current source documentation contains no obsolete scalar USDC limit, USDC canonical field, input-price conversion or fresh input/USDC requirement. The production values and priority match issue #90, including SyrupUSDC reserve 0.095.
## Compilation and controlled checks
The parent agent compiled the updated main sources and existing test sources successfully with the normal integrated Gradle/Detag workflow:
```text
JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew compileJava compileTestJava --offline --no-daemon
```
Result: `BUILD SUCCESSFUL in 13s`, four tasks executed. No permanent automated tests were added.
The temporary harness at `/tmp/evelyn-issue90/BurnerVerification.java` was compiled and executed with the same JDK against the newly built classes. Result: **PASS: 52 focused offline assertions**. These cover:
- SyrupUSDC balances 0.5 → 0.2 and 0.245 → 0.150, at/below 0.095 → skipped.
- USDT balance 0.35 → 0.25, with USDT first even when maxima are reversed.
- Downward six-decimal rounding and positive amounts that round to zero.
- Defensive configuration copying, empty lists and ten invalid-maxima configurations, including missing, duplicate, extra, non-positive, null and unknown values/identities.
- No input/USDC Ticker observation, and both missing and stale EVE/USDT observations postponing burns while still permitting input selection.
- Complete direct-EVE burning with empty input lists, FINALIZED confirmation, unchanged timeout and one proof notification using the burn signature.
All financial dependencies and notification delivery were controlled. No worker, live transaction or external notification was started. Swap/burn failure and pending recovery paths were verified by exact source comparison against HEAD; the harness does not claim exhaustive runtime coverage of those unchanged branches.
Main requirements are synchronized and all implementation tasks are complete. No deployment, push, Gitea write or live financial transaction has been performed.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-05
@@ -0,0 +1,40 @@
# Design
## Context
See proposal.md and issue #91. The burner currently loses a known timed-out swap's identity behind `swapsSuspended`. Jupiter retains its order's last valid block height internally, but structured outcomes expose no validity metadata and confirmation I/O failures expose a signature only in text. Solana awaiting already uses historical status lookup and a shared five-second gate.
## Goals / Non-Goals
**Goals:** Resume buybacks after a safely resolved original transaction while maintaining duplicate prevention, process-local state and the existing burn path.
**Non-Goals:** Build persistent submission infrastructure or change token amount policy, provider selection, burn confirmation or worker scheduling.
## Decisions
1. Keep the shared transaction outcome enum unchanged. Add a small separate reconciliation result and original transaction-validity value in the Solana package, and a read-only reconciliation method. The cached decorator delegates it directly. An additive conservative default permits existing blockchain implementations to await but never infer expiry.
2. Extend the existing structured Jupiter outcome exception with optional validity metadata while preserving the three-argument constructor used by perps. Add typed IOException and InterruptedException subclasses carrying submission-stage certainty, optional trustworthy signature, original validity and the original cause. Preparation failures are explicitly pre-submission; ambiguous execution failures without a signature retain manual suspension. A known confirmation signature is retained even when confirmation I/O or interruption fails.
3. Capture a finalized pre-submission slot, taker address and the order's last valid block height. Extract the original recent blockhash from the serialized transaction message and reject a signer response that changes it. Bind the execution signature to the signed transaction's first signature when it is populated. A sponsor signature completed by Jupiter retains provider identity for status checks, but cannot prove expiry of the local payload. No unrelated latest blockhash participates in expiry evidence.
4. Reconcile in a bounded thirty-second operation using the existing shared RPC gate/deadline. A historical status at CONFIRMED or stronger resolves success/failure. Any lower-commitment observation remains pending. For an absent signature, require finalized epoch height above the original validity height, the original blockhash to be invalid at that finalized context, local `minimumLedgerSlot` at/before the original submission anchor, and a subsequent finalized signer-address history lookup with `minContextSlot` at/after that finalized context and no original signature. One page (limit 1000) must extend strictly before the submission anchor or exhaust the covered retained history; a full page ending at/after the anchor remains unresolved. Missing, pruned, stale, inconsistent or failed evidence remains unresolved. This trusts the configured RPC's retained-history contract; it does not treat a remote archive's earliest block as contiguous local coverage.
5. Pending burns retain absolute priority. Otherwise pending swaps are reconciled before input selection; resolution clears state and selection reloads wallet balances. Unresolved swaps block only new swaps and still reach the existing complete-EVE burn path. Never call `swap()` as reconciliation or resubmit/re-sign an unresolved operation.
6. Log canonical symbol/UUID, attempted amount, known signature, status and next action. Log safe phase/cause classes instead of transport body, signer payload, transaction bytes or arbitrary cause messages.
## Risks / Trade-offs
- History pruned before the retained anchor or incomplete/contradictory evidence → keep pending and diagnose the missing evidence; no speculative recovery.
- A process restart discards pending state → document external reconciliation before restart and retain no persistent journal.
- Additional read-only preparation/reconciliation calls add gate latency → use shared pacing and a bounded reconciliation deadline; add no immediate retry loop.
- RPC evidence cannot be independently audited against the entire chain → rely only on the configured history-enabled RPC's local-retention contract and preserve pending on detected uncertainty.
## Primary references
- [Solana historical signature status](https://solana.com/docs/rpc/http/getsignaturestatuses).
- [Solana transaction retry guidance](https://solana.com/developers/cookbook/transactions/retry).
- [minimumLedgerSlot](https://solana.com/docs/rpc/http/minimumledgerslot) identifies the lowest retained local ledger slot, which may advance as history is purged.
- [isBlockhashValid](https://solana.com/docs/rpc/http/isblockhashvalid) supports commitment and minimum context slot.
- [Finalized address history](https://solana.com/docs/rpc/http/getsignaturesforaddress) binds absence to a minimum finalized context; Agave rejects a finalized request if its highest super-majority root has not reached that context.
- [Agave RPC implementation](https://github.com/anza-xyz/agave/blob/master/rpc/src/rpc.rs) checks transaction-history availability for historical signature lookup; getFirstAvailableBlock may include BigTable and is not used as local coverage evidence.
## Migration Plan
Deployment remains separate. Replace the application only after reconciling unknown transactions in any old process; restarting either version loses in-memory pending state. No configuration or history migration is required, and current reserves/maxima remain unchanged.
@@ -0,0 +1,30 @@
# Proposal
## Why
Issue #91 addresses known Jupiter swaps that time out and currently suspend Evelyn buybacks until a Hub restart. Retaining and reconciling the original transaction safely lets normal hourly buybacks resume after its outcome is resolved, without risking duplicate economic operations.
## What Changes
- Retain process-local pending swap signature, input, attempted amount and original validity metadata after an unknown outcome.
- Reconcile that same signature read-only in later iterations at CONFIRMED or stronger; resume selection only after success, on-chain failure or demonstrated non-executed expiry, using freshly read balances.
- Bind original validity to the actual signed transaction identity where available; sponsor-completed identity can resolve status but cannot prove local-payload expiry.
- Preserve known signatures and original causes in structured confirmation errors; classify proven preparation failures as retryable and retain manual handling for uncertain failures without a trustworthy signature.
- Preserve pending-burn priority, existing EVE burns while a swap remains unresolved, one new swap per iteration, all current reserves/maxima and Ticker behavior.
- Add safe operational diagnostics and focused offline verification.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `evelyn-iou-burner-service`: Later-iteration reconciliation and safe recovery of pending swaps.
- `jupiter-swap-service`: Structured submission stage/signature/cause and original validity context.
- `solana-transaction-awaiting`: Small read-only reconciliation support with conservative expiry/history evidence.
## Impact
The burner, Jupiter swap integration, shared structured Jupiter outcome exception, Solana API/reference implementation/cached delegate, and directly affected documentation/specifications are involved. Existing outcome statuses and perps callers remain compatible. No persistent journal, rebroadcast, automatic retry/re-signing, provider routing, live transaction, deployment, #92 minimum configuration, ValueTag change or unrelated burn/lifecycle work is included.
@@ -0,0 +1,69 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Immediate sequential fixed-delay lifecycle
Each reference implementation instance SHALL own at most one dedicated background worker. A successful Hub-owned `NenjimProcess.start()` SHALL begin the first iteration immediately; every later iteration SHALL begin only after the previous iteration completes and the configured fixed delay elapses; and iterations SHALL never overlap. A repeated process start while active SHALL NOT create another worker. If scheduling the worker fails during startup, the implementation SHALL stop the newly created executor, clear its worker reference and process-local state, and report startup failure.
Pending swap/burn information and manual swap/burn suspension flags SHALL be process-local only. A complete process restart SHALL clear those states. The public JavaDoc SHALL disclose that restart loses reconciliation state and can weaken duplicate prevention until unknown transactions have been reconciled externally. Neither the service interface nor the implementation SHALL expose ordinary stop or restart operations.
#### Scenario: First and later iterations
- **WHEN** the Hub starts the Burner process and one iteration takes longer than usual
- **THEN** the first iteration starts without initial delay and the next starts only after completion plus the configured interval
#### Scenario: Active service is started again
- **WHEN** `NenjimProcess.start()` is called while the instance already owns an active worker
- **THEN** the call is rejected without creating an overlapping worker
#### Scenario: Explicit lifecycle reset
- **WHEN** a complete process restart occurs after pending or suspended process-local state has been externally reconciled
- **THEN** the new process begins without that prior in-memory state and reloads wallet and chain state normally
### Requirement: At most one confirmed swap attempt per iteration
After selecting a positive exact amount, the service SHALL invoke the existing Jupiter exact-input operation once with the selected CIS-resolved input mint, fixed EVE output mint, selected amount, and configured slippage. Normal return SHALL be treated as already `CONFIRMED` and SHALL NOT trigger a second confirmation wait. Once invoked, no later input currency SHALL be attempted in that iteration regardless of success or failure, and the service SHALL proceed to reload the complete EVE balance whenever processing was not interrupted.
A structured definitive on-chain swap failure SHALL be logged without suspending later-process swaps. A timed-out outcome or known-signature confirmation communication error SHALL retain process-local pending swap signature, canonical input, attempted amount and original validity metadata for later read-only reconciliation. Proven pre-submission errors SHALL permit a fresh attempt in a later scheduled iteration. An ambiguous post-submission error without a trustworthy signature SHALL retain manual suspension. Interruption SHALL preserve available pending context and propagate normally. No unknown outcome SHALL cause automatic rebuilding, re-signing or resubmission; pending swaps SHALL still permit existing EVE burn evaluation.
#### Scenario: First swap fails definitively
- **WHEN** the first attempted input returns a definitive on-chain failure
- **THEN** no second input is attempted in that iteration, the EVE balance is still reloaded, and a later iteration may attempt a new swap
#### Scenario: Swap outcome is unknown
- **WHEN** the attempted swap times out, throws an I/O failure that permits unknown post-submission status, or is interrupted
- **THEN** no equivalent or alternative swap is attempted while its outcome is unresolved; a known signature is checked in later iterations and a signature-less ambiguous submission requires manual reconciliation
#### Scenario: Swap returns normally
- **WHEN** Jupiter returns a successful result whose transaction already reached `CONFIRMED`
- **THEN** the burner performs no additional confirmation wait and reloads the wallet instead of deriving its burn amount only from the returned output amount
### Requirement: Production burner accepts USDT and SyrupUSDC
The production Hub SHALL configure USDT first with reserve 0.1 and maximum input 0.25 USDT, and SyrupUSDC second with reserve 0.095 and maximum input 0.2 SyrupUSDC. These SHALL be token-denominated limits without a USD-value guarantee. The existing burner wallet, Jupiter route selection, registration, hourly interval, slippage, EVE/USDT freshness, confirmation settings and burn recovery behavior SHALL be preserved without new Ticker sources or history storage.
#### Scenario: Production inputs and settings
- **WHEN** the Hub constructs the production burner
- **THEN** it supplies the two exact reserves and maxima as MoneyAmount values in USDT-then-SyrupUSDC priority and retains the other existing production arguments
## ADDED Requirements
### Requirement: Pending swaps reconcile before new input selection
After pending-burn priority, a retained swap SHALL be checked read-only by its original signature at CONFIRMED or stronger in a bounded operation. Success, on-chain failure or demonstrated non-executed expiry SHALL clear it and permit newly read balances to drive at most one new swap. Unknown, absent without expiry proof or I/O outcomes SHALL remain pending without a new swap while allowing existing EVE burns. Interruption SHALL propagate with pending state intact.
#### Scenario: Original swap resolves
- **WHEN** a later check resolves the original signature as successful, definitively failed or proven non-executed and expired
- **THEN** pending swap state is cleared, complete failure details are logged when applicable, and any new selection uses newly read balances
#### Scenario: Original swap remains unresolved
- **WHEN** a later check times out, lacks trustworthy expiry/history evidence, observes only lower commitment or encounters I/O failure
- **THEN** the same pending signature is retained for the next scheduled iteration, no new swap occurs and the existing EVE burn path still runs
#### Scenario: Burn remains pending
- **WHEN** both a swap and a burn are pending at iteration start
- **THEN** only the burn signature is awaited and no swap reconciliation or new transaction is performed
### Requirement: Safe swap recovery diagnostics
Swap diagnostics SHALL identify canonical input symbol/UUID, attempted amount, known signature, submission or reconciliation state, safe cause details and next action. Timeout SHALL be described as unknown. Credentials, arbitrary provider/signer bodies and serialized transaction payloads SHALL NOT be logged. Pending swap state SHALL remain process-local; restart SHALL lose it and require external reconciliation before safely resuming uncertain activity.
#### Scenario: Unknown outcome is diagnosed
- **WHEN** a swap outcome remains unknown
- **THEN** diagnostics identify whether its original signature will be checked next iteration or manual investigation is required, without exposing secret or payload data
@@ -0,0 +1,59 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Confirmed execution result validation
The service SHALL first accept a Jupiter execution response only when it has status `Success`, explicitly has result code `0`, contains a non-blank transaction signature, and contains positive valid actual total input and output raw amounts. It SHALL convert each actual amount using the independently resolved precision of its mint. After this provider-response validation, the service SHALL independently await the returned signature on Solana at `CONFIRMED` using its configured confirmation timeout and SHALL return the already validated swap result only for a `SUCCEEDED` outcome. Non-success HTTP responses, malformed responses, expired or rejected swaps, failed or contradictory status/code combinations, missing result codes, blank signatures, and invalid result amounts SHALL remain provider failures and SHALL not be represented as successful swaps.
#### Scenario: Provider response and on-chain confirmation succeed
- **WHEN** Jupiter reports status `Success`, code `0`, a signature, and valid actual total input and output amounts, and that signature independently reaches `CONFIRMED` with a `SUCCEEDED` outcome
- **THEN** the service returns the signature and those actual amounts in human-readable units
#### Scenario: Jupiter rejects or fails execution
- **WHEN** Jupiter returns a failed status, a missing or non-zero result code, a contradictory status/code combination, expiration, rejection, non-success HTTP status, malformed body, blank signature, or invalid actual amount
- **THEN** the service reports an I/O failure without treating provider validation as an on-chain confirmation
#### Scenario: Provider succeeds but transaction fails on-chain
- **WHEN** a fully validated Jupiter execution response contains a signature whose independent `CONFIRMED` outcome is `FAILED`
- **THEN** the service throws a structured checked outcome exception preserving the signature, requested commitment, slot, and complete compact Solana failure details
#### Scenario: Provider succeeds but confirmation times out
- **WHEN** a fully validated Jupiter execution response contains a signature whose independent `CONFIRMED` outcome is `TIMED_OUT`
- **THEN** the service throws a structured checked outcome exception preserving the signature, requested commitment, original validity metadata and unknown outcome warning and does not automatically resubmit an equivalent transaction
### Requirement: Post-submission communication and interruption policy
If independent confirmation fails with an I/O error after the signature is known, the service SHALL throw a structured `IOException` preserving the original signature, original transaction-validity metadata, submission uncertainty and underlying cause. Interruption SHALL propagate as `InterruptedException` while preserving known signature/validity context in structured form without another RPC or Jupiter request. Neither condition SHALL trigger rebuilding, re-signing, retrying, or resubmitting a transaction.
#### Scenario: Confirmation communication fails
- **WHEN** independent confirmation throws an I/O error for a known submitted signature
- **THEN** the service reports the structured signature, original validity metadata and unknown on-chain outcome while preserving the original error and performs no retry or resubmission
#### Scenario: Confirmation is interrupted
- **WHEN** independent confirmation throws `InterruptedException`
- **THEN** that interruption retains its known signature and validity context, propagates normally and the service performs no subsequent request
## ADDED Requirements
### Requirement: Submission certainty and original validity remain structured
The integration SHALL expose submission certainty, known signature, original cause and original validity in structured form. Original validity SHALL retain the order height, actual blockhash, taker and finalized submission anchor. Signing SHALL preserve the hash and required taker signer. Expiry evidence SHALL be bound to a populated local first signature; contradictory provider identity SHALL require manual handling. Existing perps outcome construction SHALL remain compatible.
#### Scenario: Preparation fails
- **WHEN** mint/order acquisition, validation, signing or pre-submission validity preparation fails before execution submission
- **THEN** the error is structured as pre-submission and no execution or automatic retry occurs
#### Scenario: Execution response is lost
- **WHEN** execution may have been submitted and no trustworthy signature is available
- **THEN** the error remains structured as uncertain submission and does not authorize automatic recovery or resubmission
#### Scenario: Provider identity contradicts the signed transaction
- **WHEN** an execution response signature differs from the populated first signature of the signed payload
- **THEN** the result is not treated as confirmed and uncertain submission requires manual reconciliation
#### Scenario: Sponsor identity is completed by Jupiter
- **WHEN** Jupiter supplies a valid provider signature while the local first signature is blank
- **THEN** the signature can be awaited and reconciled by status, but original local validity cannot establish non-executed expiry
#### Scenario: Timeout retains original validity
- **WHEN** confirmation of a known submitted signature times out
- **THEN** the exceptional outcome exposes its original validity data without substituting a new transaction or blockhash
@@ -0,0 +1,33 @@
# Spec Delta
## ADDED Requirements
### Requirement: Read-only transaction reconciliation
The blockchain SHALL support bounded reconciliation of an existing signature at CONFIRMED or stronger, retaining normal await behavior and outcome statuses. Reconciliation SHALL distinguish definitive success/failure, demonstrated non-executed expiry and unresolved evidence. It SHALL use the existing shared RPC gate/deadline and historical status lookup without submission, caching, immediate retries or a new pacing mechanism. Unsupported expiry evidence SHALL remain unresolved.
#### Scenario: Definitive historical status
- **WHEN** the original signature is observed at or above requested commitment
- **THEN** reconciliation returns its successful or failed outcome with slot and complete failure details as applicable
#### Scenario: Below commitment or interrupted
- **WHEN** the signature is only observed below commitment or the check is interrupted
- **THEN** expiry is not inferred; an unresolved result is returned for lower commitment and interruption propagates normally
#### Scenario: Cached decorator
- **WHEN** reconciliation is invoked through the cached decorator
- **THEN** it reaches the delegate independently on every call
### Requirement: Expiry requires original validity and reliable local history absence
Expiry SHALL require signature-bound original validity, finalized height above its last valid height, original blockhash invalidity, retained local history through its submission anchor and fresh finalized signer-history absence. Missing, pruned, stale, lower-commitment or inconsistent evidence SHALL remain unresolved. Absence, elapsed time or blockhash invalidity alone SHALL NOT authorize clearing a pending transaction.
#### Scenario: Non-executed expiry is established
- **WHEN** all original-validity, finalized-height, original-blockhash, local-history-coverage and finalized signer-history absence checks succeed, with `minContextSlot` at/after the expired finalized context and a bounded page reaching before the submission anchor or exhausting covered retained history
- **THEN** reconciliation identifies a non-executed expired transaction without resubmitting it
#### Scenario: Evidence is incomplete
- **WHEN** original metadata is missing, history is unavailable/pruned, a lookup fails, a response is inconsistent/stale or the original blockhash is still valid
- **THEN** no expiry result is returned and the diagnostic explains the missing evidence
#### Scenario: Bounded history page is incomplete
- **WHEN** a full history page ends at or after the submission anchor
- **THEN** the transaction remains unresolved instead of treating partial absence as proof
@@ -0,0 +1,17 @@
# Tasks
## 1. Preserve submitted swap identity
- [x] 1.1 Add compatible structured validity, I/O and interruption metadata; verify original signature/cause and pre-submission certainty offline.
- [x] 1.2 Retain the actual transaction blockhash, original last-valid height and pre-submission anchor; verify signer hash consistency without live submission.
## 2. Reconcile pending swaps safely
- [x] 2.1 Add bounded read-only Solana reconciliation and direct cached delegation; verify confirmed outcomes and conservative finalized-history expiry evidence with controlled RPC responses.
- [x] 2.2 Preserve process-local pending swaps and safe diagnostics in the burner; verify scheduled recovery, unresolved suppression, fresh balances, EVE burn continuation and pending-burn priority offline.
## 3. Complete the documented change
- [x] 3.1 Update service documentation and operational restart limitations; review unchanged amount policy, transaction submission and burn recovery behavior.
- [x] 3.2 Compile existing main/test sources, run the temporary focused verification harness, record evidence and validate OpenSpec.
- [x] 3.3 Sync main specifications and archive the completed change after checking implementation and verification evidence.
@@ -0,0 +1,24 @@
# Verification
Verified on 2026-10-06 with JDK `/usr/local/software/java/jfx-25`.
## Compilation and offline checks
- Final parent compilation: `JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew compileJava compileTestJava --offline --no-daemon` → **BUILD SUCCESSFUL in 7s**; `detagMain` and `compileJava` executed against the final safe order-diagnostic correction and `compileTestJava` was up-to-date. The preceding successful 9s build also executed `compileTestJava`.
- Temporary harness compiled with `javac --enable-preview --release 25 -cp 'build/classes/java/main:build/libs/*' -d /tmp/evelyn-issue91 /tmp/evelyn-issue91/Issue91Verification.java`.
- `java --enable-preview -cp '/tmp/evelyn-issue91:build/classes/java/main:build/libs/*' Issue91Verification` → **PASS: 102 focused offline assertions**. Every wallet, swap and HTTP dependency is controlled; no live signing, submission or remote RPC occurs.
- The harness's initial deadline fixture omitted the gate's completed-call flag. Correcting that temporary fixture produced the successful run; no implementation change was necessary. An earlier harness compilation preceded the parent build and saw old classes; it is superseded by this final compilation/run.
Coverage includes later-iteration success/failure/expiry recovery using fresh balances; same-signature bounded CONFIRMED checks; unresolved/I/O/interruption suppression; pre-submission retry and manual signature-less uncertainty; EVE burn continuation, stale-price gate and pending-burn priority; original timeout/signature/validity/cause retention; compatible perps construction; actual legacy/v0 blockhash and required-taker extraction; blank sponsor identity; invalid provider signature rejection; and finalized expiry evidence with pruning, full history pages, minContextSlot, lower commitment, stale/inconsistent evidence and deadline handling. Safe numeric RPC and Jupiter order HTTP/provider codes are retained without provider bodies/error messages.
## Review and OpenSpec
- `git diff --check`: passed.
- Strict validation of the active change and all three synced main specifications: passed. All ten delta requirements match the main specs exactly. After archive, `openspec list --json` reports no active changes and `openspec validate --archived --json --report findings` passes all 29 archived changes with no findings. Existing long-requirement informational hints remain. After intentional main-spec synchronization the change validator may note that ADDED requirements already exist; the semantic archive workflow preserves that synchronized state.
- Independent parent review found no remaining material findings; its safety corrections were implemented before the successful final build.
- Static diff review confirms current reserves/maxima, Hub factory, input selection, Ticker behavior, EVE balance/price gate, burn submission/await/notification and fixed-delay scheduling are unchanged. The provider/local signature contradiction branch retains manual handling. The cached decorator delegates reconciliation directly.
- There remains one managed execution call and no automatic resubmission, persistent journal, ValueTag change or issue #92 minimum-input policy.
## Limits
The harness is temporary and outside the repository; no permanent automated test was added. Full live Jupiter/provider integration was not exercised. Expiry relies on the configured RPC's reliable retained-history contract and remains pending when original signature binding, history coverage or finalized evidence is insufficient. Sponsor-completed signatures can resolve by status but cannot establish local-payload expiry. Restart loses process-local pending state, requiring external reconciliation before safely resuming unknown activity. No deployment, push or Gitea change was made.
@@ -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.
@@ -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.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-06
@@ -0,0 +1,28 @@
# Design
## Context
See proposal.md for the operational need. The burner already reloads EVE after its independent buyback stage, retains known burn signatures before finalization, and prioritizes pending burns. It can reuse supported-program and mint-supply precision validation already used for input selection. Monetary values and CurrencyType are immutable.
## Goals / Non-Goals
**Goals:** Preserve a configured EVE reserve and accumulate small buys/deposits until a representable burn amount qualifies, retaining existing transaction and notification safety.
**Non-Goals:** Fixed burn batches, maximum burn size, a new configuration framework, notification batching, altered buybacks, persistent reconciliation or live deployment.
## Decisions
1. Add `minimumBurnAmount` and `eveReserve` MoneyAmount constructor arguments beside input bounds. Validate non-null amount/currency/UUID, canonical EVE UUID, positive minimum and non-negative reserve before startup, copying into canonical EVE MoneyAmount values. Stale display metadata is accepted by UUID. A separate record or ValueTag adds no needed domain safety.
2. Reload EVE after buybacks. Subtract the reserve and skip non-positive available balance, then resolve EVE mint precision using its detected token program. Round DOWN and compare that actual amount to the exact unrounded minimum. Skip non-positive/below-minimum amounts before price lookup/submission. Rounding configuration up or down would alter the owner's reserve/minimum; instead any fractional remainder stays in the wallet.
3. Submit all eligible EVE above reserve, using the same amount for price multiplication and PendingBurn. Keep pending-burn priority and awaiting unchanged so later thresholds/balances never rebuild an unresolved burn.
4. The owner chose production minimum **0.1 EVE** and reserve **0.01 EVE** on 2026-10-06. Supply both using canonical EVE in the production Hub. Issue #93's 1/0.1 values remain independent constructor acceptance examples, not production defaults.
## Risks / Trade-offs
- [EVE mint metadata unavailable] → Postpone burning without notification, using existing warning and interruption behavior; buybacks have already been evaluated independently.
- [Configuration precision exceeds mint precision] → Round only the eligible burn amount DOWN; compare to exact minimum and retain at least the exact reserve.
- [Unknown transaction outcome] → Preserve original signature and existing process-local suspension/reconciliation behavior. Restart still requires external reconciliation.
## Migration Plan
Compile the reference sources and update the production construction call together. Verify controlled threshold, precision, accumulation, direct-deposit, validation, pending-state and notification cases offline, synchronize specs and archive. Deployment is a separate task; no private deployment files or live transactions are touched. Rollback requires restoring the prior constructor and Hub call together, after reconciling uncertain transactions.
@@ -0,0 +1,27 @@
# Proposal
## Why
Small Evelyn buybacks currently create tiny burns and excessive proof notifications. Accumulating EVE for larger burns while retaining a wallet reserve supports predictable Evelyn operation without changing its small buybacks.
## What Changes
- Add independently configured EVE `MoneyAmount` values for a strictly positive burn minimum and non-negative retained reserve.
- Burn the entire balance above reserve, rounded down to mint precision, only when that actual amount reaches the minimum.
- Keep buybacks independent and preserve pending transaction reconciliation and successful FINALIZED proof delivery using the actual burned amount.
- Configure production with the owner's chosen minimum 0.1 EVE and reserve 0.01 EVE; retain issue #93's 1/0.1 examples as acceptance cases.
- **BREAKING**: Reference implementation constructor requires the two burn policy amounts.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `evelyn-iou-burner-service`: EVE burn policy configuration/validation, precision-aware eligibility above reserve, submitted/notification amounts and production settings.
## Impact
Reference burner implementation and its domain JavaDoc, production Hub constructor, Evelyn configuration documentation and directly affected OpenSpec requirements. No new ValueTags, administration API, buyback policy, scheduling, deployment changes or permanent tests. Verification uses controlled offline dependencies without live transactions.
@@ -0,0 +1,125 @@
# Spec Delta
## ADDED Requirements
### Requirement: Independent EVE burn policy configuration
The constructor SHALL accept minimumBurnAmount and eveReserve as EVE-denominated MoneyAmount values identified by canonical EVE UUID regardless of supplied display metadata. Both values, their amounts, currencies and UUIDs SHALL be non-null; minimum SHALL be strictly positive and reserve non-negative. Zero reserve SHALL be valid. Invalid burn configuration SHALL fail before worker startup without changing existing input validation.
#### Scenario: Invalid burn configuration
- **WHEN** either value, amount, currency or UUID is null, a UUID is not EVE, the minimum is zero/negative, or the reserve is negative
- **THEN** construction fails before creating a worker or transaction
#### Scenario: Stale EVE metadata and zero reserve
- **WHEN** both values carry the EVE UUID with stale names/symbols and the reserve is zero
- **THEN** construction succeeds and uses canonical EVE identity with the configured exact numeric amounts
## MODIFIED Requirements
### Requirement: Complete visible EVE balance is eligible
After the optional swap attempt, the service SHALL detect the fixed EVE mint's supported token program internally and reload the wallet's complete available EVE balance. A missing EVE mint account, unsupported program, absent wallet token account, zero balance, or negative/invalid balance SHALL cause no burn or notification in that iteration. The service SHALL subtract its configured EVE reserve, clamp negative available amounts to zero and round down to validated EVE mint precision using the detected token program. It SHALL submit a new burn only when the actual rounded amount is positive and at least the configured minimum burn amount; equality qualifies. It SHALL burn the entire eligible amount above reserve, without a maximum or fixed batch size, including EVE deposited before the iteration or visible after a failed or absent swap. Below threshold or with unusable mint precision it SHALL retain the EVE and send no burn notification. This threshold SHALL NOT suppress independent buybacks.
#### Scenario: Existing EVE without buyback
- **WHEN** no swap is performed but the wallet contains a positive EVE balance
- **THEN** the service evaluates that complete balance under the EVE reserve, precision and burn-minimum rules
#### Scenario: EVE account is absent or empty
- **WHEN** the wallet has no EVE token account or its complete balance is zero
- **THEN** the iteration submits no burn and sends no notification
#### Scenario: Issue example threshold and reserve
- **WHEN** minimum is 1 EVE and reserve is 0.1 EVE
- **THEN** balances 0, 0.05, 0.1, 1.05 and 1.099999 produce no burn; balance 1.1 burns 1 and balance 1.35 burns 1.25, retaining 0.1 after success
#### Scenario: Accumulation across small buybacks
- **WHEN** individually eligible small buybacks accumulate EVE over multiple iterations
- **THEN** each below-threshold iteration still permits its buyback and sends no burn message; the first qualifying iteration burns all eligible EVE and subsequent accumulation starts with the retained reserve
#### Scenario: Exact configuration exceeds mint precision
- **WHEN** six-decimal mint precision rounds available 1.0000009 to 1.000000 while minimum is 1.0000001
- **THEN** no burn occurs; no amount is rounded upward and the reserve is never consumed to reach the minimum
### Requirement: Fresh EVE value is required before submission
Immediately before a new burn, the service SHALL obtain the latest canonical EVE/USDT observation and require a positive price, non-null observation timestamp, and age no greater than `maximumPriceAge` milliseconds. USDT SHALL be treated as USD for notification value, calculated as the actual rounded eligible EVE amount above reserve multiplied by that fresh price. If the pair is unsupported or the observation is missing, invalid, or stale, the service SHALL warn, postpone the burn, send no notification, and retry only by re-evaluating state in a later scheduled iteration.
#### Scenario: Fresh EVE price exists
- **WHEN** a positive rounded EVE amount at or above the burn minimum and sufficiently fresh positive EVE/USDT observation are available
- **THEN** the service calculates the notification's USD value from that actual eligible burn amount, excluding reserve before submitting the burn
#### Scenario: EVE price is stale
- **WHEN** EVE is present but its observation is older than the configured age limit
- **THEN** the service leaves EVE unburned and sends no notification in that iteration
### Requirement: Single complete-balance burn submission and pending state
For a positive rounded EVE amount at or above the minimum with a usable price, the service SHALL call the wallet burn operation exactly once with the fixed EVE mint and entire eligible rounded amount above reserve. It SHALL NOT automatically retry, rebuild, re-sign, or resubmit within or across iterations. Immediately after a non-blank signature is returned, it SHALL retain process-local pending data containing that signature, actual submitted EVE amount excluding reserve, and its calculated notification value or complete notification text before awaiting the signature.
If burn submission fails without a usable signature and its outcome may be unknown, including an I/O or interruption path whose contract permits post-submission uncertainty, the service SHALL conservatively suspend further burn submissions for the current process. It MAY continue ordinary iteration work that does not submit another burn. Recovery SHALL require external reconciliation followed by a complete process restart, not a Burner service stop/start operation.
#### Scenario: Burn returns a signature
- **WHEN** the wallet submits the entire eligible rounded amount above reserve and returns a non-blank signature
- **THEN** pending state is retained before the service begins finalization waiting
#### Scenario: Submission outcome may be unknown without a signature
- **WHEN** burn submission fails or is interrupted without providing a usable signature and may already have reached Solana
- **THEN** the service performs no further burn submission until external reconciliation and a complete process restart
#### Scenario: Pending burn bypasses new threshold evaluation
- **WHEN** a retained burn signature remains pending while balance is now below the minimum or at the reserve
- **THEN** only the original signature is awaited, its original submitted amount/value are preserved and it is never re-evaluated, rebuilt or resubmitted
### 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 governed independently by their own EVE minimum and reserve. 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 its rounded eligible amount above the EVE reserve is burned if it meets the EVE burn minimum
### 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 production EVE burn minimum SHALL be 0.1 EVE and its retained reserve SHALL be 0.01 EVE, as selected by the owner on 2026-10-06, configured as MoneyAmount values using the canonical EVE UUID. 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, supplies minimum burn 0.1 EVE/reserve 0.01 EVE and retains the other existing production arguments
#### Scenario: Production burn threshold
- **WHEN** production EVE balance is below 0.11, exactly 0.11, or 0.135 with usable mint precision and fresh price
- **THEN** below 0.11 produces no burn, 0.11 burns 0.1 and 0.135 burns 0.125; successful burns retain 0.01 EVE
@@ -0,0 +1,15 @@
# Tasks
## 1. EVE burn policy
- [x] 1.1 Add canonical EVE MoneyAmount minimum/reserve constructor validation and constructor JavaDoc; verify invalid/null/wrong UUID values fail before worker/transaction activity, stale metadata works and zero reserve is valid in controlled offline checks.
- [x] 1.2 Replace full-balance submission with reserve-aware downward precision rounding and actual-minimum qualification; update domain JavaDoc and verify issue boundaries, rounding, direct deposits, accumulation, independent buybacks and actual pending/notification amounts with controlled dependencies.
## 2. Production configuration
- [x] 2.1 Supply owner's selected minimum 0.1 EVE and reserve 0.01 EVE in the production Hub, update Evelyn docs and verify source arguments plus production 0.11 threshold/0.135 balance examples offline.
## 3. Integration and completion
- [x] 3.1 Compile relevant sources, verify pending burn/swap reconciliation and suspension regressions without live transactions, and record results in verification.md; run strict OpenSpec validation and git diff --check.
- [x] 3.2 Synchronize directly affected main specification, verify all delta blocks match while unrelated content survives, run strict validation and archive completed artifacts/tasks.
@@ -0,0 +1,30 @@
# Verification
Verified on 2026-10-06 with `/usr/local/software/java/jfx-25`.
## Compilation and controlled acceptance checks
- `JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew compileJava compileTestJava --offline --no-daemon` → **BUILD SUCCESSFUL in 6s**; normal Detag and both source compilations executed. The initial sandbox attempt could not create Gradle's existing cache lock outside the workspace; the same compilation passed with approved cache access.
- Temporary harness sources in `/tmp/evelyn-issue93` compiled with `javac --enable-preview --release 25 -cp 'build/classes/java/main:build/libs/*' -d /tmp/evelyn-issue93`.
- `java --enable-preview -cp '/tmp/evelyn-issue93:build/classes/java/main:build/libs/*' Issue93Verification` → **PASS: 235 focused burn-policy assertions**.
- Same invocation for the copied `Issue92Verification` → **PASS: 89 buyback-minimum regression assertions**.
- Same invocation for the copied `Issue91Verification`, restricted to its burner checks → **PASS: 63 pending-swap/recovery regression assertions**. Unchanged RPC/Jupiter library checks were not rerun.
- Production source check confirms canonical EVE minimum **0.1** and reserve **0.01** in the correct constructor order and preserved input bounds, hourly interval, freshness and finalization arguments.
All wallet, transaction, chain, pricing and notification dependencies are controlled. No real RPC, signing or live financial operation occurs. No permanent automated tests were added. The older temporary harness copies add only a positive EVE burn minimum of 0.000001 and zero reserve to their constructor calls, retaining their original buyback/recovery expectations; original temporary files are preserved.
The #93 checks cover every issue #93 1/0.1 example and production 0.1/0.01 boundaries, numerical equality across decimal scales, zero reserve, fractional minimum above the rounded candidate, fractional reserve preservation, zero after rounding, both SPL Token and Token-2022 precision, missing/unsupported mint data, supply/mint/program mismatch, invalid decimals, I/O and interruption, missing/negative balance, stale price, repeated small buyback accumulation and subsequent reserve-based accumulation, direct deposits with absent/below-minimum inputs, no burn maximum, failed/unknown buybacks, original pending signature and amount/value across timeout/I/O/interruption, failed finalization without notification, once-only notification after delayed finalization, delivery failure and signature-less burn suspension with continued buybacks. Null/invalid numeric configuration, wrong/unknown UUIDs and stale EVE metadata are checked before worker/transaction activity; CurrencyType itself rejects null UUIDs before burner configuration.
An initial temporary harness compile failed because its notification-error fixture used Throwable rather than the transport's IOException contract; the fixture was corrected. Its default successful swap output was also corrected from zero to a positive amount, preventing fixture-only result validation failures. The final checks above pass against the unchanged production implementation.
## Review and workflow
Parent independent source/design/documentation and harness review found no material findings. Two delta scenario wording fixes were applied to state the actual eligible amount and grammatically separate the new production burn settings. Scope is limited to constructor burn policy, EVE eligibility/actual amount use, JavaDoc, production arguments, Evelyn docs and their specification. Buyback limits, priority, pending recovery, lifecycle, Jupiter, Ticker, ValueTags and private deployment files are preserved.
Strict active-change validation and `git diff --check` passed before synchronization. Final strict validation of the active change and synchronized main specification passed. Six delta requirement blocks match the main specification exactly; 12 untouched requirements remain byte-for-byte intact. The purpose now describes accumulated, threshold-qualified burns above reserve. Existing long-requirement informational hints remain because modified requirement blocks stay whole.
An initial temporary synchronization matcher allowed a requirement title to span lines and copied an invalid delta header into the main specification. Strict validation caught this before archive. The matcher was corrected and the main-spec edit rebuilt from the clean starting version plus the six intended blocks, then compared for exact delta matching and untouched-content preservation. Final strict validation passes.
All four planning artifacts and all five tasks are complete. The completed change is archived at `openspec/changes/archive/2026-10-06-93-add-minimum-eve-burn-and-reserve`. After archival, `openspec list --json` reports no active changes, and `openspec validate --archived --json --report findings` passed for all **31** archived changes with **0 failures**. Final whitespace validation also passes.
No deployment, push, Gitea write or live integration was performed.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-06
@@ -0,0 +1,29 @@
# Design
## Context
See proposal.md for motivation and the two delta specifications for required behavior. The burner already selects one rounded, reserve/minimum/maximum-qualified input, invokes Jupiter at most once, and then independently reloads EVE for burn evaluation. Its fresh canonical EVE/USDT reader is also used for burn valuation. The Hub constructs processes in the manually configured order and rejects unavailable process dependencies.
## Goals / Non-Goals
**Goals:** Insert the purchase condition at the last point before a new Jupiter attempt, use the corresponding live reference API, and retain independent burn and pending-transaction behavior.
**Non-Goals:** A general price-policy framework, another clock/configuration/freshness option, duplicated curve calculations, stored-history decisions, or changes to transaction execution, Ticker sources, lifecycle and deployment selection.
## Decisions
1. Add a required `EvelynStatusService` constructor dependency beside Ticker. Production composition uses the existing `requireProcessDependency` guard for `evelyn.status.prod`. The Hub does not infer or start dependencies; operators must place Ticker and Production status before the burner. Constructing a private reference calculator in the burner would duplicate ownership and risk policy drift.
2. After the first eligible rounded input is selected, reuse `findFreshPrice(eveUsdtTradingPair)`, obtain the reference through `getReferencePrice(Instant.now())`, validate its canonical pair and positive amount, and compare with `BigDecimal.compareTo`. This avoids division/index rounding and keeps the check close to submission. Checking before input selection would age the decision during balance/mint work; history/chart reads would use stale sample time.
3. A rejected condition returns only from `attemptAtMostOneSwap`, preserving the unconditional later `processCompleteEveBalance`. No other input can bypass a globally rejected EVE price. Missing/invalid reference or runtime lookup failure produces a safe warning; an at/above comparison logs the values and decision instant. No suspension state is set. Burn valuation independently reads its existing fresh price and never depends on reference availability.
4. Pending burn and swap paths stay ahead of input selection. Reconciliation is read-only regardless of price, and only a newly eligible purchase after reconciliation invokes the gate. Amount limits, precision, unknown-outcome handling and notifications remain as existing contracts define.
## Risks / Trade-offs
- [Market movement after observation] → Use the existing maximum price age and the last purchase-selection point; Jupiter execution and slippage rules remain unchanged.
- [Reference service failure] → Skip the purchase for this iteration, keep burn processing and re-evaluate next time; log exception type without arbitrary provider text.
- [Missing deployment predecessor] → Fail clearly through existing Hub dependency validation; document order without editing private deployment configuration.
- [Unknown transaction after restart] → Retain current pending-state/restart contracts; the price gate cannot replace reconciliation.
## Migration Plan
Compile and verify offline, then review the change. A later separately authorized deployment must have `assetaz.ticker.default` and `evelyn.status.prod` before a selected `evelyn.iou-burner.prod`. This implementation request changes no live configuration, performs no transactions and deploys nothing. Rollback restores the preceding application version and its constructor composition; uncertain transactions must first be reconciled under the existing restart rules.
@@ -0,0 +1,28 @@
# Proposal
## Why
Issue #94 requires Evelyn IOU buybacks to reduce supply specifically while the Evelyn Price Index is below zero. The burner currently buys whenever an input is eligible, so it needs the existing reference-price policy before submitting a new purchase while preserving independent EVE burning.
## What Changes
- Permit new buybacks only with a fresh canonical EVE/USDT price strictly below the corresponding Evelyn status service's reference price at the current decision instant.
- Skip only the purchase when either price is unusable or the market price is at/above reference; continue existing EVE burn processing and re-evaluate next iteration.
- Preserve pending-burn priority, read-only pending-swap reconciliation, at most one new swap, and all existing amount, reserve, precision and transaction rules.
- Inject the Production Evelyn status service through the existing Hub composition, documenting its explicit startup dependency and the purchase-only policy.
- Update constructor/domain JavaDoc and operating documentation and verify the boundaries and recovery paths offline.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `evelyn-iou-burner-service`: Gate new purchases on the existing reference-price API while keeping reconciliation and burns independent.
- `nenjim-hub-configuration`: Supply the corresponding Production status service to the burner using existing explicit dependency ordering.
## Impact
The burner constructor gains an `EvelynStatusService` dependency, supplied by `NenjimHubImpl`. Runtime input configuration, schedule, Ticker sources, history, ValueTags and live deployment configuration remain outside this change. Verification uses controlled offline collaborators without financial transactions or permanent test additions.
@@ -0,0 +1,71 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: At most one confirmed swap attempt per iteration
After selecting a positive exact amount and satisfying the current below-reference price condition, 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, 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 production EVE burn minimum SHALL be 0.1 EVE and its retained reserve SHALL be 0.01 EVE, as selected by the owner on 2026-10-06, configured as MoneyAmount values using the canonical EVE UUID. The production Hub SHALL supply its corresponding Production Evelyn status service for current reference-price decisions. 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, supplies minimum burn 0.1 EVE/reserve 0.01 EVE and the Production status service, and retains the other existing production arguments
#### Scenario: Production burn threshold
- **WHEN** production EVE balance is below 0.11, exactly 0.11, or 0.135 with usable mint precision and fresh price
- **THEN** below 0.11 produces no burn, 0.11 burns 0.1 and 0.135 burns 0.125; successful burns retain 0.01 EVE
## ADDED Requirements
### Requirement: New buybacks require a below-reference market price
An otherwise eligible new buyback SHALL require a fresh canonical EVE/USDT observation within the existing maximum price age and a usable canonical EVE/USDT reference price from the injected corresponding Evelyn status service at the current decision instant. Both prices SHALL be positive. Only actual price strictly below reference SHALL permit submission; equality SHALL not qualify. The existing public reference operation SHALL be reused without copying curve calculations or reading stored history.
#### Scenario: Strict boundary below reference
- **WHEN** an input is otherwise eligible and reference price is 15 USDT/EVE
- **THEN** market price 14.99 permits the existing buyback, while 15.00 and 15.01 submit no new swap from any input in that iteration
#### Scenario: Required prices are unusable
- **WHEN** market observation is missing, stale, incomplete, unavailable or has an invalid pair/non-positive price, or reference lookup throws, is missing or has an invalid pair/non-positive price
- **THEN** no new swap is submitted and a safe diagnostic explains why the purchase was skipped
#### Scenario: Reference uses the current decision instant
- **WHEN** the first usable input is ready for a new swap
- **THEN** the corresponding status service is asked for its reference price at the current instant rather than using a sampled history point or the observation timestamp
### Requirement: Price skips preserve independent burn processing and recovery
A rejected buyback price condition SHALL skip purchases only, continue complete EVE-balance processing under the existing independent burn requirements, and SHALL NOT create a suspension. Each subsequent otherwise eligible iteration SHALL re-evaluate prices. Pending-burn priority and read-only pending-swap reconciliation SHALL remain independent of the new-purchase condition; any newly permitted purchase after reconciliation SHALL pass the current price check.
#### Scenario: Existing EVE burns at or above reference
- **WHEN** market price is equal to or above reference and existing EVE satisfies its own reserve, minimum, precision, fresh valuation and transaction requirements
- **THEN** no new swap occurs and the existing eligible EVE is burned normally
#### Scenario: Reference unavailable but burn valuation is usable
- **WHEN** reference lookup is unavailable or invalid but existing EVE and its fresh canonical valuation satisfy the burn requirements
- **THEN** the purchase is skipped and the independent eligible burn still occurs
#### Scenario: Next scheduled iteration recovers
- **WHEN** one iteration skips a purchase because its prices do not qualify and a later otherwise eligible iteration has usable prices below reference
- **THEN** the later iteration can attempt its one new swap without a restart or manual reset
#### Scenario: Pending swap is resolved above reference
- **WHEN** a pending swap resolves read-only at or above reference
- **THEN** it is cleared normally, any otherwise eligible new purchase is price-checked and skipped, and existing EVE burn evaluation continues
#### Scenario: Unresolved swap and pending burn retain priority
- **WHEN** a swap remains unresolved or the iteration starts with a pending burn
- **THEN** existing transaction-priority rules apply without evaluating a new-purchase price condition or submitting a new swap
@@ -0,0 +1,48 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Selected processes are constructed and started lazily in exact order
The Hub SHALL support the modern process IDs `nenjim.registry.service`, `nenjim.journal.service`, `assetaz.ticker.default`, `jupiter-perps-alarm.default`, `evelyn.status.prod`, `evelyn.status.test`, `evelyn.iou-burner.prod`, `evelyn.liquidity.prod`, `nenjim.javafx-runtime.service`, `evelyn.mission-control.default`, and `nenjim.process-manager.default` through implementation-only construction capability that creates no process instance during validation. After passive composition, the Hub SHALL visit every configured entry after Registry in declared order, beginning with Journal. At each entry it SHALL construct only that process and any explicitly composed process-specific passive dependencies or administration view, register the process under the exact configured ID, resolve it through the canonical `NenjimProcess` contract where applicable, call `start()`, and record the ID only after successful return.
The production IOU burner SHALL receive the already constructed Production Evelyn status service, and SHALL require both Ticker and `evelyn.status.prod` to precede `evelyn.iou-burner.prod`. The configuration SHALL be the complete manually ordered process dependency closure. The Hub SHALL not discover, insert, recursively construct, reorder, or automatically start dependencies. A process omitted from `[AUTO_START]` SHALL not be constructed, registered, started, or recorded, even when its implementation is available. Legacy process prototypes outside the modern Hub graph SHALL not be selectable.
#### Scenario: Configured process starts successfully
- **WHEN** a configured process entry is reached and its dependencies are already available
- **THEN** that process is constructed, registered, started, and recorded before the next entry is reached
#### Scenario: Process is omitted
- **WHEN** a known process ID does not appear in `[AUTO_START]`
- **THEN** no instance of that process is constructed or registered and its lifecycle is never started or recorded
#### Scenario: Jupiter Perps Alarm is selected
- **WHEN** `jupiter-perps-alarm.default` is explicitly listed after its required predecessors
- **THEN** the otherwise inactive modern alarm process is constructed, registered, started and recorded at that exact position
#### Scenario: Dependency order is invalid
- **WHEN** a selected process is reached before a process dependency required by its current constructor or startup behavior
- **THEN** construction or startup fails clearly without implicit dependency construction, repair, or reordering
#### Scenario: JavaFX processes are omitted
- **WHEN** a valid headless plan omits `nenjim.javafx-runtime.service`, `evelyn.mission-control.default`, and `nenjim.process-manager.default`
- **THEN** none of those objects is constructed or registered and the JavaFX toolkit remains uninitialized by the Hub
#### Scenario: Renamed status services retain dependency wiring
- **WHEN** `evelyn.status.prod` and `evelyn.status.test` are listed after Ticker and before Mission Control
- **THEN** the Hub constructs separate Production/Test status services, registers the renamed IDs, and injects the corresponding typed service views into Mission Control in the configured order
#### Scenario: Deployment still contains an old status ID
- **WHEN** a private deployment configuration contains `evelyn.service.prod` or `evelyn.service.test`
- **THEN** validation rejects the unknown ID before startup; deployment migration replaces only that ID with `evelyn.status.prod` or `evelyn.status.test`, preserving selection and order
#### Scenario: Liquidity is explicitly selected
- **WHEN** `evelyn.liquidity.prod` is reached in a valid startup plan
- **THEN** the Hub loads its runtime configuration, binds the dedicated signer/wallet and Jupiter service to each other, constructs direct Raydium access and registers/starts the liquidity process without inserting a Ticker or status dependency or reordering the plan
#### Scenario: Production burner has its reference-price dependency
- **WHEN** `evelyn.iou-burner.prod` is reached after Ticker and `evelyn.status.prod`
- **THEN** the Hub injects the corresponding Production status service for buyback price decisions
#### Scenario: Production burner precedes its status dependency
- **WHEN** the burner is reached without a previously constructed Production status service
- **THEN** construction fails clearly without creating or starting the omitted dependency or the burner
@@ -0,0 +1,15 @@
# Tasks
## 1. Purchase policy and independent burns
- [x] 1.1 Require the corresponding Evelyn status service and gate a fully eligible new purchase on fresh canonical actual price strictly below its current reference; verify constructor validation, below/equal/above and unavailable/invalid prices with controlled offline checks.
- [x] 1.2 Update constructor/domain JavaDoc and Evelyn operating documentation; verify price skips still burn eligible existing EVE, re-evaluate next iteration and preserve pending burn/swap priority in the same offline checks.
## 2. Hub composition
- [x] 2.1 Inject the Production status dependency through the existing Hub guard and document the explicit Ticker/status/burner order; verify production arguments and fail-fast dependency behavior without starting the Hub or editing deployment configuration.
## 3. Integrated verification
- [x] 3.1 Compile main and existing test sources through normal Gradle/Detag, execute focused offline purchase/burn/recovery checks and relevant prior burner harnesses, and record their final results without adding permanent tests or running live transactions.
- [x] 3.2 Validate the scoped OpenSpec change strictly and check whitespace and changed-file scope; deliver completed planning artifacts and verification evidence for review.
@@ -0,0 +1,40 @@
# Verification
Verified on 2026-10-06 with `/usr/local/software/java/jfx-25`.
## Compilation and controlled acceptance checks
- `JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew compileJava compileTestJava --offline --no-daemon` → **BUILD SUCCESSFUL in 8s**. Normal Gradle-integrated Detag and both source compilations executed. The initial sandbox attempt could not create the existing Gradle cache lock outside the workspace; the same compilation passed with approved cache access.
- Temporary sources in `/tmp/evelyn-issue94` compiled with `javac --enable-preview --release 25 -cp 'build/classes/java/main:build/libs/*' -d /tmp/evelyn-issue94`.
- `java --enable-preview -cp '/tmp/evelyn-issue94:build/classes/java/main:build/libs/*' Issue94Verification` → **PASS: 459 focused issue #94 assertions**.
- The copied `Issue93Verification` → **PASS: 246 burn-policy regression assertions**.
- The copied `Issue92Verification` → **PASS: 115 input-minimum regression assertions**.
- The copied `Issue91Verification`, restricted to burner checks → **PASS: 80 pending-swap/recovery regression assertions**. Unchanged Solana/Jupiter library checks were not rerun.
- Production composition source check verifies the corresponding Production status dependency and preserved input reserves/minima/maxima, EVE burn minimum/reserve, hourly interval, slippage, freshness and finalization arguments.
- An INFO/WARN-enabled controlled run verifies purchase-skip reasons for at/above reference, unavailable/invalid reference and unusable market price, without arbitrary reference/Ticker exception messages.
The **900** controlled assertions invoke the actual burner iteration with offline wallet, chain, swap, Ticker, reference and notification collaborators. No RPC, signing, live financial operation, application/Hub startup or deployment configuration read occurs. The Hub dependency guard is invoked directly without constructing a Hub. No permanent automated tests were added. Prior harness copies only add an always-below reference stub to constructor calls and update #92's expected canonical EVE/USDT lookup count for eligible purchases; original temporary files remain intact.
The #94 harness covers 14.99/15.00/15.01 against reference 15, numeric equality across scales, a close below-reference decimal, the current reference decision instant instead of observation/history time, complete independent burns and reserve preservation, missing/unavailable/non-positive/null-amount/wrong-pair market and reference data, stale market observations, fresh independent burn valuation after a purchase-price failure, stale burn valuation postponement, next-iteration recovery from every rejection category, input priority and amount limits, below-minimum input fallback, no-eligible-input/direct-EVE-only paths, manual swap suspension and null status validation. It verifies original-signature read-only reconciliation while price is above reference, unresolved retention, definitive success/failure/proven expiry followed by a newly rejected purchase, later below-reference recovery, and simultaneous pending burn/swap priority with no reference lookup or new transaction. PriceObservation itself rejects null observation timestamp/price before they can reach the burner.
An initial temporary harness compilation used the wrong fully qualified component-ID type; it was corrected to the repository's existing `com.r35157.nenjim.service.registry.valuetypes.NenjimComponentId`. Final compiled checks pass. Concurrent initial Java invocations reported harmless performance-data lock warnings; the controlled diagnostic run disabled performance data. No production fix was needed for either verification-only issue.
## Review and workflow
Parent independent review of the proposal/specs/design, implementation, operating documentation and controlled harness found no actionable findings. The existing malformed burn JavaDoc line was corrected while updating that same list item. Scope is limited to the required reference dependency, new-purchase condition, Hub composition, related documentation and two OpenSpec capability deltas. Transaction execution, reconciliation, burn valuation/policy, ValueTags, scheduling and private deployment files retain their existing behavior.
Strict validation of the active change and `git diff --check` pass. All four planning artifact types and all five implementation tasks are complete. The active change is ready for review; main specifications have not been synchronized and the change has not been archived.
No deployment, commit, push, Gitea write or live integration was performed.
## Follow-up: clearer purchase-skip logs
The owner's requested logging refinement keeps price eligibility unchanged. INFO price skips now show a direct labelled comparison, for example `Skipping buyback as EVE price is at or above reference (market=15.01 >= reference=15 USDT/EVE)`, followed by the trading pair, decision instant and next action. Prices use plain decimals without redundant trailing zeros. WARN skips retain their reason; unavailable or invalid reference-price diagnostics also include the known market price.
`JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew compileJava --offline --no-daemon` passed. The existing temporary #94 harness passed its 459 assertions again with INFO/WARN enabled. Its captured output in `/tmp/evelyn-issue94/issue94-logging-refinement.log` confirms the numeric comparison for both equality and higher prices, reference failure details, missing/stale market diagnostics and exclusion of arbitrary exception messages. No new tests were added.
## Finalization
On 2026-10-06, the owner requested synchronization and archiving. Both capability deltas were merged into the main specifications for `evelyn-iou-burner-service` and `nenjim-hub-configuration`, preserving unrelated requirements and existing purpose sections. Every delta requirement was compared against the resulting main spec, and strict validation of both main specs and the completed change passed; only informational hints about long requirement descriptions were reported.
The seven completed change files, including `.openspec.yaml` and all five completed tasks, were archived intact to `openspec/changes/archive/2026-10-06-94-gate-buybacks-on-evelyn-reference-price`. OpenSpec reports no remaining active changes. This finalization records the subsequent sync/archive after the initial implementation and logging-refinement verification above.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-06
@@ -0,0 +1,29 @@
# Design
## Context
See proposal.md for motivation and the two delta specifications for required behavior. The burner already selects one rounded, reserve/minimum/maximum-qualified input, invokes Jupiter at most once, and then independently reloads EVE for burn evaluation. Its fresh canonical EVE/USDT reader is also used for burn valuation. The Hub constructs processes in the manually configured order and rejects unavailable process dependencies.
## Goals / Non-Goals
**Goals:** Insert the purchase condition at the last point before a new Jupiter attempt, use the corresponding live reference API, and retain independent burn and pending-transaction behavior.
**Non-Goals:** A general price-policy framework, another clock/configuration/freshness option, duplicated curve calculations, stored-history decisions, or changes to transaction execution, Ticker sources, lifecycle and deployment selection.
## Decisions
1. Add a required `EvelynStatusService` constructor dependency beside Ticker. Production composition uses the existing `requireProcessDependency` guard for `evelyn.status.prod`. The Hub does not infer or start dependencies; operators must place Ticker and Production status before the burner. Constructing a private reference calculator in the burner would duplicate ownership and risk policy drift.
2. After the first eligible rounded input is selected, reuse `findFreshPrice(eveUsdtTradingPair)`, obtain the reference through `getReferencePrice(Instant.now())`, validate its canonical pair and positive amount, and compare with `BigDecimal.compareTo`. This avoids division/index rounding and keeps the check close to submission. Checking before input selection would age the decision during balance/mint work; history/chart reads would use stale sample time.
3. A rejected condition returns only from `attemptAtMostOneSwap`, preserving the unconditional later `processCompleteEveBalance`. No other input can bypass a globally rejected EVE price. Missing/invalid reference or runtime lookup failure produces a safe warning; an at/above comparison logs the values and decision instant. No suspension state is set. Burn valuation independently reads its existing fresh price and never depends on reference availability.
4. Pending burn and swap paths stay ahead of input selection. Reconciliation is read-only regardless of price, and only a newly eligible purchase after reconciliation invokes the gate. Amount limits, precision, unknown-outcome handling and notifications remain as existing contracts define.
## Risks / Trade-offs
- [Market movement after observation] → Use the existing maximum price age and the last purchase-selection point; Jupiter execution and slippage rules remain unchanged.
- [Reference service failure] → Skip the purchase for this iteration, keep burn processing and re-evaluate next time; log exception type without arbitrary provider text.
- [Missing deployment predecessor] → Fail clearly through existing Hub dependency validation; document order without editing private deployment configuration.
- [Unknown transaction after restart] → Retain current pending-state/restart contracts; the price gate cannot replace reconciliation.
## Migration Plan
Compile and verify offline, then review the change. A later separately authorized deployment must have `assetaz.ticker.default` and `evelyn.status.prod` before a selected `evelyn.iou-burner.prod`. This implementation request changes no live configuration, performs no transactions and deploys nothing. Rollback restores the preceding application version and its constructor composition; uncertain transactions must first be reconciled under the existing restart rules.
@@ -0,0 +1,28 @@
# Proposal
## Why
Issue #94 requires Evelyn IOU buybacks to reduce supply specifically while the Evelyn Price Index is below zero. The burner currently buys whenever an input is eligible, so it needs the existing reference-price policy before submitting a new purchase while preserving independent EVE burning.
## What Changes
- Permit new buybacks only with a fresh canonical EVE/USDT price strictly below the corresponding Evelyn status service's reference price at the current decision instant.
- Skip only the purchase when either price is unusable or the market price is at/above reference; continue existing EVE burn processing and re-evaluate next iteration.
- Preserve pending-burn priority, read-only pending-swap reconciliation, at most one new swap, and all existing amount, reserve, precision and transaction rules.
- Inject the Production Evelyn status service through the existing Hub composition, documenting its explicit startup dependency and the purchase-only policy.
- Update constructor/domain JavaDoc and operating documentation and verify the boundaries and recovery paths offline.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `evelyn-iou-burner-service`: Gate new purchases on the existing reference-price API while keeping reconciliation and burns independent.
- `nenjim-hub-configuration`: Supply the corresponding Production status service to the burner using existing explicit dependency ordering.
## Impact
The burner constructor gains an `EvelynStatusService` dependency, supplied by `NenjimHubImpl`. Runtime input configuration, schedule, Ticker sources, history, ValueTags and live deployment configuration remain outside this change. Verification uses controlled offline collaborators without financial transactions or permanent test additions.
@@ -0,0 +1,71 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: At most one confirmed swap attempt per iteration
After selecting a positive exact amount and satisfying the current below-reference price condition, 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, 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 production EVE burn minimum SHALL be 0.1 EVE and its retained reserve SHALL be 0.01 EVE, as selected by the owner on 2026-10-06, configured as MoneyAmount values using the canonical EVE UUID. The production Hub SHALL supply its corresponding Production Evelyn status service for current reference-price decisions. 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, supplies minimum burn 0.1 EVE/reserve 0.01 EVE and the Production status service, and retains the other existing production arguments
#### Scenario: Production burn threshold
- **WHEN** production EVE balance is below 0.11, exactly 0.11, or 0.135 with usable mint precision and fresh price
- **THEN** below 0.11 produces no burn, 0.11 burns 0.1 and 0.135 burns 0.125; successful burns retain 0.01 EVE
## ADDED Requirements
### Requirement: New buybacks require a below-reference market price
An otherwise eligible new buyback SHALL require a fresh canonical EVE/USDT observation within the existing maximum price age and a usable canonical EVE/USDT reference price from the injected corresponding Evelyn status service at the current decision instant. Both prices SHALL be positive. Only actual price strictly below reference SHALL permit submission; equality SHALL not qualify. The existing public reference operation SHALL be reused without copying curve calculations or reading stored history.
#### Scenario: Strict boundary below reference
- **WHEN** an input is otherwise eligible and reference price is 15 USDT/EVE
- **THEN** market price 14.99 permits the existing buyback, while 15.00 and 15.01 submit no new swap from any input in that iteration
#### Scenario: Required prices are unusable
- **WHEN** market observation is missing, stale, incomplete, unavailable or has an invalid pair/non-positive price, or reference lookup throws, is missing or has an invalid pair/non-positive price
- **THEN** no new swap is submitted and a safe diagnostic explains why the purchase was skipped
#### Scenario: Reference uses the current decision instant
- **WHEN** the first usable input is ready for a new swap
- **THEN** the corresponding status service is asked for its reference price at the current instant rather than using a sampled history point or the observation timestamp
### Requirement: Price skips preserve independent burn processing and recovery
A rejected buyback price condition SHALL skip purchases only, continue complete EVE-balance processing under the existing independent burn requirements, and SHALL NOT create a suspension. Each subsequent otherwise eligible iteration SHALL re-evaluate prices. Pending-burn priority and read-only pending-swap reconciliation SHALL remain independent of the new-purchase condition; any newly permitted purchase after reconciliation SHALL pass the current price check.
#### Scenario: Existing EVE burns at or above reference
- **WHEN** market price is equal to or above reference and existing EVE satisfies its own reserve, minimum, precision, fresh valuation and transaction requirements
- **THEN** no new swap occurs and the existing eligible EVE is burned normally
#### Scenario: Reference unavailable but burn valuation is usable
- **WHEN** reference lookup is unavailable or invalid but existing EVE and its fresh canonical valuation satisfy the burn requirements
- **THEN** the purchase is skipped and the independent eligible burn still occurs
#### Scenario: Next scheduled iteration recovers
- **WHEN** one iteration skips a purchase because its prices do not qualify and a later otherwise eligible iteration has usable prices below reference
- **THEN** the later iteration can attempt its one new swap without a restart or manual reset
#### Scenario: Pending swap is resolved above reference
- **WHEN** a pending swap resolves read-only at or above reference
- **THEN** it is cleared normally, any otherwise eligible new purchase is price-checked and skipped, and existing EVE burn evaluation continues
#### Scenario: Unresolved swap and pending burn retain priority
- **WHEN** a swap remains unresolved or the iteration starts with a pending burn
- **THEN** existing transaction-priority rules apply without evaluating a new-purchase price condition or submitting a new swap
@@ -0,0 +1,48 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Selected processes are constructed and started lazily in exact order
The Hub SHALL support the modern process IDs `nenjim.registry.service`, `nenjim.journal.service`, `assetaz.ticker.default`, `jupiter-perps-alarm.default`, `evelyn.status.prod`, `evelyn.status.test`, `evelyn.iou-burner.prod`, `evelyn.liquidity.prod`, `nenjim.javafx-runtime.service`, `evelyn.mission-control.default`, and `nenjim.process-manager.default` through implementation-only construction capability that creates no process instance during validation. After passive composition, the Hub SHALL visit every configured entry after Registry in declared order, beginning with Journal. At each entry it SHALL construct only that process and any explicitly composed process-specific passive dependencies or administration view, register the process under the exact configured ID, resolve it through the canonical `NenjimProcess` contract where applicable, call `start()`, and record the ID only after successful return.
The production IOU burner SHALL receive the already constructed Production Evelyn status service, and SHALL require both Ticker and `evelyn.status.prod` to precede `evelyn.iou-burner.prod`. The configuration SHALL be the complete manually ordered process dependency closure. The Hub SHALL not discover, insert, recursively construct, reorder, or automatically start dependencies. A process omitted from `[AUTO_START]` SHALL not be constructed, registered, started, or recorded, even when its implementation is available. Legacy process prototypes outside the modern Hub graph SHALL not be selectable.
#### Scenario: Configured process starts successfully
- **WHEN** a configured process entry is reached and its dependencies are already available
- **THEN** that process is constructed, registered, started, and recorded before the next entry is reached
#### Scenario: Process is omitted
- **WHEN** a known process ID does not appear in `[AUTO_START]`
- **THEN** no instance of that process is constructed or registered and its lifecycle is never started or recorded
#### Scenario: Jupiter Perps Alarm is selected
- **WHEN** `jupiter-perps-alarm.default` is explicitly listed after its required predecessors
- **THEN** the otherwise inactive modern alarm process is constructed, registered, started and recorded at that exact position
#### Scenario: Dependency order is invalid
- **WHEN** a selected process is reached before a process dependency required by its current constructor or startup behavior
- **THEN** construction or startup fails clearly without implicit dependency construction, repair, or reordering
#### Scenario: JavaFX processes are omitted
- **WHEN** a valid headless plan omits `nenjim.javafx-runtime.service`, `evelyn.mission-control.default`, and `nenjim.process-manager.default`
- **THEN** none of those objects is constructed or registered and the JavaFX toolkit remains uninitialized by the Hub
#### Scenario: Renamed status services retain dependency wiring
- **WHEN** `evelyn.status.prod` and `evelyn.status.test` are listed after Ticker and before Mission Control
- **THEN** the Hub constructs separate Production/Test status services, registers the renamed IDs, and injects the corresponding typed service views into Mission Control in the configured order
#### Scenario: Deployment still contains an old status ID
- **WHEN** a private deployment configuration contains `evelyn.service.prod` or `evelyn.service.test`
- **THEN** validation rejects the unknown ID before startup; deployment migration replaces only that ID with `evelyn.status.prod` or `evelyn.status.test`, preserving selection and order
#### Scenario: Liquidity is explicitly selected
- **WHEN** `evelyn.liquidity.prod` is reached in a valid startup plan
- **THEN** the Hub loads its runtime configuration, binds the dedicated signer/wallet and Jupiter service to each other, constructs direct Raydium access and registers/starts the liquidity process without inserting a Ticker or status dependency or reordering the plan
#### Scenario: Production burner has its reference-price dependency
- **WHEN** `evelyn.iou-burner.prod` is reached after Ticker and `evelyn.status.prod`
- **THEN** the Hub injects the corresponding Production status service for buyback price decisions
#### Scenario: Production burner precedes its status dependency
- **WHEN** the burner is reached without a previously constructed Production status service
- **THEN** construction fails clearly without creating or starting the omitted dependency or the burner
@@ -0,0 +1,15 @@
# Tasks
## 1. Purchase policy and independent burns
- [x] 1.1 Require the corresponding Evelyn status service and gate a fully eligible new purchase on fresh canonical actual price strictly below its current reference; verify constructor validation, below/equal/above and unavailable/invalid prices with controlled offline checks.
- [x] 1.2 Update constructor/domain JavaDoc and Evelyn operating documentation; verify price skips still burn eligible existing EVE, re-evaluate next iteration and preserve pending burn/swap priority in the same offline checks.
## 2. Hub composition
- [x] 2.1 Inject the Production status dependency through the existing Hub guard and document the explicit Ticker/status/burner order; verify production arguments and fail-fast dependency behavior without starting the Hub or editing deployment configuration.
## 3. Integrated verification
- [x] 3.1 Compile main and existing test sources through normal Gradle/Detag, execute focused offline purchase/burn/recovery checks and relevant prior burner harnesses, and record their final results without adding permanent tests or running live transactions.
- [x] 3.2 Validate the scoped OpenSpec change strictly and check whitespace and changed-file scope; deliver completed planning artifacts and verification evidence for review.
@@ -0,0 +1,34 @@
# Verification
Verified on 2026-10-06 with `/usr/local/software/java/jfx-25`.
## Compilation and controlled acceptance checks
- `JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew compileJava compileTestJava --offline --no-daemon` → **BUILD SUCCESSFUL in 8s**. Normal Gradle-integrated Detag and both source compilations executed. The initial sandbox attempt could not create the existing Gradle cache lock outside the workspace; the same compilation passed with approved cache access.
- Temporary sources in `/tmp/evelyn-issue94` compiled with `javac --enable-preview --release 25 -cp 'build/classes/java/main:build/libs/*' -d /tmp/evelyn-issue94`.
- `java --enable-preview -cp '/tmp/evelyn-issue94:build/classes/java/main:build/libs/*' Issue94Verification` → **PASS: 459 focused issue #94 assertions**.
- The copied `Issue93Verification` → **PASS: 246 burn-policy regression assertions**.
- The copied `Issue92Verification` → **PASS: 115 input-minimum regression assertions**.
- The copied `Issue91Verification`, restricted to burner checks → **PASS: 80 pending-swap/recovery regression assertions**. Unchanged Solana/Jupiter library checks were not rerun.
- Production composition source check verifies the corresponding Production status dependency and preserved input reserves/minima/maxima, EVE burn minimum/reserve, hourly interval, slippage, freshness and finalization arguments.
- An INFO/WARN-enabled controlled run verifies purchase-skip reasons for at/above reference, unavailable/invalid reference and unusable market price, without arbitrary reference/Ticker exception messages.
The **900** controlled assertions invoke the actual burner iteration with offline wallet, chain, swap, Ticker, reference and notification collaborators. No RPC, signing, live financial operation, application/Hub startup or deployment configuration read occurs. The Hub dependency guard is invoked directly without constructing a Hub. No permanent automated tests were added. Prior harness copies only add an always-below reference stub to constructor calls and update #92's expected canonical EVE/USDT lookup count for eligible purchases; original temporary files remain intact.
The #94 harness covers 14.99/15.00/15.01 against reference 15, numeric equality across scales, a close below-reference decimal, the current reference decision instant instead of observation/history time, complete independent burns and reserve preservation, missing/unavailable/non-positive/null-amount/wrong-pair market and reference data, stale market observations, fresh independent burn valuation after a purchase-price failure, stale burn valuation postponement, next-iteration recovery from every rejection category, input priority and amount limits, below-minimum input fallback, no-eligible-input/direct-EVE-only paths, manual swap suspension and null status validation. It verifies original-signature read-only reconciliation while price is above reference, unresolved retention, definitive success/failure/proven expiry followed by a newly rejected purchase, later below-reference recovery, and simultaneous pending burn/swap priority with no reference lookup or new transaction. PriceObservation itself rejects null observation timestamp/price before they can reach the burner.
An initial temporary harness compilation used the wrong fully qualified component-ID type; it was corrected to the repository's existing `com.r35157.nenjim.service.registry.valuetypes.NenjimComponentId`. Final compiled checks pass. Concurrent initial Java invocations reported harmless performance-data lock warnings; the controlled diagnostic run disabled performance data. No production fix was needed for either verification-only issue.
## Review and workflow
Parent independent review of the proposal/specs/design, implementation, operating documentation and controlled harness found no actionable findings. The existing malformed burn JavaDoc line was corrected while updating that same list item. Scope is limited to the required reference dependency, new-purchase condition, Hub composition, related documentation and two OpenSpec capability deltas. Transaction execution, reconciliation, burn valuation/policy, ValueTags, scheduling and private deployment files retain their existing behavior.
Strict validation of the active change and `git diff --check` pass. All four planning artifact types and all five implementation tasks are complete. The active change is ready for review; main specifications have not been synchronized and the change has not been archived.
No deployment, commit, push, Gitea write or live integration was performed.
## Follow-up: clearer purchase-skip logs
The owner's requested logging refinement keeps price eligibility unchanged. INFO price skips now show a direct labelled comparison, for example `Skipping buyback as EVE price is at or above reference (market=15.01 >= reference=15 USDT/EVE)`, followed by the trading pair, decision instant and next action. Prices use plain decimals without redundant trailing zeros. WARN skips retain their reason; unavailable or invalid reference-price diagnostics also include the known market price.
`JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew compileJava --offline --no-daemon` passed. The existing temporary #94 harness passed its 459 assertions again with INFO/WARN enabled. Its captured output in `/tmp/evelyn-issue94/issue94-logging-refinement.log` confirms the numeric comparison for both equality and higher prices, reference failure details, missing/stale market diagnostics and exclusion of arbitrary exception messages. No new tests were added.
+196 -47
View File
@@ -2,7 +2,7 @@
## Purpose ## Purpose
Defines the Evelyn-specific service that safely converts prioritized wallet assets into Evelyn IOU, burns the complete visible EVE balance, and announces only finalized burns without automatically repeating transactions whose outcomes may be unknown. Defines the Evelyn-specific service that safely converts prioritized wallet assets into Evelyn IOU, accumulates EVE for burns above a configured minimum while retaining an EVE reserve, and announces only finalized burns without automatically repeating transactions whose outcomes may be unknown.
## Requirements ## Requirements
### Requirement: Evelyn-specific public service boundary ### Requirement: Evelyn-specific public service boundary
The system SHALL expose an empty lifecycle-free `EvelynIOUBurnerService` under `com.fanitas.evelyn.service.burner` as the typed domain service and Registry view until external domain operations exist, and SHALL keep its reference implementation under `com.fanitas.evelyn.service.burner.impl.ref`. The same reference implementation object SHALL implement both `EvelynIOUBurnerService` and `NenjimProcess`; the Hub SHALL start it only through its `NenjimProcess` view. Neither the service interface nor the implementation SHALL expose a general stop, restart, worker-control, or other lifecycle operation. The service SHALL remain specific to Evelyn IOU and SHALL NOT introduce a generic token-burner abstraction, burner-specific input interfaces, records, or class hierarchies. The system SHALL expose an empty lifecycle-free `EvelynIOUBurnerService` under `com.fanitas.evelyn.service.burner` as the typed domain service and Registry view until external domain operations exist, and SHALL keep its reference implementation under `com.fanitas.evelyn.service.burner.impl.ref`. The same reference implementation object SHALL implement both `EvelynIOUBurnerService` and `NenjimProcess`; the Hub SHALL start it only through its `NenjimProcess` view. Neither the service interface nor the implementation SHALL expose a general stop, restart, worker-control, or other lifecycle operation. The service SHALL remain specific to Evelyn IOU and SHALL NOT introduce a generic token-burner abstraction, burner-specific input interfaces, records, or class hierarchies.
@@ -16,31 +16,43 @@ 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 pricing and processing. An empty list 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 prices - **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
- **WHEN** no buyback inputs are configured but the wallet contains EVE - **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 - **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 ### Requirement: Complete constructor validation
Construction SHALL require all injected services, the input list, maximum swap amount, 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 UUID to be known to the injected Currency Identity Service; UUIDs to be unique; and EVE not to be an input currency. It SHALL require `maximumSwapAmountInUSDC` to be greater than zero, 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, UUIDs are distinct and known, 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
- **WHEN** a reserve is negative or null, the USDC cap is non-positive, slippage is outside its inclusive range, maximum price age is non-positive, or either duration is null or non-positive - **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 - **THEN** construction fails before a worker or transaction is created
#### Scenario: Invalid input identity #### Scenario: Invalid input identity
@@ -51,8 +63,28 @@ Every configured non-SOL UUID SHALL resolve through reverse CIS lookup to exactl
- **WHEN** a configured non-SOL currency has zero or multiple `solana-mint` references or its sole external ID is null or blank - **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 - **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: 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, construct input/USDC pairs with the canonical USDC UUID, 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.
#### Scenario: Native SOL configuration #### Scenario: Native SOL configuration
- **WHEN** a configured input UUID is the stable SOL UUID - **WHEN** a configured input UUID is the stable SOL UUID
@@ -65,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
@@ -97,7 +129,7 @@ At the start of an iteration, if a prior burn signature is pending, the service
### Requirement: Prioritized input inspection with internal Solana translation ### Requirement: Prioritized input inspection with internal Solana translation
Unless swaps are suspended, an iteration SHALL inspect configured input entries in order until it finds the first usable candidate. For every entry it SHALL resolve the configured UUID to its canonical currency. Native SOL SHALL use `getSolanaBalance().amount()` and nine-decimal precision. Every other input SHALL use its unique CIS-resolved Solana mint; the service SHALL inspect the mint account owner to detect the original SPL Token Program or Token-2022, use that program for wallet balance lookup, and obtain precision from Solana mint metadata. A missing SPL-token account SHALL equal zero balance. Unless swaps are suspended, an iteration SHALL inspect configured input entries in order until it finds the first usable candidate. For every entry it SHALL resolve the configured UUID to its canonical currency. Native SOL SHALL use `getSolanaBalance().amount()` and nine-decimal precision. Every other input SHALL use its unique CIS-resolved Solana mint; the service SHALL inspect the mint account owner to detect the original SPL Token Program or Token-2022, use that program for wallet balance lookup, and obtain precision from Solana mint metadata. A missing SPL-token account SHALL equal zero balance.
For each entry the service SHALL subtract its configured reserve and skip non-positive spendable balance. Missing or unusable balance, mint account, supported program, mint metadata, or price SHALL produce a warning and allow later configured entries to be examined. Interruption SHALL preserve the thread interrupt state and end processing rather than becoming a skipped candidate. Failure to select an input SHALL NOT prevent the later complete-EVE burn evaluation. For each entry the service SHALL subtract its configured reserve and skip non-positive spendable balance. Missing or unusable balance, mint account, supported program, or mint metadata SHALL produce a warning and allow later configured entries to be examined. Interruption SHALL preserve the thread interrupt state and end processing rather than becoming a skipped candidate. Failure to select an input SHALL NOT prevent the later complete-EVE burn evaluation.
#### Scenario: Missing token account #### Scenario: Missing token account
- **WHEN** a configured non-SOL mint is valid but the wallet has no token account under its detected program - **WHEN** a configured non-SOL mint is valid but the wallet has no token account under its detected program
@@ -111,38 +143,10 @@ For each entry the service SHALL subtract its configured reserve and skip non-po
- **WHEN** wallet, CIS-adjacent processing, or Solana access is interrupted while inspecting an input - **WHEN** wallet, CIS-adjacent processing, or Solana access is interrupted while inspecting an input
- **THEN** the service preserves interruption and ends processing without treating the input as an ordinary failure - **THEN** the service preserves interruption and ends processing without treating the input as an ordinary failure
### Requirement: Fresh canonical input prices
For each non-USDC candidate, the service SHALL request the canonical `<INPUT_CURRENCY>/USDC` pair and accept only a positive price with a non-null observation timestamp no more than `maximumPriceAge` milliseconds old at the current iteration time. The stable USDC UUID SHALL instead receive an exact price of one without a ticker observation. A missing, unsupported, invalid, or stale input price SHALL be warned about using canonical currency identity, SHALL allow the next configured input to be considered, and SHALL NOT prevent later EVE burning.
#### Scenario: USDC is the input
- **WHEN** the canonical input UUID is USDC and its spendable balance is positive
- **THEN** the service uses exactly one USDC per USDC without querying the ticker for that input price
#### Scenario: Input price is unusable
- **WHEN** an input observation is missing, unsupported, non-positive, lacks a timestamp, or is older than the maximum age
- **THEN** the service does not swap that currency and continues with later configured priorities
### Requirement: Exact USDC-capped amount and per-currency reserve
For a usable candidate, the service SHALL calculate `availableForSwap = max(balance - minimumReserve, 0)`, calculate `maximumInputAmount = maximumSwapAmountInUSDC / inputPriceInUSDC`, select the lesser amount, and round downward to the input currency's supported decimal precision. It SHALL skip a result that rounds to zero and SHALL never intentionally swap more than either the spendable balance or the configured USDC-equivalent maximum through rounding.
The SOL reserve comparison SHALL use the native balance without estimating or subtracting future transaction fees. The USDC maximum SHALL limit buybacks only and SHALL NOT cap how much EVE may be burned.
#### Scenario: Price cap is lower than spendable balance
- **WHEN** a currency has five spendable units, its price is 100 USDC per unit, and the USDC maximum is one
- **THEN** no more than 0.01 unit is selected before downward 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: 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
### 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 and satisfying the current below-reference price condition, 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
@@ -150,47 +154,63 @@ 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`
- **THEN** the burner performs no additional confirmation wait and reloads the wallet instead of deriving its burn amount only from the returned output amount - **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: Complete visible EVE balance is eligible ### Requirement: Complete visible EVE balance is eligible
After the optional swap attempt, the service SHALL detect the fixed EVE mint's supported token program internally and reload the wallet's complete available EVE balance. A missing EVE mint account, unsupported program, absent wallet token account, zero balance, or negative/invalid balance SHALL cause no burn or notification in that iteration. Every positive complete balance SHALL be eligible without a minimum threshold, including EVE deposited before the iteration or visible after a failed or absent swap. After the optional swap attempt, the service SHALL detect the fixed EVE mint's supported token program internally and reload the wallet's complete available EVE balance. A missing EVE mint account, unsupported program, absent wallet token account, zero balance, or negative/invalid balance SHALL cause no burn or notification in that iteration. The service SHALL subtract its configured EVE reserve, clamp negative available amounts to zero and round down to validated EVE mint precision using the detected token program. It SHALL submit a new burn only when the actual rounded amount is positive and at least the configured minimum burn amount; equality qualifies. It SHALL burn the entire eligible amount above reserve, without a maximum or fixed batch size, including EVE deposited before the iteration or visible after a failed or absent swap. Below threshold or with unusable mint precision it SHALL retain the EVE and send no burn notification. This threshold SHALL NOT suppress independent buybacks.
#### Scenario: Existing EVE without buyback #### Scenario: Existing EVE without buyback
- **WHEN** no swap is performed but the wallet contains a positive EVE balance - **WHEN** no swap is performed but the wallet contains a positive EVE balance
- **THEN** the service evaluates that complete balance for burning - **THEN** the service evaluates that complete balance under the EVE reserve, precision and burn-minimum rules
#### Scenario: EVE account is absent or empty #### Scenario: EVE account is absent or empty
- **WHEN** the wallet has no EVE token account or its complete balance is zero - **WHEN** the wallet has no EVE token account or its complete balance is zero
- **THEN** the iteration submits no burn and sends no notification - **THEN** the iteration submits no burn and sends no notification
#### Scenario: Issue example threshold and reserve
- **WHEN** minimum is 1 EVE and reserve is 0.1 EVE
- **THEN** balances 0, 0.05, 0.1, 1.05 and 1.099999 produce no burn; balance 1.1 burns 1 and balance 1.35 burns 1.25, retaining 0.1 after success
#### Scenario: Accumulation across small buybacks
- **WHEN** individually eligible small buybacks accumulate EVE over multiple iterations
- **THEN** each below-threshold iteration still permits its buyback and sends no burn message; the first qualifying iteration burns all eligible EVE and subsequent accumulation starts with the retained reserve
#### Scenario: Exact configuration exceeds mint precision
- **WHEN** six-decimal mint precision rounds available 1.0000009 to 1.000000 while minimum is 1.0000001
- **THEN** no burn occurs; no amount is rounded upward and the reserve is never consumed to reach the minimum
### Requirement: Fresh EVE value is required before submission ### Requirement: Fresh EVE value is required before submission
Immediately before a new burn, the service SHALL obtain the latest canonical EVE/USDT observation and require a positive price, non-null observation timestamp, and age no greater than `maximumPriceAge` milliseconds. USDT SHALL be treated as USD for notification value, calculated as the complete EVE amount multiplied by that fresh price. If the pair is unsupported or the observation is missing, invalid, or stale, the service SHALL warn, postpone the burn, send no notification, and retry only by re-evaluating state in a later scheduled iteration. Immediately before a new burn, the service SHALL obtain the latest canonical EVE/USDT observation and require a positive price, non-null observation timestamp, and age no greater than `maximumPriceAge` milliseconds. USDT SHALL be treated as USD for notification value, calculated as the actual rounded eligible EVE amount above reserve multiplied by that fresh price. If the pair is unsupported or the observation is missing, invalid, or stale, the service SHALL warn, postpone the burn, send no notification, and retry only by re-evaluating state in a later scheduled iteration.
#### Scenario: Fresh EVE price exists #### Scenario: Fresh EVE price exists
- **WHEN** a complete positive EVE balance and sufficiently fresh positive EVE/USDT observation are available - **WHEN** a positive rounded EVE amount at or above the burn minimum and sufficiently fresh positive EVE/USDT observation are available
- **THEN** the service calculates the notification's USD value from that complete balance before submitting the burn - **THEN** the service calculates the notification's USD value from that actual eligible burn amount, excluding reserve before submitting the burn
#### Scenario: EVE price is stale #### Scenario: EVE price is stale
- **WHEN** EVE is present but its observation is older than the configured age limit - **WHEN** EVE is present but its observation is older than the configured age limit
- **THEN** the service leaves EVE unburned and sends no notification in that iteration - **THEN** the service leaves EVE unburned and sends no notification in that iteration
### Requirement: Single complete-balance burn submission and pending state ### Requirement: Single complete-balance burn submission and pending state
For a positive EVE balance with a usable price, the service SHALL call the wallet burn operation exactly once with the fixed EVE mint and complete observed balance. It SHALL NOT automatically retry, rebuild, re-sign, or resubmit within or across iterations. Immediately after a non-blank signature is returned, it SHALL retain process-local pending data containing that signature, submitted EVE amount, and its calculated notification value or complete notification text before awaiting the signature. For a positive rounded EVE amount at or above the minimum with a usable price, the service SHALL call the wallet burn operation exactly once with the fixed EVE mint and entire eligible rounded amount above reserve. It SHALL NOT automatically retry, rebuild, re-sign, or resubmit within or across iterations. Immediately after a non-blank signature is returned, it SHALL retain process-local pending data containing that signature, actual submitted EVE amount excluding reserve, and its calculated notification value or complete notification text before awaiting the signature.
If burn submission fails without a usable signature and its outcome may be unknown, including an I/O or interruption path whose contract permits post-submission uncertainty, the service SHALL conservatively suspend further burn submissions for the current process. It MAY continue ordinary iteration work that does not submit another burn. Recovery SHALL require external reconciliation followed by a complete process restart, not a Burner service stop/start operation. If burn submission fails without a usable signature and its outcome may be unknown, including an I/O or interruption path whose contract permits post-submission uncertainty, the service SHALL conservatively suspend further burn submissions for the current process. It MAY continue ordinary iteration work that does not submit another burn. Recovery SHALL require external reconciliation followed by a complete process restart, not a Burner service stop/start operation.
#### Scenario: Burn returns a signature #### Scenario: Burn returns a signature
- **WHEN** the wallet submits the complete observed EVE balance and returns a non-blank signature - **WHEN** the wallet submits the entire eligible rounded amount above reserve and returns a non-blank signature
- **THEN** pending state is retained before the service begins finalization waiting - **THEN** pending state is retained before the service begins finalization waiting
#### Scenario: Submission outcome may be unknown without a signature #### Scenario: Submission outcome may be unknown without a signature
- **WHEN** burn submission fails or is interrupted without providing a usable signature and may already have reached Solana - **WHEN** burn submission fails or is interrupted without providing a usable signature and may already have reached Solana
- **THEN** the service performs no further burn submission until external reconciliation and a complete process restart - **THEN** the service performs no further burn submission until external reconciliation and a complete process restart
#### Scenario: Pending burn bypasses new threshold evaluation
- **WHEN** a retained burn signature remains pending while balance is now below the minimum or at the reserve
- **THEN** only the original signature is awaited, its original submitted amount/value are preserved and it is never re-evaluated, rebuilt or resubmitted
### Requirement: Finalized burn outcome governs completion ### Requirement: Finalized burn outcome governs completion
Every known burn signature SHALL be awaited through the Solana blockchain at `FINALIZED` with exactly the configured finalization timeout. `SUCCEEDED` SHALL authorize notification; `FAILED` SHALL log the slot and complete on-chain failure details, clear pending state, and send no notification; `TIMED_OUT` and `IOException` SHALL keep the pending signature because its result remains unknown; and `InterruptedException` SHALL preserve interruption and pending state. None of these outcomes SHALL cause automatic transaction resubmission. Every known burn signature SHALL be awaited through the Solana blockchain at `FINALIZED` with exactly the configured finalization timeout. `SUCCEEDED` SHALL authorize notification; `FAILED` SHALL log the slot and complete on-chain failure details, clear pending state, and send no notification; `TIMED_OUT` and `IOException` SHALL keep the pending signature because its result remains unknown; and `InterruptedException` SHALL preserve interruption and pending state. None of these outcomes SHALL cause automatic transaction resubmission.
@@ -225,3 +245,132 @@ The EVE amount SHALL use plain decimal notation without unnecessary trailing zer
#### Scenario: Notification delivery fails #### Scenario: Notification delivery fails
- **WHEN** the sole notification invocation throws - **WHEN** the sole notification invocation throws
- **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
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 governed independently by their own EVE minimum and reserve. 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 its rounded eligible amount above the EVE reserve is burned if it meets the EVE burn minimum
### 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 production EVE burn minimum SHALL be 0.1 EVE and its retained reserve SHALL be 0.01 EVE, as selected by the owner on 2026-10-06, configured as MoneyAmount values using the canonical EVE UUID. The production Hub SHALL supply its corresponding Production Evelyn status service for current reference-price decisions. 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, supplies minimum burn 0.1 EVE/reserve 0.01 EVE and the Production status service, and retains the other existing production arguments
#### Scenario: Production burn threshold
- **WHEN** production EVE balance is below 0.11, exactly 0.11, or 0.135 with usable mint precision and fresh price
- **THEN** below 0.11 produces no burn, 0.11 burns 0.1 and 0.135 burns 0.125; successful burns retain 0.01 EVE
### 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
### Requirement: Independent EVE burn policy configuration
The constructor SHALL accept minimumBurnAmount and eveReserve as EVE-denominated MoneyAmount values identified by canonical EVE UUID regardless of supplied display metadata. Both values, their amounts, currencies and UUIDs SHALL be non-null; minimum SHALL be strictly positive and reserve non-negative. Zero reserve SHALL be valid. Invalid burn configuration SHALL fail before worker startup without changing existing input validation.
#### Scenario: Invalid burn configuration
- **WHEN** either value, amount, currency or UUID is null, a UUID is not EVE, the minimum is zero/negative, or the reserve is negative
- **THEN** construction fails before creating a worker or transaction
#### Scenario: Stale EVE metadata and zero reserve
- **WHEN** both values carry the EVE UUID with stale names/symbols and the reserve is zero
- **THEN** construction succeeds and uses canonical EVE identity with the configured exact numeric amounts
### Requirement: New buybacks require a below-reference market price
An otherwise eligible new buyback SHALL require a fresh canonical EVE/USDT observation within the existing maximum price age and a usable canonical EVE/USDT reference price from the injected corresponding Evelyn status service at the current decision instant. Both prices SHALL be positive. Only actual price strictly below reference SHALL permit submission; equality SHALL not qualify. The existing public reference operation SHALL be reused without copying curve calculations or reading stored history.
#### Scenario: Strict boundary below reference
- **WHEN** an input is otherwise eligible and reference price is 15 USDT/EVE
- **THEN** market price 14.99 permits the existing buyback, while 15.00 and 15.01 submit no new swap from any input in that iteration
#### Scenario: Required prices are unusable
- **WHEN** market observation is missing, stale, incomplete, unavailable or has an invalid pair/non-positive price, or reference lookup throws, is missing or has an invalid pair/non-positive price
- **THEN** no new swap is submitted and a safe diagnostic explains why the purchase was skipped
#### Scenario: Reference uses the current decision instant
- **WHEN** the first usable input is ready for a new swap
- **THEN** the corresponding status service is asked for its reference price at the current instant rather than using a sampled history point or the observation timestamp
### Requirement: Price skips preserve independent burn processing and recovery
A rejected buyback price condition SHALL skip purchases only, continue complete EVE-balance processing under the existing independent burn requirements, and SHALL NOT create a suspension. Each subsequent otherwise eligible iteration SHALL re-evaluate prices. Pending-burn priority and read-only pending-swap reconciliation SHALL remain independent of the new-purchase condition; any newly permitted purchase after reconciliation SHALL pass the current price check.
#### Scenario: Existing EVE burns at or above reference
- **WHEN** market price is equal to or above reference and existing EVE satisfies its own reserve, minimum, precision, fresh valuation and transaction requirements
- **THEN** no new swap occurs and the existing eligible EVE is burned normally
#### Scenario: Reference unavailable but burn valuation is usable
- **WHEN** reference lookup is unavailable or invalid but existing EVE and its fresh canonical valuation satisfy the burn requirements
- **THEN** the purchase is skipped and the independent eligible burn still occurs
#### Scenario: Next scheduled iteration recovers
- **WHEN** one iteration skips a purchase because its prices do not qualify and a later otherwise eligible iteration has usable prices below reference
- **THEN** the later iteration can attempt its one new swap without a restart or manual reset
#### Scenario: Pending swap is resolved above reference
- **WHEN** a pending swap resolves read-only at or above reference
- **THEN** it is cleared normally, any otherwise eligible new purchase is price-checked and skipped, and existing EVE burn evaluation continues
#### Scenario: Unresolved swap and pending burn retain priority
- **WHEN** a swap remains unresolved or the iteration starts with a pending burn
- **THEN** existing transaction-priority rules apply without evaluating a new-purchase price condition or submitting a new swap
+27 -4
View File
@@ -102,7 +102,7 @@ The service SHALL first accept a Jupiter execution response only when it has sta
#### Scenario: Provider succeeds but confirmation times out #### 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
@@ -91,7 +91,7 @@ After validating the entire plan, the Hub SHALL construct the configured Registr
### Requirement: Selected processes are constructed and started lazily in exact order ### Requirement: Selected processes are constructed and started lazily in exact order
The Hub SHALL support the modern process IDs `nenjim.registry.service`, `nenjim.journal.service`, `assetaz.ticker.default`, `jupiter-perps-alarm.default`, `evelyn.status.prod`, `evelyn.status.test`, `evelyn.iou-burner.prod`, `evelyn.liquidity.prod`, `nenjim.javafx-runtime.service`, `evelyn.mission-control.default`, and `nenjim.process-manager.default` through implementation-only construction capability that creates no process instance during validation. After passive composition, the Hub SHALL visit every configured entry after Registry in declared order, beginning with Journal. At each entry it SHALL construct only that process and any explicitly composed process-specific passive dependencies or administration view, register the process under the exact configured ID, resolve it through the canonical `NenjimProcess` contract where applicable, call `start()`, and record the ID only after successful return. The Hub SHALL support the modern process IDs `nenjim.registry.service`, `nenjim.journal.service`, `assetaz.ticker.default`, `jupiter-perps-alarm.default`, `evelyn.status.prod`, `evelyn.status.test`, `evelyn.iou-burner.prod`, `evelyn.liquidity.prod`, `nenjim.javafx-runtime.service`, `evelyn.mission-control.default`, and `nenjim.process-manager.default` through implementation-only construction capability that creates no process instance during validation. After passive composition, the Hub SHALL visit every configured entry after Registry in declared order, beginning with Journal. At each entry it SHALL construct only that process and any explicitly composed process-specific passive dependencies or administration view, register the process under the exact configured ID, resolve it through the canonical `NenjimProcess` contract where applicable, call `start()`, and record the ID only after successful return.
The configuration SHALL be the complete manually ordered process dependency closure. The Hub SHALL not discover, insert, recursively construct, reorder, or automatically start dependencies. A process omitted from `[AUTO_START]` SHALL not be constructed, registered, started, or recorded, even when its implementation is available. Legacy process prototypes outside the modern Hub graph SHALL not be selectable. The production IOU burner SHALL receive the already constructed Production Evelyn status service, and SHALL require both Ticker and `evelyn.status.prod` to precede `evelyn.iou-burner.prod`. The configuration SHALL be the complete manually ordered process dependency closure. The Hub SHALL not discover, insert, recursively construct, reorder, or automatically start dependencies. A process omitted from `[AUTO_START]` SHALL not be constructed, registered, started, or recorded, even when its implementation is available. Legacy process prototypes outside the modern Hub graph SHALL not be selectable.
#### Scenario: Configured process starts successfully #### Scenario: Configured process starts successfully
- **WHEN** a configured process entry is reached and its dependencies are already available - **WHEN** a configured process entry is reached and its dependencies are already available
@@ -125,6 +125,14 @@ The configuration SHALL be the complete manually ordered process dependency clos
- **WHEN** `evelyn.liquidity.prod` is reached in a valid startup plan - **WHEN** `evelyn.liquidity.prod` is reached in a valid startup plan
- **THEN** the Hub loads its runtime configuration, binds the dedicated signer/wallet and Jupiter service to each other, constructs direct Raydium access and registers/starts the liquidity process without inserting a Ticker or status dependency or reordering the plan - **THEN** the Hub loads its runtime configuration, binds the dedicated signer/wallet and Jupiter service to each other, constructs direct Raydium access and registers/starts the liquidity process without inserting a Ticker or status dependency or reordering the plan
#### Scenario: Production burner has its reference-price dependency
- **WHEN** `evelyn.iou-burner.prod` is reached after Ticker and `evelyn.status.prod`
- **THEN** the Hub injects the corresponding Production status service for buyback price decisions
#### Scenario: Production burner precedes its status dependency
- **WHEN** the burner is reached without a previously constructed Production status service
- **THEN** construction fails clearly without creating or starting the omitted dependency or the burner
### Requirement: Configured startup is fail-fast and reports actual milestones ### Requirement: Configured startup is fail-fast and reports actual milestones
Registry and every selected process SHALL be registered before its `start()` call and appended to the Hub's immutable ordered startup snapshot only after `start()` returns successfully. Construction failure SHALL leave the selected process unregistered. Registration or startup failure SHALL stop the plan before any later entry is constructed or started; a startup failure MAY leave that process registered but SHALL not record it. Earlier successful processes SHALL remain in snapshots, no rollback or retry SHALL occur, and the Hub SHALL announce its existing online point and enter its blocking runtime wait only after every configured process succeeds. Registry and every selected process SHALL be registered before its `start()` call and appended to the Hub's immutable ordered startup snapshot only after `start()` returns successfully. Construction failure SHALL leave the selected process unregistered. Registration or startup failure SHALL stop the plan before any later entry is constructed or started; a startup failure MAY leave that process registered but SHALL not record it. Earlier successful processes SHALL remain in snapshots, no rollback or retry SHALL occur, and the Hub SHALL announce its existing online point and enter its blocking runtime wait only after every configured process succeeds.
@@ -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,21 @@ 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 minimum and maximum
* buyback amount supplied as two further lists of {@link MoneyAmount} values,
* matched by UUID independently of list order/name/symbol. The minimum must not
* 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.
* 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
* part of Evelyn's input configuration.</p> * part of Evelyn's input configuration.</p>
* *
* <p>Two independent EVE-denominated {@link MoneyAmount} values configure the
* minimum per burn and retained EVE reserve. Their canonical EVE UUID is
* authoritative; the minimum must be strictly positive and the reserve
* non-negative. A burn threshold does not suppress independent buybacks.</p>
*
* <p>Every non-overlapping iteration has this order:</p> * <p>Every non-overlapping iteration has this order:</p>
* <ol> * <ol>
* <li>If a prior burn has a pending signature, await only that exact * <li>If a prior burn has a pending signature, await only that exact
@@ -24,28 +35,52 @@ 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, apply its own minimum reserve, and require a * representation internally and apply its own minimum reserve without an
* fresh input/USDC price. Missing or unusable candidate data does not * input-price lookup. Missing or unusable candidate data does not
* prevent later currencies from being considered.</li> * prevent later currencies from being considered.</li>
* <li>Select at most the configured USDC-equivalent 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
* <li>Attempt no more than one exact-input Jupiter swap. Normal return is * 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>Immediately before a new purchase, require a fresh canonical
* EVE/USDT price strictly below the corresponding Evelyn status service's
* reference price at the current decision instant. Equality, higher price,
* unavailable reference or unusable price data skips all new purchases
* for this iteration and continues to independent EVE burn processing.
* A price skip does not suspend subsequent iterations. Pending transaction
* reconciliation is independent of this new-purchase condition.</li>
* <li>When the price condition permits, 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 USDC buyback limit never caps * swap, including a price-condition skip, does not suppress this step,
* the amount eligible for burning.</li> * and the input buyback limit never caps
* the amount eligible for burning. Subtract the retained EVE reserve and
* round the positive remainder down to EVE mint precision. If the actual
* rounded amount is zero or below the burn minimum, keep the EVE for a
* later iteration and send no burn notification. Equality qualifies;
* never round upward or consume reserve to reach the minimum.</li>
* <li>Require a positive, sufficiently fresh canonical EVE/USDT price and * <li>Require a positive, sufficiently fresh canonical EVE/USDT price and
* calculate the complete balance's dollar value before submitting a burn. * calculate the actual eligible burn amount's dollar value, excluding
* the retained reserve, before submitting a burn.
* Missing, invalid, or stale price data postpones the burn.</li> * Missing, invalid, or stale price data postpones the burn.</li>
* <li>Submit the complete EVE balance once and retain its signature, * <li>Submit all eligible rounded EVE above reserve once, without a burn
* maximum or fixed batch size, and retain its signature,
* amount, and notification value as pending before waiting. The service * amount, and notification value as pending before waiting. The service
* never automatically retries, rebuilds, re-signs, or resubmits a swap or * never automatically retries, rebuilds, re-signs, or resubmits a swap or
* burn because timeout, interruption, or communication failure can leave * burn because timeout, interruption, or communication failure can leave
@@ -7,13 +7,19 @@ import com.r35157.assetaz.services.ticker.PriceObservation;
import com.r35157.assetaz.services.ticker.TickerService; import com.r35157.assetaz.services.ticker.TickerService;
import com.r35157.assetaz.valuetypes.CurrencyType; import com.r35157.assetaz.valuetypes.CurrencyType;
import com.r35157.cryptowallet.solana.SolanaWallet; import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.evelyn.service.status.EvelynStatusService;
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;
@@ -30,8 +36,10 @@ import java.math.RoundingMode;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -41,7 +49,6 @@ import java.util.concurrent.TimeUnit;
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.EVE_ID; import static com.r35157.assetaz.services.cis.CurrencyTypeIds.EVE_ID;
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.SOLANA_ID; import static com.r35157.assetaz.services.cis.CurrencyTypeIds.SOLANA_ID;
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.USDC_ID;
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.USDT_ID; import static com.r35157.assetaz.services.cis.CurrencyTypeIds.USDT_ID;
import static com.r35157.assetaz.services.cis.ExternalCurrencyReference.SOLANA_MINT_NAMESPACE; import static com.r35157.assetaz.services.cis.ExternalCurrencyReference.SOLANA_MINT_NAMESPACE;
@@ -65,13 +72,45 @@ public final class EvelynIOUBurnerServiceImpl
* metadata. Solana mint addresses and token programs are resolved * metadata. Solana mint addresses and token programs are resolved
* internally and are not constructor configuration.</p> * internally and are not constructor configuration.</p>
* *
* <p>{@code inputCurrencyMaximumSwapAmounts} must contain exactly one
* strictly positive maximum for every accepted input and no other currency.
* Maxima are copied defensively and matched by canonical currency UUID,
* independently of list order or supplied metadata. Each maximum is in
* that input currency's own units, without an input-price conversion or
* 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>
*
* <p>{@code minimumBurnAmount} and {@code eveReserve} must identify EVE by
* its canonical UUID, regardless of supplied name/symbol. The minimum must
* be strictly positive and the reserve non-negative. A new burn uses the
* entire balance above reserve, rounded down to EVE mint precision, only
* when that actual amount is positive and at least the minimum. Equality
* qualifies; fractional remainders stay in the wallet. This policy does
* not suppress independent small buybacks or re-evaluate pending burns.</p>
*
* <p>A fully eligible new buyback additionally requires a fresh canonical
* EVE/USDT price strictly below the corresponding status service's
* reference price at the current decision instant. Equality, unavailable
* or invalid prices skip purchases only, without suspending later
* iterations or suppressing independent EVE burn processing. Pending
* burn priority and read-only swap reconciliation remain unchanged.</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 eligible EVE above reserve is burned
* @param swapService exact-input Jupiter service; normal return already * @param swapService exact-input Jupiter service; normal return already
* means the swap reached {@code CONFIRMED} * means the swap reached {@code CONFIRMED}
* @param solanaBlockChain Solana access used for mint ownership, token * @param solanaBlockChain Solana access used for mint ownership, token
* precision, and burn finalization * precision, and burn finalization
* @param tickerService latest-price source for input/USDC and EVE/USDT * @param tickerService latest-price source for EVE/USDT before new
* buybacks and independently before burning
* @param evelynStatusService corresponding Evelyn instance's reference
* price source for new buyback decisions
* @param currencyIdentityService authoritative resolver for AssetAZ UUIDs * @param currencyIdentityService authoritative resolver for AssetAZ UUIDs
* and Solana mint references * and Solana mint references
* @param notificationService fully bound synchronous destination for the * @param notificationService fully bound synchronous destination for the
@@ -81,34 +120,52 @@ 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 maximumSwapAmountInUSDC strictly positive USDC-equivalent ceiling * @param inputCurrencyMinimumSwapAmounts exactly one strictly positive
* for one buyback; it does not limit burns * 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
* token-denominated buyback maximum per accepted input UUID;
* it does not limit burns
* @param minimumBurnAmount strictly positive EVE-denominated minimum for
* the actual rounded amount of each new burn
* @param eveReserve non-negative EVE-denominated amount retained after a
* successful burn; zero is valid
* @param maximumSlippageBps Jupiter slippage limit in basis points from 0 * @param maximumSlippageBps Jupiter slippage limit in basis points from 0
* through 10000 inclusive * through 10000 inclusive
* @param maximumPriceAge strictly positive maximum observation age in * @param maximumPriceAge strictly positive maximum observation age in
* milliseconds for both input and EVE prices * milliseconds for the EVE/USDT price
* @param burnFinalizationTimeout strictly positive timeout passed unchanged * @param burnFinalizationTimeout strictly positive timeout passed unchanged
* to each {@code FINALIZED} burn await * to each {@code FINALIZED} burn await
* @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 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 scalar 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 minimum/maximum UUIDs do not match inputs,
* a swap bound is non-positive, or a
* minimum exceeds its maximum, or the
* burn minimum/reserve does not identify
* EVE or has an invalid numeric value
*/ */
public EvelynIOUBurnerServiceImpl( public EvelynIOUBurnerServiceImpl(
@NotNull SolanaWallet burnerWallet, @NotNull SolanaWallet burnerWallet,
@NotNull JupiterSwapService swapService, @NotNull JupiterSwapService swapService,
@NotNull SolanaBlockChain solanaBlockChain, @NotNull SolanaBlockChain solanaBlockChain,
@NotNull TickerService tickerService, @NotNull TickerService tickerService,
@NotNull EvelynStatusService evelynStatusService,
@NotNull CurrencyIdentityService currencyIdentityService, @NotNull CurrencyIdentityService currencyIdentityService,
@NotNull BoundNotificationService notificationService, @NotNull BoundNotificationService notificationService,
@NotNull List<MoneyAmount> inputCurrencyMinimumReserves, @NotNull List<MoneyAmount> inputCurrencyMinimumReserves,
@NotNull ΩUSDCAmountΩ maximumSwapAmountInUSDC, @NotNull List<MoneyAmount> inputCurrencyMinimumSwapAmounts,
@NotNull List<MoneyAmount> inputCurrencyMaximumSwapAmounts,
@NotNull MoneyAmount minimumBurnAmount,
@NotNull MoneyAmount eveReserve,
int maximumSlippageBps, int maximumSlippageBps,
ΩmilliSecondsΩ maximumPriceAge, ΩmilliSecondsΩ maximumPriceAge,
@NotNull Duration burnFinalizationTimeout, @NotNull Duration burnFinalizationTimeout,
@@ -130,6 +187,10 @@ public final class EvelynIOUBurnerServiceImpl
tickerService, tickerService,
"tickerService" "tickerService"
); );
this.evelynStatusService = Objects.requireNonNull(
evelynStatusService,
"evelynStatusService"
);
this.currencyIdentityService = Objects.requireNonNull( this.currencyIdentityService = Objects.requireNonNull(
currencyIdentityService, currencyIdentityService,
"currencyIdentityService" "currencyIdentityService"
@@ -139,15 +200,6 @@ public final class EvelynIOUBurnerServiceImpl
"notificationService" "notificationService"
); );
Objects.requireNonNull(
maximumSwapAmountInUSDC,
"maximumSwapAmountInUSDC"
);
if (maximumSwapAmountInUSDC.signum() <= 0) {
throw new IllegalArgumentException(
"maximumSwapAmountInUSDC must be greater than zero"
);
}
if (maximumSlippageBps < 0 if (maximumSlippageBps < 0
|| maximumSlippageBps > MAXIMUM_SLIPPAGE_BPS) { || maximumSlippageBps > MAXIMUM_SLIPPAGE_BPS) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
@@ -179,7 +231,6 @@ public final class EvelynIOUBurnerServiceImpl
); );
} }
this.maximumSwapAmountInUSDC = maximumSwapAmountInUSDC;
this.maximumSlippageBps = maximumSlippageBps; this.maximumSlippageBps = maximumSlippageBps;
this.maximumPriceAge = maximumPriceAge; this.maximumPriceAge = maximumPriceAge;
this.maximumPriceAgeDuration = Duration.ofMillis(maximumPriceAge); this.maximumPriceAgeDuration = Duration.ofMillis(maximumPriceAge);
@@ -188,11 +239,16 @@ public final class EvelynIOUBurnerServiceImpl
EVE_ID, EVE_ID,
"Evelyn IOU" "Evelyn IOU"
); );
this.canonicalUsdc = requireCanonicalCurrency(USDC_ID, "USDC");
CurrencyType canonicalUsdt = requireCanonicalCurrency( CurrencyType canonicalUsdt = requireCanonicalCurrency(
USDT_ID, USDT_ID,
"USDT" "USDT"
); );
this.minimumBurnAmount = validateEveAmount(
minimumBurnAmount, "minimumBurnAmount", canonicalEve, true
);
this.eveReserve = validateEveAmount(
eveReserve, "eveReserve", canonicalEve, false
);
this.eveUsdtTradingPair = new TradingPair( this.eveUsdtTradingPair = new TradingPair(
canonicalEve, canonicalEve,
canonicalUsdt canonicalUsdt
@@ -200,6 +256,19 @@ public final class EvelynIOUBurnerServiceImpl
this.inputCurrencyMinimumReserves = validateAndCopyInputs( this.inputCurrencyMinimumReserves = validateAndCopyInputs(
inputCurrencyMinimumReserves inputCurrencyMinimumReserves
); );
this.inputCurrencyMaximumSwapAmounts = validateAndCopySwapAmounts(
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
@@ -269,7 +338,10 @@ public final class EvelynIOUBurnerServiceImpl
return; return;
} }
if (!swapsSuspended) { if (pendingSwap != null) {
reconcilePendingSwap();
}
if (!swapsSuspended && pendingSwap == null) {
attemptAtMostOneSwap(); attemptAtMostOneSwap();
} }
processCompleteEveBalance(); processCompleteEveBalance();
@@ -353,18 +425,6 @@ public final class EvelynIOUBurnerServiceImpl
continue; continue;
} }
ΩPriceΩ inputPriceInUsdc;
if (USDC_ID.equals(currencyId) || USDT_ID.equals(currencyId)) {
inputPriceInUsdc = BigDecimal.ONE;
} else {
inputPriceInUsdc = findFreshPrice(
new TradingPair(canonicalCurrency, canonicalUsdc)
);
if (inputPriceInUsdc == null) {
continue;
}
}
if (!nativeSolana) { if (!nativeSolana) {
try { try {
decimals = resolveMintDecimals( decimals = resolveMintDecimals(
@@ -381,18 +441,20 @@ public final class EvelynIOUBurnerServiceImpl
} }
} }
ΩAmountΩ maximumInputAmount = maximumSwapAmountInUSDC.divide( ΩAmountΩ maximumInputAmount = inputCurrencyMaximumSwapAmounts
inputPriceInUsdc, .get(currencyId)
decimals, .amount();
RoundingMode.DOWN
);
Ω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;
} }
if (!isBuybackBelowReferencePrice()) {
return;
}
attemptSwap( attemptSwap(
canonicalCurrency, canonicalCurrency,
inputMint, inputMint,
@@ -402,72 +464,169 @@ public final class EvelynIOUBurnerServiceImpl
} }
} }
private boolean isBuybackBelowReferencePrice() {
ΩPriceΩ actualPrice = findFreshPrice(eveUsdtTradingPair);
if (actualPrice == null) {
log.warn(
"Skipping buyback as no fresh valid EVE/USDT price "
+ "is available; nextAction=burn "
+ "evaluation and price recheck next iteration"
);
return false;
}
Instant decisionInstant = Instant.now();
AssetPrice referencePrice;
try {
referencePrice = evelynStatusService.getReferencePrice(
decisionInstant
);
} catch (RuntimeException exception) {
log.warn(
"Skipping buyback as reference price is unavailable "
+ "(market={} USDT/EVE); decisionInstant={}, "
+ "failureType={}; "
+ "nextAction=burn evaluation and price recheck "
+ "next iteration",
actualPrice.stripTrailingZeros().toPlainString(),
decisionInstant,
exception.getClass().getName()
);
return false;
}
if (referencePrice == null
|| !eveUsdtTradingPair.equals(referencePrice.tradingPair())
|| referencePrice.price() == null
|| referencePrice.price().signum() <= 0) {
log.warn(
"Skipping buyback as reference price is missing or "
+ "invalid (market={} USDT/EVE); tradingPair={}, "
+ "decisionInstant={}; nextAction=burn evaluation "
+ "and price recheck next iteration",
actualPrice.stripTrailingZeros().toPlainString(),
eveUsdtTradingPair,
decisionInstant
);
return false;
}
if (actualPrice.compareTo(referencePrice.price()) >= 0) {
log.info(
"Skipping buyback as EVE price is at or above reference "
+ "(market={} >= reference={} USDT/EVE); "
+ "tradingPair={}, "
+ "decisionInstant={}; nextAction=burn evaluation "
+ "and price recheck next iteration",
actualPrice.stripTrailingZeros().toPlainString(),
referencePrice.price().stripTrailingZeros().toPlainString(),
eveUsdtTradingPair,
decisionInstant
);
return false;
}
return true;
}
private void attemptSwap( private void attemptSwap(
CurrencyType canonicalCurrency, CurrencyType canonicalCurrency,
ΩSPLMintAddressΩ inputMint, ΩSPLMintAddressΩ inputMint,
Ω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;
SolanaSPLTokenProgram eveTokenProgram;
try { try {
SolanaSPLTokenProgram eveTokenProgram = eveTokenProgram = resolveSupportedTokenProgram(EVELYN_IOU_MINT);
resolveSupportedTokenProgram(EVELYN_IOU_MINT);
eveBalance = burnerWallet.getSPLTokenBalance( eveBalance = burnerWallet.getSPLTokenBalance(
EVELYN_IOU_MINT, EVELYN_IOU_MINT,
eveTokenProgram eveTokenProgram
@@ -500,17 +659,40 @@ public final class EvelynIOUBurnerServiceImpl
return; return;
} }
ΩAmountΩ availableForBurn = eveBalance.subtract(eveReserve.amount());
if (availableForBurn.signum() <= 0) {
return;
}
ΩAmountΩ amountToBurn;
try {
ΩamountDecimalsΩ decimals = resolveMintDecimals(
EVELYN_IOU_MINT, eveTokenProgram
);
amountToBurn = availableForBurn.setScale(decimals, RoundingMode.DOWN);
} catch (IOException | RuntimeException exception) {
log.warn(
"Could not resolve Evelyn IOU mint precision; burn is "
+ "postponed: failureType={}",
exception.getClass().getName()
);
return;
}
if (amountToBurn.signum() <= 0
|| amountToBurn.compareTo(minimumBurnAmount.amount()) < 0) {
return;
}
ΩPriceΩ evePriceInUsdt = findFreshPrice(eveUsdtTradingPair); ΩPriceΩ evePriceInUsdt = findFreshPrice(eveUsdtTradingPair);
if (evePriceInUsdt == null) { if (evePriceInUsdt == null) {
return; return;
} }
ΩAmountΩ dollarValue = eveBalance.multiply(evePriceInUsdt); ΩAmountΩ dollarValue = amountToBurn.multiply(evePriceInUsdt);
ΩSolanaTransactionSignatureΩ burnSignature; ΩSolanaTransactionSignatureΩ burnSignature;
try { try {
burnSignature = burnerWallet.burnSPLToken( burnSignature = burnerWallet.burnSPLToken(
EVELYN_IOU_MINT, EVELYN_IOU_MINT,
eveBalance amountToBurn
); );
} catch (IOException exception) { } catch (IOException exception) {
burnsSuspended = true; burnsSuspended = true;
@@ -553,7 +735,7 @@ public final class EvelynIOUBurnerServiceImpl
pendingBurn = new PendingBurn( pendingBurn = new PendingBurn(
burnSignature, burnSignature,
eveBalance, amountToBurn,
dollarValue dollarValue
); );
awaitPendingBurn(); awaitPendingBurn();
@@ -772,6 +954,31 @@ public final class EvelynIOUBurnerServiceImpl
return supply.decimals(); return supply.decimals();
} }
private static MoneyAmount validateEveAmount(
MoneyAmount configuredAmount,
String configurationName,
CurrencyType canonicalEve,
boolean strictlyPositive
) {
Objects.requireNonNull(configuredAmount, configurationName);
Objects.requireNonNull(configuredAmount.amount(), configurationName + ".amount");
CurrencyType currency = Objects.requireNonNull(
configuredAmount.currencyType(), configurationName + ".currencyType"
);
UUID currencyId = Objects.requireNonNull(
currency.id(), configurationName + ".currencyType.id"
);
if (!EVE_ID.equals(currencyId)) {
throw new IllegalArgumentException(configurationName + " must identify EVE by UUID");
}
if (configuredAmount.amount().signum() < 0
|| (strictlyPositive && configuredAmount.amount().signum() == 0)) {
throw new IllegalArgumentException(configurationName
+ (strictlyPositive ? " must be greater than zero" : " must be zero or greater"));
}
return new MoneyAmount(configuredAmount.amount(), canonicalEve);
}
private List<MoneyAmount> validateAndCopyInputs( private List<MoneyAmount> validateAndCopyInputs(
List<MoneyAmount> configuredInputs List<MoneyAmount> configuredInputs
) { ) {
@@ -830,6 +1037,47 @@ public final class EvelynIOUBurnerServiceImpl
return List.copyOf(validatedInputs); return List.copyOf(validatedInputs);
} }
private Map<UUID, MoneyAmount> validateAndCopySwapAmounts(
List<MoneyAmount> configuredAmounts,
String configurationName
) {
Objects.requireNonNull(configuredAmounts, configurationName);
Set<UUID> acceptedCurrencyIds = new HashSet<>();
for (MoneyAmount minimumReserve : inputCurrencyMinimumReserves) {
acceptedCurrencyIds.add(minimumReserve.currencyType().id());
}
Map<UUID, MoneyAmount> validatedAmounts = new HashMap<>();
for (int index = 0; index < configuredAmounts.size(); index++) {
String entryName = configurationName + "[" + index + "]";
MoneyAmount bound = Objects.requireNonNull(configuredAmounts.get(index), entryName);
Objects.requireNonNull(bound.amount(), entryName + ".amount");
if (bound.amount().signum() <= 0) {
throw new IllegalArgumentException(entryName + " amount must be greater than zero");
}
CurrencyType configuredCurrency = Objects.requireNonNull(
bound.currencyType(), entryName + ".currencyType");
UUID currencyId = Objects.requireNonNull(configuredCurrency.id(), entryName + ".currencyType.id");
CurrencyType canonicalCurrency = requireCanonicalCurrency(currencyId, configurationName);
if (!acceptedCurrencyIds.contains(currencyId)) {
throw new IllegalArgumentException(configurationName
+ " configured for unaccepted input currency UUID: " + currencyId);
}
if (validatedAmounts.putIfAbsent(currencyId,
new MoneyAmount(bound.amount(), canonicalCurrency)) != null) {
throw new IllegalArgumentException("Duplicate " + configurationName
+ " currency UUID: " + currencyId);
}
}
for (UUID currencyId : acceptedCurrencyIds) {
if (!validatedAmounts.containsKey(currencyId)) {
throw new IllegalArgumentException("Missing " + configurationName
+ " for input currency UUID: " + currencyId);
}
}
return Map.copyOf(validatedAmounts);
}
private CurrencyType requireCanonicalCurrency( private CurrencyType requireCanonicalCurrency(
UUID currencyId, UUID currencyId,
String purpose String purpose
@@ -936,6 +1184,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;
@@ -958,19 +1207,31 @@ public final class EvelynIOUBurnerServiceImpl
private final JupiterSwapService swapService; private final JupiterSwapService swapService;
private final SolanaBlockChain solanaBlockChain; private final SolanaBlockChain solanaBlockChain;
private final TickerService tickerService; private final TickerService tickerService;
private final EvelynStatusService evelynStatusService;
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 ΩUSDCAmountΩ maximumSwapAmountInUSDC; private final Map<UUID, MoneyAmount> inputCurrencyMinimumSwapAmounts;
private final Map<UUID, MoneyAmount> inputCurrencyMaximumSwapAmounts;
private final MoneyAmount minimumBurnAmount;
private final MoneyAmount eveReserve;
private final int maximumSlippageBps; private final int maximumSlippageBps;
private final ΩmilliSecondsΩ maximumPriceAge; private final ΩmilliSecondsΩ maximumPriceAge;
private final Duration maximumPriceAgeDuration; private final Duration maximumPriceAgeDuration;
private final Duration burnFinalizationTimeout; private final Duration burnFinalizationTimeout;
private final long iterationIntervalNanos; private final long iterationIntervalNanos;
private final CurrencyType canonicalUsdc;
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;
}
@@ -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,
@@ -303,9 +303,18 @@ public final class NenjimHubImpl implements NenjimHub {
} }
private NenjimProcess constructEvelynIOUBurnerProdProcess() { private NenjimProcess constructEvelynIOUBurnerProdProcess() {
List<MoneyAmount> burnTokenCandidates = List.of(new MoneyAmount(new ΩAmountΩ("0.1"), cis.resolve(CurrencyTypeIds.USDT_ID))); List<MoneyAmount> inputCurrencyMinimumReserves = List.of(
new MoneyAmount(new ΩAmountΩ("0.1"), cis.resolve(CurrencyTypeIds.USDT_ID)),
ΩUSDCAmountΩ maximumSwapAmountPerIterationInUSD = new ΩUSDCAmountΩ("0.25"); 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(
new MoneyAmount(new ΩAmountΩ("0.25"), cis.resolve(CurrencyTypeIds.USDT_ID)),
new MoneyAmount(new ΩAmountΩ("0.2"), cis.resolve(CurrencyTypeIds.SYRUPUSDC_ID))
);
EvelynIOUBurnerServiceImpl process = new EvelynIOUBurnerServiceImpl( EvelynIOUBurnerServiceImpl process = new EvelynIOUBurnerServiceImpl(
solanaWalletEvelynBurnerProd, solanaWalletEvelynBurnerProd,
@@ -315,10 +324,17 @@ public final class NenjimHubImpl implements NenjimHub {
tickerService, tickerService,
EVELYN_IOU_BURNER_PROD_ID, EVELYN_IOU_BURNER_PROD_ID,
TICKER_SERVICE_ID), TICKER_SERVICE_ID),
requireProcessDependency(
evelynStatusServiceProd,
EVELYN_IOU_BURNER_PROD_ID,
EVELYN_STATUS_PROD_ID),
cis, cis,
discordAssetAZTokenNotificationService, discordAssetAZTokenNotificationService,
burnTokenCandidates, inputCurrencyMinimumReserves,
maximumSwapAmountPerIterationInUSD, inputCurrencyMinimumSwapAmounts,
inputCurrencyMaximumSwapAmounts,
new MoneyAmount(new ΩAmountΩ("0.1"), cis.resolve(CurrencyTypeIds.EVE_ID)),
new MoneyAmount(new ΩAmountΩ("0.01"), cis.resolve(CurrencyTypeIds.EVE_ID)),
200, 200,
2*1000*60L, 2*1000*60L,
Duration.ofSeconds(10), Duration.ofSeconds(10),