diff --git a/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/.openspec.yaml b/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/.openspec.yaml new file mode 100644 index 0000000..b6b2d1f --- /dev/null +++ b/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-13 diff --git a/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/design.md b/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/design.md new file mode 100644 index 0000000..a3387ad --- /dev/null +++ b/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/design.md @@ -0,0 +1,83 @@ +## Context + +See `proposal.md` for motivation. The lower-level services already separate wallet state and signing, Jupiter's single managed execution plus `CONFIRMED` wait, generic Solana `FINALIZED` awaiting, price observations, AssetAZ identity translation, and synchronous bound notification delivery. The new component must orchestrate those boundaries without widening their APIs, leaking Solana details into Evelyn configuration, or mistaking timeout and communication failure for permission to repeat a transaction. + +## Goals / Non-Goals + +**Goals:** + +- Keep the public API minimal while documenting the complete externally significant iteration and safety contract. +- Make stable AssetAZ UUIDs and an ordered `List` the only input-selection model, with CIS and Solana resolving technical representations internally. +- Encode swap and burn uncertainty as conservative process-local lifecycle state so a periodic worker cannot duplicate an operation automatically. +- Keep each iteration sequential, permit existing EVE to be burned independently of swap success, and bind notification data to the exact submitted burn. + +**Non-Goals:** + +- Persisting reconciliation state or providing operator recovery tooling after restart. +- Adding configuration objects, parsers, extra constructors, Nenjim autorun/composition wiring, or wallet/webhook secrets. +- Introducing burner-specific input types, a reusable generic burner, new ValueTags, dependencies, tests, or production test hooks. +- Retrying transactions, transaction awaits, or notifications, closing token accounts, or estimating native SOL fees. + +## Decisions + +### Keep input configuration unchanged and canonicalize on use + +The implementation stores `List.copyOf(inputCurrencyMinimumReserves)` after validating every entry. It does not replace `MoneyAmount`, attach mint/program fields, or create an input-specific interface, record, or hierarchy. Construction resolves every configured UUID to prove it is known, detects duplicates and EVE, and verifies exactly one non-blank `solana-mint` reference for every non-SOL input. Each iteration resolves the UUID again and uses the returned canonical `CurrencyType`, deliberately ignoring configured display metadata. + +Caching pre-resolved integration DTOs was rejected because it would create the prohibited parallel burner input model and weaken the visible rule that UUID is authoritative. Extending `MoneyAmount` was rejected because reserve validity and accepted-input policy belong only to this service. + +### Resolve representation and precision at the point of use + +The input loop handles candidates inline in priority order rather than materializing burner-specific candidate objects. SOL is recognized by `CurrencyTypeIds.SOLANA_ID`, read through `getSolanaBalance()`, assigned its protocol-defined nine decimals, and translated to the unique canonical CIS SOL/WSOL mint only when calling Jupiter. Other currencies resolve their unique CIS mint, inspect the mint-account owner against the two supported `SolanaSPLTokenProgram` values, read balance with that program, and obtain decimals from current mint supply metadata. + +A constructor-supplied program or mint was rejected because those are mutable integration facts rather than Evelyn domain configuration. Treating native SOL as a wallet SPL holding was rejected because it would observe the wrong balance. A general CIS preferred-mint redesign was rejected because the current burner contract deliberately fails ambiguous non-SOL configuration. + +### Round the exact-input limit downward at mint precision + +For a candidate with positive spendable balance and usable price, the implementation divides the positive USDC cap by price using the input precision and `RoundingMode.DOWN`, takes the lesser of that cap and `balance - reserve`, then applies the same downward scale once more. USDC bypasses ticker lookup with an exact price of one. This produces an exactly representable Jupiter amount that cannot exceed the USDC cap through rounding. + +Using arbitrary high precision followed by Jupiter rejection was rejected because supported precision is already available. Half-up rounding was rejected for transaction amounts because it can cross either the configured cap or reserve boundary. SOL fees remain intentionally outside the calculation, so execution may consume part of the reserve or fail when no fee balance remains. + +### Use one daemon scheduled worker and fixed delay + +Each start creates one single-thread scheduled executor, resets process-local state, and schedules with zero initial delay plus the validated interval as fixed delay. The task boundary catches ordinary failures so one bad iteration does not silently cancel future scheduling. Interruption is restored and terminates current processing. Stop prevents scheduling, interrupts the executor, waits for bounded clean termination, and clears state only after the worker has ended; a still-active or stopping instance cannot start another worker. + +A timer that dispatches concurrent jobs was rejected because overlap would violate transaction ordering. A global worker was rejected because state and lifecycle belong to the service instance. Fixed rate was rejected because a slow blockchain wait could cause catch-up behavior instead of delaying from completion. + +### Model transaction uncertainty as two suspensions plus one pending burn + +The worker owns separate boolean flags for swap and burn suspension and one private pending-burn value containing signature, exact submitted EVE amount, and calculated USD value. A pending signature always short-circuits the next iteration before input inspection. Timeout and await I/O retain it; definitive failure clears it without same-iteration work; success proceeds to the associated notification only. + +For swaps, `JupiterTransactionOutcomeException` exposes the needed distinction: `FAILED` is definitive and allows later-iteration swaps, while `TIMED_OUT` suspends them. Any swap `IOException` is treated as potentially post-submission because the public contract permits that state. Interruption is also conservative because it can occur during execution or confirmation. In every case the input loop returns after its first Jupiter invocation. + +For burns, a returned signature is stored before awaiting. An `IOException`, interruption, blank signature, or other submission-contract failure that can have occurred after send suspends future burn submissions when no usable signature is available. Local validation failures that are known to precede submission may be reconsidered after a later state reload. Swap and burn suspension remain separate: one unknown operation does not authorize repetition and does not erase the other safe parts of an iteration. + +Persisting flags/signatures was rejected by issue scope. Consequently, an explicit stop/start or process restart intentionally forgets unknown state. Wallet reloading reduces stale-state risk but cannot prove an unknown transaction is dead; operators should reconcile known chain activity before deliberately resetting a suspended or pending lifecycle. + +### Bind price and notification data before the burn + +The complete EVE wallet balance is always reloaded after the optional swap attempt rather than trusting `JupiterSwapResult.receivedOutputTokenAmount()`. Before submission, the implementation validates the latest canonical EVE/USDT observation against the current time and stores the resulting unrounded USD value together with the returned burn signature. This postpones every burn that could not be truthfully announced and ensures a later price movement cannot change the value associated with a pending burn. + +On successful `FINALIZED`, the pending state is made ineligible for another delivery before calling the synchronous notification service. Amount formatting uses `stripTrailingZeros().toPlainString()`; USD formatting uses scale two and `HALF_UP`. Delivery exceptions are logged by type and transaction identity without including destination details, and pending state is never restored. + +Deriving the burn from Jupiter output was rejected because the wallet can already contain EVE. Pricing after finalization was rejected because a future missing price would leave a completed burn without its required announcement. Keeping pending state until notification returns was rejected because an unchecked delivery failure could otherwise cause a second attempt. + +### Clarify Jupiter native SOL support without widening its API + +The existing Jupiter reference implementation already treats its `inputTokenMint` as a mint used for precision validation and passes it unchanged to Swap V2. The canonical SOL/WSOL mint is a valid Solana mint, and Jupiter interprets it for native SOL input from the taker. Therefore only the public JavaDoc and `jupiter-swap-service` contract need clarification; no separate method or implementation branch is required, and legacy SPL plus Token-2022 behavior remains unchanged. + +## Risks / Trade-offs + +- [A process or explicit lifecycle reset forgets a pending signature or suspension] → Document the safety limitation prominently, reload balances on every later decision, and require deliberate external reconciliation before an operator resets unknown state. +- [A wallet or network exception may have occurred before submission but cannot prove that] → Prefer conservative lifecycle suspension over a possible duplicate buyback or burn. +- [A stale mint reference was valid at construction but CIS changes dynamically] → Resolve canonical identity and external references again during processing and skip unusable representations safely. +- [A mint balance or supply changes between reads and submission] → Submit only exactly representable observed amounts once and let wallet/Solana validation reject races without automatic retry. +- [Notification succeeds remotely but the response is lost] → Count the single local invocation as the only attempt and never retry it, because notification delivery is not transaction completion. +- [Stop cannot promptly interrupt a misbehaving dependency] → Use interruption plus bounded termination and refuse a new worker while termination remains unconfirmed. + +## Migration Plan + +1. Add the Evelyn burner API and reference implementation without composing or starting it in production. +2. Clarify the existing Jupiter JavaDoc while preserving its public method signature and reference implementation. +3. Compile and assemble production sources, perform strict OpenSpec validation, and use structural/manual review only; add no automated tests. +4. Leave this change active for review. Rollback removes the new service and documentation delta; there is no persisted state, configuration, or data migration. diff --git a/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/proposal.md b/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/proposal.md new file mode 100644 index 0000000..7816c77 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/proposal.md @@ -0,0 +1,30 @@ +## Why + +The available wallet, Jupiter, Solana transaction-awaiting, ticker, currency-identity, and notification services now make an automated Evelyn IOU buyback-and-burn flow possible, but no Evelyn-owned orchestration currently combines them with the transaction-safety rules needed to avoid duplicate swaps or burns. Issue #72 adds that orchestration while keeping stable AssetAZ UUIDs—not Solana integration details—as its configuration boundary. + +## What Changes + +- Add an Evelyn-specific continuously running `EvelynIOUBurnerService` API and reference implementation with immediate, non-overlapping, fixed-delay iterations and explicit start/stop lifecycle behavior. +- Configure prioritized accepted inputs and their minimum reserves as an ordered defensive copy of `List`, using each entry's AssetAZ UUID as authoritative and canonicalizing metadata through `CurrencyIdentityService`. +- Resolve native SOL and unique non-SOL `solana-mint` representations internally through CIS, detect supported SPL token programs and token precision through Solana, and keep mints/programs out of Evelyn's constructor configuration. +- Limit each iteration to at most one exact-input Jupiter attempt and to the configured USDC-equivalent ceiling, while preserving each currency's reserve and rounding downward to its supported precision. +- Reload and burn the complete visible EVE balance only with a fresh canonical EVE/USDT price, retain pending burn signatures until a definitive `FINALIZED` result, and make exactly one notification attempt after successful finalization. +- Suspend later swaps or burns for the current lifecycle when their submission outcome may be unknown; never automatically retry, rebuild, re-sign, or resubmit a transaction. Reset process-local pending and suspension state only on an explicit stop/start cycle. +- Clarify the existing Jupiter swap contract so the canonical SOL/WSOL mint may represent native SOL input without changing the existing `swap(...)` signature or SPL/Token-2022 behavior. +- Keep configuration parsing, Nenjim autorun wiring, persistence, generic burner abstractions, tests, and test hooks out of scope. + +## Capabilities + +### New Capabilities + +- `evelyn-iou-burner-service`: Defines the Evelyn-specific configuration, identity resolution, buyback limit and priority rules, sequential lifecycle, complete-balance burn, pending/unknown transaction safety, finalization, and one-attempt notification behavior. + +### Modified Capabilities + +- `jupiter-swap-service`: Explicitly supports native SOL input when represented by the canonical CIS-resolved SOL/WSOL mint while retaining the existing exact-input API and behavior for supported SPL token programs. + +## Impact + +- Adds public API source under `com.fanitas.evelyn.service.burner` and its reference implementation under `com.fanitas.evelyn.service.burner.impl.ref`. +- Uses existing `SolanaWallet`, `JupiterSwapService`, `SolanaBlockChain`, `TickerService`, `CurrencyIdentityService`, `BoundNotificationService`, `MoneyAmount`, and ValueTags without adding dependencies or changing DeTag configuration. +- Updates `JupiterSwapService` JavaDoc and the active OpenSpec delta only; no composition-root, configuration-file, persistence, generated-source, or automated-test changes are included. diff --git a/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/specs/evelyn-iou-burner-service/spec.md b/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/specs/evelyn-iou-burner-service/spec.md new file mode 100644 index 0000000..b66b5a5 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/specs/evelyn-iou-burner-service/spec.md @@ -0,0 +1,227 @@ +## 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. + +## ADDED Requirements + +### Requirement: Evelyn-specific public service boundary +The system SHALL expose `EvelynIOUBurnerService` under `com.fanitas.evelyn.service.burner` with only `start()` and `stop()` operations, and SHALL keep its reference implementation under `com.fanitas.evelyn.service.burner.impl.ref`. 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. + +#### Scenario: Downstream lifecycle consumer +- **WHEN** a downstream component depends on the Evelyn burner API +- **THEN** it can start and stop the service without depending on reference-implementation types or a generic burner model + +#### Scenario: Input model remains domain-level +- **WHEN** callers configure accepted buyback currencies +- **THEN** they use existing `MoneyAmount` values rather than burner-specific Solana, SPL-token, or input-asset types + +### Requirement: Ordered AssetAZ input configuration +The authoritative constructor SHALL accept a non-null ordered `List` 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. + +#### 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 prices +- **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 + +### 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. + +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, UUIDs are distinct and known, 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, 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 +- **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 + +### 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. + +#### 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: Immediate sequential fixed-delay lifecycle +Each service instance SHALL own at most one dedicated background worker. A successful `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. `stop()` SHALL prevent new iterations, interrupt and stop the worker cleanly, and preserve interruption if the stopping thread is interrupted. A repeated start while active SHALL NOT create another worker. + +Pending burn information and swap/burn suspension flags SHALL be process-local only. A completed explicit stop followed by start SHALL clear those states. The public JavaDoc SHALL disclose that this reset, or a process restart, loses reconciliation state and can weaken duplicate-prevention until unknown transactions have been reconciled externally. + +#### Scenario: First and later iterations +- **WHEN** the service starts 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** `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 service with pending or suspended process-local state is cleanly stopped and then explicitly started +- **THEN** the new lifecycle begins without that state and reloads wallet and chain state normally + +### Requirement: Pending burn is handled before all new work +At the start of an iteration, if a prior burn signature is pending, the service SHALL only await that exact signature at `FINALIZED`. It SHALL submit no swap or burn in that iteration. An unknown timeout or I/O outcome SHALL retain the pending state and end the iteration; definitive success SHALL make the one associated notification attempt and end the iteration; and definitive on-chain failure SHALL log the slot and complete failure details, clear pending state, send no notification, and defer any new wallet decision to a later iteration. + +#### Scenario: Pending result remains unknown +- **WHEN** awaiting the retained signature times out or fails through I/O +- **THEN** the service retains that signature and performs no swap, burn, or notification in the iteration + +#### Scenario: Pending burn succeeds +- **WHEN** the retained signature reaches `FINALIZED` with a successful outcome +- **THEN** the service performs its sole notification attempt and does no other transaction work in that iteration + +#### Scenario: Pending burn fails definitively +- **WHEN** the retained signature reaches `FINALIZED` with an on-chain failure +- **THEN** the service logs its slot and complete failure details, clears it without notification, and waits until a later iteration before evaluating another burn + +### 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, 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. + +#### 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 + +### Requirement: Fresh canonical input prices +For each non-USDC candidate, the service SHALL request the canonical `/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 +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-lifecycle swaps. A timed-out structured outcome SHALL suspend all later swaps in that lifecycle. 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. + +#### 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 and all later swaps remain suspended until explicit stop/start + +#### 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: 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. + +#### 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 for burning + +#### 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 + +### 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. + +#### Scenario: Fresh EVE price exists +- **WHEN** a complete positive EVE balance 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 + +#### 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 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. + +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 lifecycle. It MAY continue ordinary iteration work that does not submit another burn. Explicit stop/start SHALL reset that suspension. + +#### Scenario: Burn returns a signature +- **WHEN** the wallet submits the complete observed EVE balance 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 explicit stop/start + +### 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. + +#### Scenario: Burn finalizes successfully +- **WHEN** the submitted signature reaches `FINALIZED` without an on-chain error +- **THEN** the service may perform the notification attempt associated with that exact burn + +#### Scenario: Burn fails on-chain +- **WHEN** the signature reaches `FINALIZED` with an on-chain error +- **THEN** the service logs its slot and complete error, clears pending state, and never announces that burn + +#### Scenario: Finalized result remains unknown +- **WHEN** finalization times out or communication fails +- **THEN** the service retains the exact signature and later awaits it again without submitting another burn + +### Requirement: Exactly one post-finalization notification attempt +Only after a burn reaches successful `FINALIZED` status, the service SHALL invoke the bound notification service exactly once with this structure, using the burn signature rather than any swap signature: + +```text +🔥🔥🔥 We have bought back and burned EVE (Evelyn IOU tokens, value $) to reduce the circulating supply! 🔥🔥🔥 + +Proof: +https://solscan.io/tx/ +``` + +The EVE amount SHALL use plain decimal notation without unnecessary trailing zeroes or scientific notation. The USD value SHALL have exactly two decimal places using half-up monetary rounding. The blank line before `Proof:` SHALL be preserved. Before invoking delivery, the service SHALL make the completed pending burn ineligible for another attempt. Delivery failure SHALL be logged without notification secrets and SHALL NOT retry notification or repeat the burn. + +#### Scenario: Successful finalized burn is announced +- **WHEN** 10 EVE with a calculated value of 150.225 USDT reaches successful `FINALIZED` status under a burn signature +- **THEN** one message reports `10 EVE`, `$150.23`, preserves the blank line, and links to that burn signature + +#### Scenario: Notification delivery fails +- **WHEN** the sole notification invocation throws +- **THEN** the service logs a non-secret failure, clears completed state, and never retries either notification or burn diff --git a/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/specs/jupiter-swap-service/spec.md b/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/specs/jupiter-swap-service/spec.md new file mode 100644 index 0000000..2cf7f47 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/specs/jupiter-swap-service/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: Public exact-input swap contract +The service SHALL accept a positive human-readable exact input amount, a maximum slippage in basis points, a supported output SPL-token mint, and a distinct input representation that is either a supported SPL-token mint or native SOL expressed as the canonical SOL/WSOL mint `So11111111111111111111111111111111111111112`. It SHALL pass that canonical native-SOL representation unchanged to Jupiter as `inputMint`, preserve the existing `swap(...)` signature, and return the confirmed Solana transaction signature together with the actual human-readable input amount spent and output amount received. Callers that select native SOL SHALL remain responsible for identifying the AssetAZ SOL UUID, resolving the canonical mint through CIS, reading native wallet balance, and applying any reserve policy; the swap service SHALL NOT introduce a separate native-SOL operation. + +#### Scenario: Successful exact-input swap +- **WHEN** a caller requests a valid exact-input swap from a supported legacy SPL or Token-2022 mint that Jupiter executes successfully +- **THEN** the result contains the confirmed signature and the actual spent and received amounts converted with their respective mint decimal precision + +#### Scenario: Successful native SOL input swap +- **WHEN** a caller requests a valid exact-input swap using `So11111111111111111111111111111111111111112` as input and Jupiter executes it from the taker's native SOL balance +- **THEN** Jupiter receives that same canonical input mint and the service returns the confirmed signature and actual human-readable spent and received amounts through the existing result contract + +#### Scenario: Existing token-program behavior is preserved +- **WHEN** a caller uses a supported legacy SPL-token or Token-2022 input +- **THEN** mint validation, decimal conversion, signing, single managed execution, and independent `CONFIRMED` handling remain unchanged + +#### Scenario: Invalid caller input +- **WHEN** either mint representation is blank, both mints are equal, the amount is null or non-positive, or the maximum slippage is outside 0 through 10000 basis points +- **THEN** the service rejects the request before requesting a Jupiter order diff --git a/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/tasks.md b/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/tasks.md new file mode 100644 index 0000000..9a6cb08 --- /dev/null +++ b/openspec/changes/archive/2026-08-13-72-add-evelyn-iou-buyback-and-burn-service/tasks.md @@ -0,0 +1,32 @@ +## 1. Public contracts + +- [x] 1.1 Add the `EvelynIOUBurnerService` API with complete JavaDoc for ordered `List` configuration, iteration order, transaction uncertainty, finalization, notification, lifecycle, and process-local reset risks. +- [x] 1.2 Update `JupiterSwapService` JavaDoc to support canonical SOL/WSOL native input through the unchanged exact-input method while preserving legacy SPL and Token-2022 behavior. + +## 2. Construction and identity boundaries + +- [x] 2.1 Add the reference implementation's authoritative constructor with dependency, numeric, duration, reserve, duplicate, known-UUID, and EVE-input validation plus an order-preserving defensive list copy. +- [x] 2.2 Resolve canonical fixed currencies and validate exactly one non-blank CIS `solana-mint` representation for each configured non-SOL UUID without adding burner-specific input types or changing `MoneyAmount`/DeTag configuration. + +## 3. Sequential lifecycle and iteration control + +- [x] 3.1 Implement one immediate fixed-delay daemon worker, non-overlapping start/stop semantics, interruption preservation, bounded clean termination, and explicit stop/start reset of process-local pending and suspension state. +- [x] 3.2 Implement the safe iteration boundary and pending-burn-first short circuit so unresolved signatures are awaited before and instead of any new swap or burn work. + +## 4. Prioritized buyback path + +- [x] 4.1 Inspect configured UUIDs in order, canonicalize through CIS, distinguish native SOL from mint-backed tokens, detect supported token programs and precision internally, enforce reserves, and skip unusable candidates without blocking later inputs or EVE processing. +- [x] 4.2 Resolve fresh canonical input/USDC prices (with exact-one USDC), calculate a downward-rounded exact amount within the reserve and USDC cap, and attempt at most one Jupiter swap per iteration. +- [x] 4.3 Classify definitive versus unknown Jupiter outcomes, never add a second confirmation wait or transaction attempt, and suspend later-lifecycle swaps conservatively for timeout, applicable I/O, or interruption while retaining the burn path. + +## 5. Burn finalization and notification + +- [x] 5.1 Reload the complete EVE balance with internally detected token program, require a fresh canonical EVE/USDT price, calculate its bound USD value, submit one complete-balance burn, and retain pending data before awaiting. +- [x] 5.2 Handle `FINALIZED` success, definitive failure, timeout, I/O, and interruption without resubmission; suspend unknown signature-less burn submission outcomes for the lifecycle. +- [x] 5.3 Format the fixed proof message exactly and make at most one non-secret-logging notification attempt for each successfully finalized burn, clearing completed pending state before delivery. + +## 6. Verification + +- [x] 6.1 Review production JavaDoc, lifecycle/state transitions, ValueTag imports, changed-file scope, and absence of parser, autorun wiring, persistence, tests, test hooks, generated-source edits, or burner-specific input types. +- [x] 6.2 Run production compilation and assembly successfully without creating or modifying automated tests. +- [x] 6.3 Run strict OpenSpec validation and safe narrowly scoped structural/manual verification while leaving the change active and unsynchronized. diff --git a/openspec/specs/evelyn-iou-burner-service/spec.md b/openspec/specs/evelyn-iou-burner-service/spec.md new file mode 100644 index 0000000..bbbf9e6 --- /dev/null +++ b/openspec/specs/evelyn-iou-burner-service/spec.md @@ -0,0 +1,229 @@ +# evelyn-iou-burner-service Specification + +## 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. + +## Requirements + +### Requirement: Evelyn-specific public service boundary +The system SHALL expose `EvelynIOUBurnerService` under `com.fanitas.evelyn.service.burner` with only `start()` and `stop()` operations, and SHALL keep its reference implementation under `com.fanitas.evelyn.service.burner.impl.ref`. 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. + +#### Scenario: Downstream lifecycle consumer +- **WHEN** a downstream component depends on the Evelyn burner API +- **THEN** it can start and stop the service without depending on reference-implementation types or a generic burner model + +#### Scenario: Input model remains domain-level +- **WHEN** callers configure accepted buyback currencies +- **THEN** they use existing `MoneyAmount` values rather than burner-specific Solana, SPL-token, or input-asset types + +### Requirement: Ordered AssetAZ input configuration +The authoritative constructor SHALL accept a non-null ordered `List` 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. + +#### 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 prices +- **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 + +### 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. + +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, UUIDs are distinct and known, 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, 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 +- **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 + +### 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. + +#### 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: Immediate sequential fixed-delay lifecycle +Each service instance SHALL own at most one dedicated background worker. A successful `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. `stop()` SHALL prevent new iterations, interrupt and stop the worker cleanly, and preserve interruption if the stopping thread is interrupted. A repeated start while active SHALL NOT create another worker. + +Pending burn information and swap/burn suspension flags SHALL be process-local only. A completed explicit stop followed by start SHALL clear those states. The public JavaDoc SHALL disclose that this reset, or a process restart, loses reconciliation state and can weaken duplicate-prevention until unknown transactions have been reconciled externally. + +#### Scenario: First and later iterations +- **WHEN** the service starts 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** `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 service with pending or suspended process-local state is cleanly stopped and then explicitly started +- **THEN** the new lifecycle begins without that state and reloads wallet and chain state normally + +### Requirement: Pending burn is handled before all new work +At the start of an iteration, if a prior burn signature is pending, the service SHALL only await that exact signature at `FINALIZED`. It SHALL submit no swap or burn in that iteration. An unknown timeout or I/O outcome SHALL retain the pending state and end the iteration; definitive success SHALL make the one associated notification attempt and end the iteration; and definitive on-chain failure SHALL log the slot and complete failure details, clear pending state, send no notification, and defer any new wallet decision to a later iteration. + +#### Scenario: Pending result remains unknown +- **WHEN** awaiting the retained signature times out or fails through I/O +- **THEN** the service retains that signature and performs no swap, burn, or notification in the iteration + +#### Scenario: Pending burn succeeds +- **WHEN** the retained signature reaches `FINALIZED` with a successful outcome +- **THEN** the service performs its sole notification attempt and does no other transaction work in that iteration + +#### Scenario: Pending burn fails definitively +- **WHEN** the retained signature reaches `FINALIZED` with an on-chain failure +- **THEN** the service logs its slot and complete failure details, clears it without notification, and waits until a later iteration before evaluating another burn + +### 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, 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. + +#### 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 + +### Requirement: Fresh canonical input prices +For each non-USDC candidate, the service SHALL request the canonical `/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 +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-lifecycle swaps. A timed-out structured outcome SHALL suspend all later swaps in that lifecycle. 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. + +#### 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 and all later swaps remain suspended until explicit stop/start + +#### 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: 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. + +#### 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 for burning + +#### 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 + +### 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. + +#### Scenario: Fresh EVE price exists +- **WHEN** a complete positive EVE balance 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 + +#### 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 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. + +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 lifecycle. It MAY continue ordinary iteration work that does not submit another burn. Explicit stop/start SHALL reset that suspension. + +#### Scenario: Burn returns a signature +- **WHEN** the wallet submits the complete observed EVE balance 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 explicit stop/start + +### 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. + +#### Scenario: Burn finalizes successfully +- **WHEN** the submitted signature reaches `FINALIZED` without an on-chain error +- **THEN** the service may perform the notification attempt associated with that exact burn + +#### Scenario: Burn fails on-chain +- **WHEN** the signature reaches `FINALIZED` with an on-chain error +- **THEN** the service logs its slot and complete error, clears pending state, and never announces that burn + +#### Scenario: Finalized result remains unknown +- **WHEN** finalization times out or communication fails +- **THEN** the service retains the exact signature and later awaits it again without submitting another burn + +### Requirement: Exactly one post-finalization notification attempt +Only after a burn reaches successful `FINALIZED` status, the service SHALL invoke the bound notification service exactly once with this structure, using the burn signature rather than any swap signature: + +```text +🔥🔥🔥 We have bought back and burned EVE (Evelyn IOU tokens, value $) to reduce the circulating supply! 🔥🔥🔥 + +Proof: +https://solscan.io/tx/ +``` + +The EVE amount SHALL use plain decimal notation without unnecessary trailing zeroes or scientific notation. The USD value SHALL have exactly two decimal places using half-up monetary rounding. The blank line before `Proof:` SHALL be preserved. Before invoking delivery, the service SHALL make the completed pending burn ineligible for another attempt. Delivery failure SHALL be logged without notification secrets and SHALL NOT retry notification or repeat the burn. + +#### Scenario: Successful finalized burn is announced +- **WHEN** 10 EVE with a calculated value of 150.225 USDT reaches successful `FINALIZED` status under a burn signature +- **THEN** one message reports `10 EVE`, `$150.23`, preserves the blank line, and links to that burn signature + +#### Scenario: Notification delivery fails +- **WHEN** the sole notification invocation throws +- **THEN** the service logs a non-secret failure, clears completed state, and never retries either notification or burn diff --git a/openspec/specs/jupiter-swap-service/spec.md b/openspec/specs/jupiter-swap-service/spec.md index a0aa6db..d594bf3 100644 --- a/openspec/specs/jupiter-swap-service/spec.md +++ b/openspec/specs/jupiter-swap-service/spec.md @@ -7,14 +7,22 @@ Defines safe exact-input SPL-token swaps through Jupiter Swap V2 using human-rea ## Requirements ### Requirement: Public exact-input swap contract -The service SHALL accept distinct input and output SPL mint addresses, a positive human-readable input amount, and a maximum slippage in basis points. It SHALL return the confirmed Solana transaction signature together with the actual human-readable input amount spent and output amount received. +The service SHALL accept a positive human-readable exact input amount, a maximum slippage in basis points, a supported output SPL-token mint, and a distinct input representation that is either a supported SPL-token mint or native SOL expressed as the canonical SOL/WSOL mint `So11111111111111111111111111111111111111112`. It SHALL pass that canonical native-SOL representation unchanged to Jupiter as `inputMint`, preserve the existing `swap(...)` signature, and return the confirmed Solana transaction signature together with the actual human-readable input amount spent and output amount received. Callers that select native SOL SHALL remain responsible for identifying the AssetAZ SOL UUID, resolving the canonical mint through CIS, reading native wallet balance, and applying any reserve policy; the swap service SHALL NOT introduce a separate native-SOL operation. #### Scenario: Successful exact-input swap -- **WHEN** a caller requests a valid exact-input swap that Jupiter executes successfully +- **WHEN** a caller requests a valid exact-input swap from a supported legacy SPL or Token-2022 mint that Jupiter executes successfully - **THEN** the result contains the confirmed signature and the actual spent and received amounts converted with their respective mint decimal precision +#### Scenario: Successful native SOL input swap +- **WHEN** a caller requests a valid exact-input swap using `So11111111111111111111111111111111111111112` as input and Jupiter executes it from the taker's native SOL balance +- **THEN** Jupiter receives that same canonical input mint and the service returns the confirmed signature and actual human-readable spent and received amounts through the existing result contract + +#### Scenario: Existing token-program behavior is preserved +- **WHEN** a caller uses a supported legacy SPL-token or Token-2022 input +- **THEN** mint validation, decimal conversion, signing, single managed execution, and independent `CONFIRMED` handling remain unchanged + #### Scenario: Invalid caller input -- **WHEN** either mint is blank, both mints are equal, the amount is null or non-positive, or the maximum slippage is outside 0 through 10000 basis points +- **WHEN** either mint representation is blank, both mints are equal, the amount is null or non-positive, or the maximum slippage is outside 0 through 10000 basis points - **THEN** the service rejects the request before requesting a Jupiter order ### Requirement: On-chain mint validation and exact amount conversion diff --git a/src/main/tjava/com/fanitas/evelyn/service/burner/EvelynIOUBurnerService.tjava b/src/main/tjava/com/fanitas/evelyn/service/burner/EvelynIOUBurnerService.tjava new file mode 100644 index 0000000..2db3510 --- /dev/null +++ b/src/main/tjava/com/fanitas/evelyn/service/burner/EvelynIOUBurnerService.tjava @@ -0,0 +1,90 @@ +package com.fanitas.evelyn.service.burner; + +import com.r35157.libs.valuetypes.basic.MoneyAmount; + +import java.util.List; + +/** + * Continuously buys back and burns Evelyn IOU from a dedicated wallet. + * + *

Accepted buyback inputs and their reserves are configured as an ordered + * {@link List} of {@link MoneyAmount} values. Each + * {@link MoneyAmount#currencyType() currency type}'s stable AssetAZ UUID is + * the authoritative identity, and each {@link MoneyAmount#amount() amount} is + * the minimum balance that must remain. List order is currency priority. + * Solana mint addresses and token programs are integration details resolved + * internally through the Currency Identity Service and Solana and are not + * part of Evelyn's input configuration.

+ * + *

Every non-overlapping iteration has this order:

+ *
    + *
  1. If a prior burn has a pending signature, await only that exact + * signature at Solana {@code FINALIZED}. An unknown result ends the + * iteration; definitive failure is logged without notification; success + * receives the burn's single notification attempt. No new swap or burn is + * submitted in an iteration that begins with a pending burn.
  2. + *
  3. Unless swaps are suspended, inspect accepted currencies in priority + * order. Canonicalize each AssetAZ UUID, resolve its native-SOL or SPL + * representation internally, apply its own minimum reserve, and require a + * fresh input/USDC price. Missing or unusable candidate data does not + * prevent later currencies from being considered.
  4. + *
  5. Select at most the configured USDC-equivalent maximum, rounded down + * to the input currency's supported precision. Native SOL uses its native + * balance and nine-decimal precision; no future transaction fee is + * estimated when applying its reserve.
  6. + *
  7. Attempt no more than one exact-input Jupiter swap. Normal return is + * already {@code CONFIRMED}; it is not awaited a second time. A definitive + * failure permits a later iteration to try again, while a timeout or other + * potentially unknown post-submission result suspends swaps for this + * lifecycle.
  8. + *
  9. Reload the complete visible Evelyn IOU balance, including tokens + * deposited or acquired before this iteration. Absence or failure of a + * swap does not suppress this step, and the USDC buyback limit never caps + * the amount eligible for burning.
  10. + *
  11. Require a positive, sufficiently fresh canonical EVE/USDT price and + * calculate the complete balance's dollar value before submitting a burn. + * Missing, invalid, or stale price data postpones the burn.
  12. + *
  13. Submit the complete EVE balance once and retain its signature, + * amount, and notification value as pending before waiting. The service + * never automatically retries, rebuilds, re-signs, or resubmits a swap or + * burn because timeout, interruption, or communication failure can leave + * the original transaction's outcome unknown.
  14. + *
  15. Await the burn at {@code FINALIZED}. Timeout or I/O retains the + * signature for a later await; definitive on-chain failure clears it + * without notification; successful finalization permits notification.
  16. + *
  17. Attempt the fixed proof notification exactly once for a successfully + * finalized burn. Notification failure neither retries delivery nor + * repeats the completed burn.
  18. + *
+ * + *

One dedicated worker begins immediately on {@link #start()}, uses fixed + * delay measured after each completed iteration, and never overlaps + * iterations. {@link #stop()} prevents new iterations and stops that worker. + * Pending signatures and swap/burn suspension flags are process-local only + * and are deliberately reset by a completed explicit stop/start cycle or a + * process restart. Such a reset forgets unknown transaction state and can + * weaken duplicate prevention; operators should reconcile uncertain on-chain + * activity before deliberately restarting a suspended or pending service.

+ */ +public interface EvelynIOUBurnerService { + /** + * Starts the dedicated worker and begins the first iteration immediately. + * + * @throws IllegalStateException if this instance is already running or a + * prior worker has not terminated cleanly + */ + void start(); + + /** + * Prevents new iterations, interrupts current processing, and stops the + * dedicated worker cleanly. + * + *

A completed stop clears process-local pending and suspension state. + * If the calling thread is interrupted while waiting for termination, its + * interrupt status is preserved.

+ * + * @throws IllegalStateException if clean worker termination cannot be + * established + */ + void stop(); +} diff --git a/src/main/tjava/com/fanitas/evelyn/service/burner/impl/ref/EvelynIOUBurnerServiceImpl.tjava b/src/main/tjava/com/fanitas/evelyn/service/burner/impl/ref/EvelynIOUBurnerServiceImpl.tjava new file mode 100644 index 0000000..a575481 --- /dev/null +++ b/src/main/tjava/com/fanitas/evelyn/service/burner/impl/ref/EvelynIOUBurnerServiceImpl.tjava @@ -0,0 +1,1045 @@ +package com.fanitas.evelyn.service.burner.impl.ref; + +import com.fanitas.evelyn.service.burner.EvelynIOUBurnerService; +import com.r35157.assetaz.services.cis.CurrencyIdentityService; +import com.r35157.assetaz.services.cis.ExternalCurrencyReference; +import com.r35157.assetaz.services.ticker.PriceObservation; +import com.r35157.assetaz.services.ticker.TickerService; +import com.r35157.assetaz.valuetypes.CurrencyType; +import com.r35157.cryptowallet.solana.SolanaWallet; +import com.r35157.libs.jupiter.JupiterTransactionOutcomeException; +import com.r35157.libs.jupiter.swap.JupiterSwapService; +import com.r35157.libs.solana.SPLTokenSupply; +import com.r35157.libs.solana.SolanaAccountInfo; +import com.r35157.libs.solana.SolanaBlockChain; +import com.r35157.libs.solana.SolanaCommitment; +import com.r35157.libs.solana.SolanaTransactionOutcome; +import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram; +import com.r35157.libs.valuetypes.basic.AssetPrice; +import com.r35157.libs.valuetypes.basic.MoneyAmount; +import com.r35157.libs.valuetypes.basic.TradingPair; +import com.r35157.service.notification.BoundNotificationService; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +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.USDC_ID; +import static com.r35157.assetaz.services.cis.CurrencyTypeIds.USDT_ID; +import static com.r35157.assetaz.services.cis.ExternalCurrencyReference.SOLANA_MINT_NAMESPACE; + +/** + * Reference implementation of the Evelyn IOU buyback-and-burn lifecycle. + */ +public final class EvelynIOUBurnerServiceImpl + implements EvelynIOUBurnerService { + /** + * Creates an Evelyn IOU burner with all runtime dependencies and current + * operating policy. + * + *

Each entry in {@code inputCurrencyMinimumReserves} identifies one + * accepted input through {@code currencyType().id()} and gives the minimum + * amount of that same currency that must remain through {@code amount()}. + * Entry order is buyback priority. The list is copied defensively, and an + * empty list permits direct EVE deposits to be burned without buybacks. + * Supplied names and symbols are ignored in favor of CIS-owned canonical + * metadata. Solana mint addresses and token programs are resolved + * internally and are not constructor configuration.

+ * + * @param burnerWallet dedicated wallet whose balances are inspected and + * whose complete EVE balance is burned + * @param swapService exact-input Jupiter service; normal return already + * means the swap reached {@code CONFIRMED} + * @param solanaBlockChain Solana access used for mint ownership, token + * precision, and burn finalization + * @param tickerService latest-price source for input/USDC and EVE/USDT + * @param currencyIdentityService authoritative resolver for AssetAZ UUIDs + * and Solana mint references + * @param notificationService fully bound synchronous destination for the + * single post-finalization notification attempt + * @param inputCurrencyMinimumReserves ordered accepted-input list; entries + * must be non-null, use distinct known non-EVE UUIDs, and contain a + * non-null reserve amount greater than or equal to zero; every + * non-SOL UUID must have exactly one non-blank CIS + * {@code solana-mint} external reference + * @param maximumSwapAmountInUSDC strictly positive USDC-equivalent ceiling + * for one buyback; it does not limit burns + * @param maximumSlippageBps Jupiter slippage limit in basis points from 0 + * through 10000 inclusive + * @param maximumPriceAge strictly positive maximum observation age in + * milliseconds for both input and EVE prices + * @param burnFinalizationTimeout strictly positive timeout passed unchanged + * to each {@code FINALIZED} burn await + * @param iterationInterval strictly positive fixed delay measured from one + * completed iteration to the next + * @throws NullPointerException if an injected service, configuration + * object, list entry, reserve amount, + * configured currency, or UUID is null + * @throws IllegalArgumentException if a reserve or scalar limit is out of + * range, a duration is non-positive, a + * UUID is unknown or duplicated, EVE is + * configured as input, or a non-SOL input + * lacks exactly one usable Solana mint + */ + public EvelynIOUBurnerServiceImpl( + @NotNull SolanaWallet burnerWallet, + @NotNull JupiterSwapService swapService, + @NotNull SolanaBlockChain solanaBlockChain, + @NotNull TickerService tickerService, + @NotNull CurrencyIdentityService currencyIdentityService, + @NotNull BoundNotificationService notificationService, + @NotNull List inputCurrencyMinimumReserves, + @NotNull ΩUSDCAmountΩ maximumSwapAmountInUSDC, + int maximumSlippageBps, + ΩmilliSecondsΩ maximumPriceAge, + @NotNull Duration burnFinalizationTimeout, + @NotNull Duration iterationInterval + ) { + this.burnerWallet = Objects.requireNonNull( + burnerWallet, + "burnerWallet" + ); + this.swapService = Objects.requireNonNull( + swapService, + "swapService" + ); + this.solanaBlockChain = Objects.requireNonNull( + solanaBlockChain, + "solanaBlockChain" + ); + this.tickerService = Objects.requireNonNull( + tickerService, + "tickerService" + ); + this.currencyIdentityService = Objects.requireNonNull( + currencyIdentityService, + "currencyIdentityService" + ); + this.notificationService = Objects.requireNonNull( + notificationService, + "notificationService" + ); + + Objects.requireNonNull( + maximumSwapAmountInUSDC, + "maximumSwapAmountInUSDC" + ); + if (maximumSwapAmountInUSDC.signum() <= 0) { + throw new IllegalArgumentException( + "maximumSwapAmountInUSDC must be greater than zero" + ); + } + if (maximumSlippageBps < 0 + || maximumSlippageBps > MAXIMUM_SLIPPAGE_BPS) { + throw new IllegalArgumentException( + "maximumSlippageBps must be between 0 and " + + MAXIMUM_SLIPPAGE_BPS + ); + } + if (maximumPriceAge <= 0) { + throw new IllegalArgumentException( + "maximumPriceAge must be greater than zero milliseconds" + ); + } + + this.burnFinalizationTimeout = requirePositiveDuration( + burnFinalizationTimeout, + "burnFinalizationTimeout" + ); + Duration validatedIterationInterval = requirePositiveDuration( + iterationInterval, + "iterationInterval" + ); + this.iterationIntervalNanos = TimeUnit.NANOSECONDS.convert( + validatedIterationInterval + ); + if (iterationIntervalNanos <= 0) { + throw new IllegalArgumentException( + "iterationInterval must be representable as at least one " + + "nanosecond" + ); + } + + this.maximumSwapAmountInUSDC = maximumSwapAmountInUSDC; + this.maximumSlippageBps = maximumSlippageBps; + this.maximumPriceAge = maximumPriceAge; + this.maximumPriceAgeDuration = Duration.ofMillis(maximumPriceAge); + + CurrencyType canonicalEve = requireCanonicalCurrency( + EVE_ID, + "Evelyn IOU" + ); + this.canonicalUsdc = requireCanonicalCurrency(USDC_ID, "USDC"); + CurrencyType canonicalUsdt = requireCanonicalCurrency( + USDT_ID, + "USDT" + ); + this.eveUsdtTradingPair = new TradingPair( + canonicalEve, + canonicalUsdt + ); + this.inputCurrencyMinimumReserves = validateAndCopyInputs( + inputCurrencyMinimumReserves + ); + } + + @Override + public synchronized void start() { + if (worker != null || stopping) { + throw new IllegalStateException( + "Evelyn IOU burner is already running or stopping" + ); + } + + resetProcessLocalState(); + ScheduledExecutorService newWorker; + try { + newWorker = Executors.newSingleThreadScheduledExecutor( + runnable -> { + Thread thread = new Thread( + runnable, + WORKER_THREAD_NAME + ); + thread.setDaemon(true); + return thread; + } + ); + } catch (RuntimeException exception) { + throw new IllegalStateException( + "Could not create Evelyn IOU burner worker", + exception + ); + } + + worker = newWorker; + try { + newWorker.scheduleWithFixedDelay( + this::runIterationSafely, + 0, + iterationIntervalNanos, + TimeUnit.NANOSECONDS + ); + } catch (RuntimeException exception) { + newWorker.shutdownNow(); + worker = null; + resetProcessLocalState(); + throw new IllegalStateException( + "Could not start Evelyn IOU burner worker", + exception + ); + } + } + + @Override + public void stop() { + ScheduledExecutorService workerToStop; + synchronized (this) { + if (worker == null) { + resetProcessLocalState(); + return; + } + if (stopping) { + throw new IllegalStateException( + "Evelyn IOU burner is already stopping" + ); + } + stopping = true; + workerToStop = worker; + } + + workerToStop.shutdownNow(); + boolean terminated; + try { + terminated = workerToStop.awaitTermination( + WORKER_TERMINATION_TIMEOUT_SECONDS, + TimeUnit.SECONDS + ); + } catch (InterruptedException exception) { + workerToStop.shutdownNow(); + boolean terminatedAfterInterruption = workerToStop.isTerminated(); + synchronized (this) { + if (terminatedAfterInterruption && worker == workerToStop) { + worker = null; + resetProcessLocalState(); + } + stopping = false; + } + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Interrupted while stopping Evelyn IOU burner", + exception + ); + } + + synchronized (this) { + if (!terminated) { + stopping = false; + throw new IllegalStateException( + "Evelyn IOU burner worker did not terminate cleanly" + ); + } + if (worker == workerToStop) { + worker = null; + } + resetProcessLocalState(); + stopping = false; + } + } + + private void runIterationSafely() { + try { + runIteration(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + if (!isStopping()) { + log.warn("Evelyn IOU burner iteration was interrupted"); + } + } catch (RuntimeException exception) { + log.error( + "Unexpected Evelyn IOU burner iteration failure", + exception + ); + } + } + + private void runIteration() throws InterruptedException { + if (pendingBurn != null) { + awaitPendingBurn(); + return; + } + + if (!swapsSuspended) { + attemptAtMostOneSwap(); + } + processCompleteEveBalance(); + } + + private void attemptAtMostOneSwap() throws InterruptedException { + for (MoneyAmount minimumReserve : inputCurrencyMinimumReserves) { + UUID currencyId = minimumReserve.currencyType().id(); + CurrencyType canonicalCurrency; + try { + canonicalCurrency = requireCanonicalCurrency( + currencyId, + "configured input" + ); + } catch (RuntimeException exception) { + warnSkippedInput( + currencyId, + "canonical currency resolution", + exception + ); + continue; + } + + boolean nativeSolana = SOLANA_ID.equals(currencyId); + ΩSPLMintAddressΩ inputMint; + ΩAmountΩ balance; + ΩamountDecimalsΩ decimals; + SolanaSPLTokenProgram inputTokenProgram = null; + try { + inputMint = requireUniqueSolanaMint(currencyId); + if (nativeSolana) { + if (!CANONICAL_SOLANA_JUPITER_MINT.equals(inputMint)) { + throw new IllegalStateException( + "CIS returned a non-canonical Jupiter mint for " + + "native SOL" + ); + } + MoneyAmount solanaBalance = burnerWallet.getSolanaBalance(); + if (solanaBalance == null + || solanaBalance.amount() == null) { + throw new IllegalStateException( + "Wallet returned no native SOL balance amount" + ); + } + balance = solanaBalance.amount(); + decimals = NATIVE_SOLANA_DECIMALS; + } else { + inputTokenProgram = resolveSupportedTokenProgram( + inputMint + ); + balance = burnerWallet.getSPLTokenBalance( + inputMint, + inputTokenProgram + ); + if (balance == null) { + continue; + } + decimals = -1; + } + } catch (IOException | RuntimeException exception) { + warnSkippedInput( + currencyId, + "balance or Solana representation", + exception + ); + continue; + } + + if (balance.signum() < 0) { + warnSkippedInput( + currencyId, + "negative wallet balance", + null + ); + continue; + } + ΩAmountΩ availableForSwap = balance.subtract( + minimumReserve.amount() + ); + if (availableForSwap.signum() <= 0) { + continue; + } + + ΩPriceΩ inputPriceInUsdc; + if (USDC_ID.equals(currencyId)) { + inputPriceInUsdc = BigDecimal.ONE; + } else { + inputPriceInUsdc = findFreshPrice( + new TradingPair(canonicalCurrency, canonicalUsdc) + ); + if (inputPriceInUsdc == null) { + continue; + } + } + + if (!nativeSolana) { + try { + decimals = resolveMintDecimals( + inputMint, + inputTokenProgram + ); + } catch (IOException | RuntimeException exception) { + warnSkippedInput( + currencyId, + "mint precision", + exception + ); + continue; + } + } + + ΩAmountΩ maximumInputAmount = maximumSwapAmountInUSDC.divide( + inputPriceInUsdc, + decimals, + RoundingMode.DOWN + ); + ΩAmountΩ amountToSwap = availableForSwap + .min(maximumInputAmount) + .setScale(decimals, RoundingMode.DOWN); + if (amountToSwap.signum() <= 0) { + continue; + } + + attemptSwap( + canonicalCurrency, + inputMint, + amountToSwap + ); + return; + } + } + + private void attemptSwap( + CurrencyType canonicalCurrency, + ΩSPLMintAddressΩ inputMint, + ΩAmountΩ amountToSwap + ) throws InterruptedException { + try { + swapService.swap( + inputMint, + amountToSwap, + EVELYN_IOU_MINT, + maximumSlippageBps + ); + } catch (JupiterTransactionOutcomeException exception) { + SolanaTransactionOutcome outcome = exception.outcome(); + switch (outcome.status()) { + case FAILED -> log.error( + "Evelyn IOU buyback swap failed on-chain: " + + "currencyId={}, signature={}, slot={}, " + + "failureDetails={}", + canonicalCurrency.id(), + exception.transactionSignature(), + outcome.slot(), + outcome.failureDetails() + ); + case TIMED_OUT -> { + swapsSuspended = true; + log.error( + "Evelyn IOU buyback swap outcome is unknown after " + + "timeout; later swaps are suspended: " + + "currencyId={}, signature={}", + canonicalCurrency.id(), + exception.transactionSignature() + ); + } + case SUCCEEDED -> throw new IllegalStateException( + "A successful Jupiter outcome was reported as an " + + "exception" + ); + } + } catch (IOException exception) { + swapsSuspended = true; + log.error( + "Evelyn IOU buyback swap may have an unknown outcome; " + + "later swaps are suspended: currencyId={}, " + + "failureType={}", + canonicalCurrency.id(), + exception.getClass().getName() + ); + } catch (InterruptedException exception) { + swapsSuspended = true; + throw exception; + } catch (RuntimeException exception) { + log.error( + "Evelyn IOU buyback swap failed before a confirmed " + + "result: currencyId={}, failureType={}", + canonicalCurrency.id(), + exception.getClass().getName() + ); + } + } + + private void processCompleteEveBalance() throws InterruptedException { + ΩAmountΩ eveBalance; + try { + SolanaSPLTokenProgram eveTokenProgram = + resolveSupportedTokenProgram(EVELYN_IOU_MINT); + eveBalance = burnerWallet.getSPLTokenBalance( + EVELYN_IOU_MINT, + eveTokenProgram + ); + } catch (IOException | RuntimeException exception) { + log.warn( + "Could not reload complete Evelyn IOU balance; burn is " + + "postponed: failureType={}", + exception.getClass().getName() + ); + return; + } + + if (eveBalance == null || eveBalance.signum() == 0) { + return; + } + if (eveBalance.signum() < 0) { + log.warn( + "Wallet returned a negative Evelyn IOU balance; burn is " + + "postponed" + ); + return; + } + if (burnsSuspended) { + log.warn( + "Evelyn IOU burn submissions remain suspended because a " + + "prior signature-less submission outcome may be " + + "unknown" + ); + return; + } + + ΩPriceΩ evePriceInUsdt = findFreshPrice(eveUsdtTradingPair); + if (evePriceInUsdt == null) { + return; + } + ΩAmountΩ dollarValue = eveBalance.multiply(evePriceInUsdt); + + ΩSolanaTransactionSignatureΩ burnSignature; + try { + burnSignature = burnerWallet.burnSPLToken( + EVELYN_IOU_MINT, + eveBalance + ); + } catch (IOException exception) { + burnsSuspended = true; + log.error( + "Evelyn IOU burn submission may have an unknown outcome; " + + "later burns are suspended: failureType={}", + exception.getClass().getName() + ); + return; + } catch (InterruptedException exception) { + burnsSuspended = true; + throw exception; + } catch (IllegalArgumentException | IllegalStateException exception) { + log.error( + "Evelyn IOU burn was rejected before a usable signature: " + + "failureType={}", + exception.getClass().getName() + ); + return; + } catch (RuntimeException exception) { + burnsSuspended = true; + log.error( + "Evelyn IOU burn failed without a usable signature and " + + "may have an unknown outcome; later burns are " + + "suspended: failureType={}", + exception.getClass().getName() + ); + return; + } + + if (burnSignature == null || burnSignature.isBlank()) { + burnsSuspended = true; + log.error( + "Evelyn IOU burn returned no usable signature; later " + + "burns are suspended because the outcome may be " + + "unknown" + ); + return; + } + + pendingBurn = new PendingBurn( + burnSignature, + eveBalance, + dollarValue + ); + awaitPendingBurn(); + } + + private void awaitPendingBurn() throws InterruptedException { + PendingBurn burn = pendingBurn; + if (burn == null) { + return; + } + + SolanaTransactionOutcome outcome; + try { + outcome = solanaBlockChain.awaitTransaction( + burn.transactionSignature(), + SolanaCommitment.FINALIZED, + burnFinalizationTimeout + ); + } catch (IOException exception) { + log.warn( + "Could not determine FINALIZED Evelyn IOU burn outcome; " + + "the pending signature is retained: signature={}, " + + "failureType={}", + burn.transactionSignature(), + exception.getClass().getName() + ); + return; + } + + if (outcome == null) { + log.error( + "Solana returned no outcome while awaiting Evelyn IOU burn; " + + "the pending signature is retained: signature={}", + burn.transactionSignature() + ); + return; + } + + switch (outcome.status()) { + case SUCCEEDED -> notifyFinalizedBurnOnce(burn); + case FAILED -> { + log.error( + "Evelyn IOU burn failed on-chain at FINALIZED: " + + "signature={}, slot={}, failureDetails={}", + burn.transactionSignature(), + outcome.slot(), + outcome.failureDetails() + ); + pendingBurn = null; + } + case TIMED_OUT -> log.warn( + "Evelyn IOU burn did not reach a definitive FINALIZED " + + "outcome; the pending signature is retained: " + + "signature={}", + burn.transactionSignature() + ); + } + } + + private void notifyFinalizedBurnOnce(PendingBurn burn) { + ΩNotificationMessageΩ message = formatNotification(burn); + + // Clearing before push makes this invocation the only delivery attempt + // even if the bound transport throws an unchecked failure. + pendingBurn = null; + try { + notificationService.push(message); + } catch (IOException | RuntimeException exception) { + log.error( + "Evelyn IOU burn notification delivery failed and will not " + + "be retried: signature={}, failureType={}", + burn.transactionSignature(), + exception.getClass().getName() + ); + } + } + + private ΩNotificationMessageΩ formatNotification(PendingBurn burn) { + String eveAmount = burn.eveAmount() + .stripTrailingZeros() + .toPlainString(); + String dollarValue = burn.dollarValue() + .setScale(2, RoundingMode.HALF_UP) + .toPlainString(); + return "🔥🔥🔥 We have bought back and burned " + + eveAmount + + " EVE (Evelyn IOU tokens, value $" + + dollarValue + + ") to reduce the circulating supply! 🔥🔥🔥\n\n" + + "Proof:\n" + + "https://solscan.io/tx/" + + burn.transactionSignature(); + } + + private ΩPriceΩ findFreshPrice(TradingPair tradingPair) { + PriceObservation observation; + try { + observation = tickerService.getLatestPrice(tradingPair); + } catch (RuntimeException exception) { + log.warn( + "No usable price is available; operation is postponed or " + + "the input is skipped: tradingPair={}, " + + "baseCurrencyId={}, " + + "failureType={}", + tradingPair, + tradingPair.base().id(), + exception.getClass().getName() + ); + return null; + } + + if (observation == null + || observation.observedAt() == null + || observation.price() == null) { + log.warn( + "Price observation is incomplete; operation is postponed " + + "or the input is skipped: tradingPair={}, " + + "baseCurrencyId={}", + tradingPair, + tradingPair.base().id() + ); + return null; + } + + AssetPrice observedPrice = observation.price(); + if (observedPrice.tradingPair() == null + || !tradingPair.equals(observedPrice.tradingPair()) + || observedPrice.price() == null + || observedPrice.price().signum() <= 0) { + log.warn( + "Price observation is invalid; operation is postponed or " + + "the input is skipped: tradingPair={}, " + + "baseCurrencyId={}", + tradingPair, + tradingPair.base().id() + ); + return null; + } + + Duration age = Duration.between( + observation.observedAt(), + Instant.now() + ); + if (age.compareTo(maximumPriceAgeDuration) > 0) { + log.warn( + "Price observation is too old; operation is postponed or " + + "the input is skipped: tradingPair={}, " + + "baseCurrencyId={}, observedAt={}, " + + "maximumAgeMillis={}", + tradingPair, + tradingPair.base().id(), + observation.observedAt(), + maximumPriceAge + ); + return null; + } + return observedPrice.price(); + } + + private SolanaSPLTokenProgram resolveSupportedTokenProgram( + ΩSPLMintAddressΩ mintAddress + ) throws IOException, InterruptedException { + SolanaAccountInfo mintAccount = solanaBlockChain.getAccountInfo( + mintAddress + ); + if (mintAccount == null) { + throw new IllegalStateException( + "Solana mint account does not exist: " + mintAddress + ); + } + + for (SolanaSPLTokenProgram tokenProgram + : SolanaSPLTokenProgram.values()) { + if (tokenProgram.getAddress().equals(mintAccount.owner())) { + return tokenProgram; + } + } + throw new IllegalStateException( + "Solana mint is owned by an unsupported token program: " + + mintAddress + ); + } + + private ΩamountDecimalsΩ resolveMintDecimals( + ΩSPLMintAddressΩ mintAddress, + SolanaSPLTokenProgram tokenProgram + ) throws IOException, InterruptedException { + SPLTokenSupply supply = solanaBlockChain.getSPLTokenSupply( + mintAddress, + tokenProgram + ); + if (supply == null) { + throw new IllegalStateException( + "Solana returned no supply metadata for mint " + + mintAddress + ); + } + if (!mintAddress.equals(supply.mintAddress())) { + throw new IllegalStateException( + "Solana returned supply metadata for an unexpected mint" + ); + } + if (!tokenProgram.getAddress().equals(supply.programId())) { + throw new IllegalStateException( + "Solana returned an unexpected token program for mint " + + mintAddress + ); + } + if (supply.decimals() < 0 + || supply.decimals() > MAXIMUM_SPL_TOKEN_DECIMALS) { + throw new IllegalStateException( + "Solana returned invalid decimal precision for mint " + + mintAddress + ); + } + return supply.decimals(); + } + + private List validateAndCopyInputs( + List configuredInputs + ) { + Objects.requireNonNull( + configuredInputs, + "inputCurrencyMinimumReserves" + ); + List validatedInputs = new ArrayList<>( + configuredInputs.size() + ); + Set configuredCurrencyIds = new HashSet<>(); + + for (int index = 0; index < configuredInputs.size(); index++) { + MoneyAmount minimumReserve = Objects.requireNonNull( + configuredInputs.get(index), + "inputCurrencyMinimumReserves[" + index + "]" + ); + Objects.requireNonNull( + minimumReserve.amount(), + "inputCurrencyMinimumReserves[" + index + "].amount" + ); + if (minimumReserve.amount().signum() < 0) { + throw new IllegalArgumentException( + "Input currency minimum reserve must be zero or " + + "greater at index " + index + ); + } + + CurrencyType configuredCurrency = Objects.requireNonNull( + minimumReserve.currencyType(), + "inputCurrencyMinimumReserves[" + index + + "].currencyType" + ); + UUID currencyId = Objects.requireNonNull( + configuredCurrency.id(), + "inputCurrencyMinimumReserves[" + index + + "].currencyType.id" + ); + requireCanonicalCurrency(currencyId, "configured input"); + + if (!configuredCurrencyIds.add(currencyId)) { + throw new IllegalArgumentException( + "Duplicate input currency UUID: " + currencyId + ); + } + if (EVE_ID.equals(currencyId)) { + throw new IllegalArgumentException( + "Evelyn IOU cannot be configured as a burner input" + ); + } + if (!SOLANA_ID.equals(currencyId)) { + requireUniqueSolanaMint(currencyId); + } + validatedInputs.add(minimumReserve); + } + return List.copyOf(validatedInputs); + } + + private CurrencyType requireCanonicalCurrency( + UUID currencyId, + String purpose + ) { + try { + CurrencyType canonicalCurrency = currencyIdentityService.resolve( + Objects.requireNonNull(currencyId, "currencyId") + ); + if (canonicalCurrency == null) { + throw new IllegalArgumentException( + "CIS returned null for " + purpose + " UUID " + + currencyId + ); + } + if (!currencyId.equals(canonicalCurrency.id())) { + throw new IllegalArgumentException( + "CIS returned canonical currency UUID " + + canonicalCurrency.id() + + " while resolving " + + currencyId + ); + } + return canonicalCurrency; + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException( + "Unknown or invalid " + purpose + " AssetAZ UUID: " + + currencyId, + exception + ); + } + } + + private ΩSPLMintAddressΩ requireUniqueSolanaMint(UUID currencyId) { + Set references = + currencyIdentityService.findExternalReferences(currencyId); + if (references == null) { + throw new IllegalArgumentException( + "CIS returned no external-reference set for UUID " + + currencyId + ); + } + + ExternalCurrencyReference matchingReference = null; + int matchingReferenceCount = 0; + for (ExternalCurrencyReference reference : references) { + if (reference != null + && SOLANA_MINT_NAMESPACE.equals(reference.namespace())) { + matchingReference = reference; + matchingReferenceCount++; + } + } + if (matchingReferenceCount != 1) { + throw new IllegalArgumentException( + "AssetAZ currency UUID " + + currencyId + + " must have exactly one " + + SOLANA_MINT_NAMESPACE + + " external reference, but has " + + matchingReferenceCount + ); + } + + String mintAddress = matchingReference.externalId(); + if (mintAddress == null || mintAddress.isBlank()) { + throw new IllegalArgumentException( + "AssetAZ currency UUID " + + currencyId + + " has a blank " + + SOLANA_MINT_NAMESPACE + + " external ID" + ); + } + return mintAddress; + } + + private void warnSkippedInput( + UUID currencyId, + String unavailableData, + Throwable exception + ) { + String failureType = exception == null + ? "unavailable" + : exception.getClass().getName(); + log.warn( + "Skipping Evelyn IOU buyback input: currencyId={}, " + + "unavailableData={}, failureType={}", + currencyId, + unavailableData, + failureType + ); + } + + private static Duration requirePositiveDuration( + Duration duration, + String parameterName + ) { + Objects.requireNonNull(duration, parameterName); + if (duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException( + parameterName + " must be greater than zero" + ); + } + return duration; + } + + private synchronized boolean isStopping() { + return stopping; + } + + private void resetProcessLocalState() { + pendingBurn = null; + swapsSuspended = false; + burnsSuspended = false; + } + + private static final Logger log = LoggerFactory.getLogger( + EvelynIOUBurnerServiceImpl.class + ); + private static final ΩSPLMintAddressΩ EVELYN_IOU_MINT = + "meveYG2iXYSkgSUn1T1uxcthH1EGMZdRHGgCntXZA3Y"; + private static final ΩSPLMintAddressΩ CANONICAL_SOLANA_JUPITER_MINT = + "So11111111111111111111111111111111111111112"; + private static final int MAXIMUM_SLIPPAGE_BPS = 10000; + private static final ΩamountDecimalsΩ NATIVE_SOLANA_DECIMALS = 9; + private static final ΩamountDecimalsΩ MAXIMUM_SPL_TOKEN_DECIMALS = 255; + private static final long WORKER_TERMINATION_TIMEOUT_SECONDS = 10; + private static final String WORKER_THREAD_NAME = + "evelyn-iou-buyback-and-burner"; + + private final SolanaWallet burnerWallet; + private final JupiterSwapService swapService; + private final SolanaBlockChain solanaBlockChain; + private final TickerService tickerService; + private final CurrencyIdentityService currencyIdentityService; + private final BoundNotificationService notificationService; + private final List inputCurrencyMinimumReserves; + private final ΩUSDCAmountΩ maximumSwapAmountInUSDC; + private final int maximumSlippageBps; + private final ΩmilliSecondsΩ maximumPriceAge; + private final Duration maximumPriceAgeDuration; + private final Duration burnFinalizationTimeout; + private final long iterationIntervalNanos; + private final CurrencyType canonicalUsdc; + private final TradingPair eveUsdtTradingPair; + + private ScheduledExecutorService worker; + private boolean stopping; + private boolean swapsSuspended; + private boolean burnsSuspended; + private PendingBurn pendingBurn; + + private record PendingBurn( + ΩSolanaTransactionSignatureΩ transactionSignature, + ΩAmountΩ eveAmount, + ΩAmountΩ dollarValue + ) { + } +} diff --git a/src/main/tjava/com/r35157/libs/jupiter/swap/JupiterSwapService.tjava b/src/main/tjava/com/r35157/libs/jupiter/swap/JupiterSwapService.tjava index 885ef86..24a7550 100644 --- a/src/main/tjava/com/r35157/libs/jupiter/swap/JupiterSwapService.tjava +++ b/src/main/tjava/com/r35157/libs/jupiter/swap/JupiterSwapService.tjava @@ -8,16 +8,31 @@ import java.io.IOException; import java.math.BigDecimal; /** - * Exchanges an exact human-readable SPL-token amount through Jupiter. + * Exchanges an exact human-readable token amount through Jupiter. * - *

The service resolves token precision from Solana, obtains and validates a + *

An input can be a supported SPL token or native SOL represented by + * Jupiter's canonical SOL/WSOL mint + * {@code So11111111111111111111111111111111111111112}. The canonical address + * remains the {@code inputTokenMint} passed to the unchanged swap operation; + * callers that select native SOL remain responsible for reading the native + * wallet balance and applying their own reserve policy. Output remains a + * supported SPL-token mint.

+ * + *

The service resolves mint precision from Solana, obtains and validates a * Jupiter Swap V2 order, asks its configured wallet to sign the transaction, * submits the signed transaction through Jupiter's managed execution - * endpoint, and independently awaits Solana confirmation.

+ * endpoint, and independently awaits Solana confirmation. Legacy SPL-token + * and Token-2022 behavior is unchanged.

*/ public interface JupiterSwapService { /** - * Swaps an exact amount of one SPL token for another SPL token. + * Swaps an exact amount of a supported token or native SOL into an SPL + * token. + * + *

Native SOL input is expressed with the canonical SOL/WSOL mint + * {@code So11111111111111111111111111111111111111112}; the service passes + * that value unchanged to Jupiter as {@code inputMint}. No separate + * native-SOL method is required.

* *

The returned amounts are the actual wallet-level amounts reported by * Jupiter after successful provider execution, not the quoted amounts. @@ -26,7 +41,8 @@ public interface JupiterSwapService { * error after submission can leave the transaction outcome unknown and * must never cause automatic resubmission.

* - * @param inputTokenMint input SPL-token mint address + * @param inputTokenMint input SPL-token mint address, or the canonical + * SOL/WSOL mint representing native SOL * @param inputTokenAmount exact input amount in human-readable token units * @param outputTokenMint output SPL-token mint address * @param maxSlippageBps maximum accepted slippage from 0 through 10000