Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e69292c2e | ||
|
|
124b0d49c6 | ||
|
|
e8d4dbe7ae | ||
|
|
edad01dbb9 |
@@ -39,6 +39,7 @@ This policy currently applies to:
|
||||
- `alarmaction_Pushover.conf`
|
||||
- `alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf`
|
||||
- `alarmaction_JupiterPerpsPositionDecreaseAlarmAction.conf`
|
||||
- `evelyn-liquidity.conf` (optional version 1 liquidity realization/payout configuration)
|
||||
- `evelyn.conf`
|
||||
- `positions.conf`
|
||||
|
||||
@@ -49,6 +50,25 @@ Nenjim Hub reads only `conf/NenjimHub.conf`; copy
|
||||
`conf/NenjimHub.conf.example` before first use and edit the ordered process
|
||||
list for the deployment role. The example is never loaded as runtime input.
|
||||
|
||||
The Evelyn status processes use `evelyn.status.prod` and `evelyn.status.test`.
|
||||
When upgrading an existing deployment, replace `evelyn.service.prod` and
|
||||
`evelyn.service.test` with those IDs in private `conf/NenjimHub.conf`, preserving
|
||||
their order and enabled/commented selections. The instance names `Production`
|
||||
and `Test`, their `data/evelyn/<instance-name>/status.log` files, and history
|
||||
format version 1 remain unchanged.
|
||||
|
||||
`com.r35157.evelyn.service.status.EvelynStatusService` exposes immutable status
|
||||
history and `AssetPrice getReferencePrice(Instant timestamp)`. The reference
|
||||
price is calculated on demand for the supplied instant and carries the canonical
|
||||
EVE/USDT trading pair. It works before startup and while sampling is stopped,
|
||||
without accessing Ticker or history storage. See [Evelyn](docs/Evelyn.md) for the
|
||||
curve and service contract.
|
||||
|
||||
The optional `evelyn.liquidity.prod` process realizes manually funded
|
||||
EVE/SyrupUSDC CLMM positions, exchanges available SyrupUSDC and sends available
|
||||
USDC from its dedicated wallet. Its deployment configuration and signer are
|
||||
loaded only when selected. See [liquidity service deployment and recovery](docs/EvelynLiquidityService.md).
|
||||
|
||||
## Release log:
|
||||
| Date | Version | Description |
|
||||
|:-----------------|:--------------:|----------------------|
|
||||
|
||||
@@ -6,3 +6,6 @@ FORMAT_VERSION=1
|
||||
[AUTO_START]
|
||||
nenjim.registry.service
|
||||
nenjim.journal.service
|
||||
|
||||
# Optional AssetAZ fact journal (separate from the mandatory Nenjim artifact journal).
|
||||
# assetaz.journal.service
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
FORMAT_VERSION=1
|
||||
# Copy to conf/evelyn-liquidity.conf, replace placeholders and explicitly select
|
||||
# evelyn.liquidity.prod in conf/NenjimHub.conf. This example is never a fallback.
|
||||
# External jup signer must control LIQUIDITY_WALLET; keep secrets outside this file.
|
||||
# Fund the dedicated wallet with SOL for transaction fees and token-account rent.
|
||||
LIQUIDITY_WALLET=REPLACE_WITH_DEDICATED_SOLANA_WALLET
|
||||
SIGNER_KEY_NAME=REPLACE_WITH_EXTERNAL_JUP_SIGNER
|
||||
CLMM_POOL=REPLACE_WITH_EXISTING_EVE_SYRUPUSDC_CLMM_POOL
|
||||
HARVEST_DISTANCE_PERCENT=10
|
||||
ITERATION_INTERVAL_SECONDS=300
|
||||
USDC_RECIPIENT=REPLACE_WITH_RECIPIENT_WALLET_OWNER
|
||||
MINIMUM_SYRUPUSDC_RESERVE=0
|
||||
MINIMUM_USDC_RESERVE=0
|
||||
MAXIMUM_SWAP_SLIPPAGE_BPS=100
|
||||
MAXIMUM_WITHDRAWAL_SLIPPAGE_BPS=100
|
||||
@@ -28,6 +28,7 @@
|
||||
* [8. Risici, fail-safes og antifragilitet](#8-risici-fail-safes-og-antifragilitet)
|
||||
* [9. Udvikling, YouTrack-integration og offentlig gennemsigtighed](#9-udvikling-youtrack-integration-og-offentlig-gennemsigtighed)
|
||||
* [10. Fremtidige emner / TODO](#10-fremtidige-emner--todo)
|
||||
* [11. Statusservice og referencepris](#11-statusservice-og-referencepris)
|
||||
<!-- TOC -->
|
||||
|
||||
## 1. Introduktion formål og vision
|
||||
@@ -301,3 +302,35 @@ kapitalallokering over tid (AI-drevet tuning).
|
||||
ekstreme markedsbevægelser.
|
||||
- Open API for ekstern integration: Gør det muligt at tilføje eksterne datafeeds, udveksle signaler med andre
|
||||
systemer, eller automatisere funding-opgaver.
|
||||
|
||||
## 11. Statusservice og referencepris
|
||||
|
||||
Evelyn er navnet på det samlede system. `com.r35157.evelyn.service.status.EvelynStatusService` indsamler og
|
||||
persisterer statusindeks for én navngiven instans og giver consumers adgang til den samme referencepris, som
|
||||
Evelyn Price Index bruger. Referenceimplementeringen er `EvelynStatusServiceImpl` under
|
||||
`com.r35157.evelyn.service.status.impl.ref`. Hub'en ejer fortsat startup af de enkelte processer.
|
||||
|
||||
`AssetPrice getReferencePrice(Instant timestamp)` beregner prisen for det angivne tidspunkt:
|
||||
|
||||
```text
|
||||
Start: 2026-07-31T22:00:00Z
|
||||
Startpris: 15.00 USDT pr. EVE
|
||||
Årlig faktor: 1.20
|
||||
Årslængde: 31,536,000 sekunder
|
||||
referencePrice = 15.00 × 1.20^(elapsedSeconds / 31,536,000)
|
||||
```
|
||||
|
||||
Resultatet indeholder det kanoniske EVE/USDT-tradingpar, så quote-valutaen er eksplicit. Prisen er 15 ved start og
|
||||
18 efter ét 365-dages år. Statusindekset bruger `((actualPrice / referencePrice) - 1) × 10` med samme samplingtidspunkt
|
||||
og samme beregning og konstanter. Den eksisterende konvention om at behandle USDT som USD i indekset bevares.
|
||||
|
||||
Referenceprisen beregnes ved hvert kald uden tickeropslag, historikadgang, workers eller ændring af historikken.
|
||||
Kaldet virker før sampling starter og mens den er stoppet, også uden provisionerede historikfiler. `null` afvises
|
||||
med `NullPointerException`, og tidspunkter før kurvens start afvises med `IllegalArgumentException`.
|
||||
|
||||
Production og Test bevarer separate navne, historikker, livscyklusser og Mission Control-charts. Historikfilerne
|
||||
ligger fortsat i `data/evelyn/<instance-name>/status.log` med formatversion 1. Sampling sker fortsat med cirka ét
|
||||
minuts fast forsinkelse, og et punkt persisteres før publicering i et immutable snapshot. `stop()` rydder kun
|
||||
hukommelsen og beholder navnereservationen, restart genindlæser filen, og `close()` frigiver navnet efter sikker
|
||||
terminering. `EvelynStatusIndexPoint` bliver i sin eksisterende package med alle seks indeksfelter; kun Evelyn
|
||||
Price Index beregnes og vises, mens de øvrige fem felter fortsat er nul. Legacy-rebalancering er fortsat inaktiv.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Evelyn liquidity proceeds
|
||||
|
||||
`com.r35157.evelyn.service.liquidity.EvelynLiquidityService` is a domain-only
|
||||
Nenjim component. `impl.ref.EvelynLiquidityServiceImpl` additionally implements
|
||||
`NenjimProcess`; NenjimHub owns startup. The optional process ID is
|
||||
`evelyn.liquidity.prod`.
|
||||
|
||||
The owner creates/funds EVE/SyrupUSDC CLMM positions manually (for example,
|
||||
10 EVE each) using the dedicated liquidity wallet. This service withdraws,
|
||||
converts and pays proceeds; it never creates, refills, reranges, closes or burns
|
||||
positions, NFTs or source token accounts. A falling price leaves nonqualifying
|
||||
positions intact, including positions whose inventory has returned to EVE.
|
||||
|
||||
## Deployment
|
||||
|
||||
Copy `conf/evelyn-liquidity.conf.example` to `conf/evelyn-liquidity.conf` and
|
||||
replace every placeholder. The local runtime file is intentionally ignored by
|
||||
Git. Both begin with `FORMAT_VERSION=1`; the example is never loaded as a
|
||||
fallback. Unknown, duplicate and missing settings fail validation.
|
||||
|
||||
| Setting | Contract |
|
||||
| --- | --- |
|
||||
| `LIQUIDITY_WALLET` | Dedicated public Solana wallet address |
|
||||
| `SIGNER_KEY_NAME` | Existing external jup key name controlling exactly that address |
|
||||
| `CLMM_POOL` | Existing EVE/SyrupUSDC Raydium CLMM pool address |
|
||||
| `HARVEST_DISTANCE_PERCENT` | `0 <= h < 100`; `10` means ten percent |
|
||||
| `ITERATION_INTERVAL_SECONDS` | Positive integer delay after an iteration completes; example `300` |
|
||||
| `USDC_RECIPIENT` | Recipient wallet owner address, not a token account; must differ from the liquidity wallet |
|
||||
| `MINIMUM_SYRUPUSDC_RESERVE` | Nonnegative SyrupUSDC amount; example `0` |
|
||||
| `MINIMUM_USDC_RESERVE` | Nonnegative USDC amount; example `0` |
|
||||
| `MAXIMUM_SWAP_SLIPPAGE_BPS` | Explicit Jupiter limit in `[0,10000]` basis points |
|
||||
| `MAXIMUM_WITHDRAWAL_SLIPPAGE_BPS` | Explicit CLMM limit in `[0,10000)` basis points |
|
||||
|
||||
The existing SolanaWallet implementation checks the external jup version and
|
||||
key/address binding during construction. No key material belongs in these files.
|
||||
Fund the wallet with SOL for fees and any destination token-account rent.
|
||||
Reserve amounts must be exactly representable at the corresponding mint's
|
||||
precision; `0.01` SyrupUSDC and `0.5` USDC are permitted operating choices.
|
||||
No hidden positive reserve is added.
|
||||
|
||||
Only after configuring and funding the wallet, explicitly add
|
||||
`evelyn.liquidity.prod` to the existing `[AUTO_START]` section of private
|
||||
`conf/NenjimHub.conf`. Keep Registry first and Journal second, with no blank line
|
||||
inside the active section. Preserve all existing process selections and their
|
||||
order. This process requires no Ticker, Evelyn status, JavaFX or burner process.
|
||||
The checked-in Hub example continues to select only Registry and Journal.
|
||||
|
||||
Deployment configuration and the dedicated signer/wallet, Jupiter and direct
|
||||
Raydium dependencies are constructed only when the selected entry is reached.
|
||||
Registry IDs are `solana.wallet.evelyn-liquidity-prod`,
|
||||
`jupiter.swap.evelyn-liquidity-prod`, `raydium.service.evelyn-liquidity-prod`
|
||||
and `evelyn.liquidity.prod`. Jupiter signs with this wallet. Currency/mint
|
||||
identities come from CIS; token programs and precision come from chain metadata.
|
||||
|
||||
## Iteration and recovery
|
||||
|
||||
A single worker runs immediately on startup and then with fixed delay after
|
||||
completion. Every iteration follows the same three independent steps:
|
||||
|
||||
1. Discover the wallet's position NFTs. For each owned, positive-liquidity
|
||||
position in the configured pool, read fresh on-chain pool state in
|
||||
SyrupUSDC per EVE after discovery and any earlier withdrawals. Withdraw complete
|
||||
liquidity when its normalized **upper** boundary satisfies
|
||||
`U <= P * (1 - h/100)`. Equality qualifies. Both token orientations use
|
||||
exact squared Q64.64 comparisons, without display-price rounding. At
|
||||
`P=15, h=10`, `13–13.5` qualifies and `13.5–14` does not. Withdrawals retain
|
||||
the position and NFT, and return pool-token fees to the same wallet.
|
||||
Each position uses its own fresh state for both eligibility and withdrawal
|
||||
minimums; the price is not reused across positions.
|
||||
2. Read current SyrupUSDC balance and swap the full balance above its reserve
|
||||
into USDC through the confirmed exact-input Jupiter operation.
|
||||
3. Read current USDC balance and send the full balance above its reserve to
|
||||
the recipient owner, creating its ATA at the sender's expense if needed.
|
||||
The transfer submits once and is awaited at `CONFIRMED`.
|
||||
|
||||
Missing/zero balances and balances equal to their reserves are skipped. Amounts
|
||||
are rounded down to mint precision. An unavailable or unusable pool price stops
|
||||
the remaining withdrawals in that iteration while retaining steps 2 and 3. EVE returned as principal/fees and
|
||||
other reward tokens remain in the wallet and never enter the conversion/payout
|
||||
pipeline. All available SyrupUSDC/USDC is eligible, including manual deposits.
|
||||
|
||||
An ordinary position failure does not block other readable positions. An
|
||||
ordinary step failure does not block later independent steps. Logs identify
|
||||
steps, position NFTs, liquidity/token amounts and returned signatures. Unknown
|
||||
outcomes are logged with signatures when available. Subsequent iterations and
|
||||
restarts reread chain state; there is no service journal, attribution, pending
|
||||
signature gate, immediate retry or transaction replay. Empty positions are
|
||||
skipped and can be manually reused. Shutdown interrupts the current operation
|
||||
and prevents following steps; the reference implementation can also be closed
|
||||
by its local owner through `AutoCloseable`.
|
||||
|
||||
## Withdrawal support
|
||||
|
||||
The transaction follows the official
|
||||
[decrease_liquidity_v2 accounts](https://github.com/raydium-io/raydium-clmm/blob/master/programs/amm/src/instructions/decrease_liquidity_v2.rs)
|
||||
and [decrease/minimum-output behavior](https://github.com/raydium-io/raydium-clmm/blob/master/programs/amm/src/instructions/decrease_liquidity.rs),
|
||||
also checked against the
|
||||
[official SDK encoder](https://github.com/raydium-io/raydium-sdk-V2/blob/master/src/raydium/clmm/instrument.ts).
|
||||
Withdrawal and closure are separate actions in
|
||||
[Raydium's liquidity documentation](https://github.com/raydium-io/raydium-docs-v1/blob/main/user-flows/add-remove-liquidity.mdx).
|
||||
|
||||
Raydium validates pool/personal-position discriminators, the owned NFT holding,
|
||||
current complete liquidity, mint precision, vaults and tick arrays before signing.
|
||||
For every pool or reward mint, existing owner token accounts are reused after
|
||||
validating their mint, owner, token program and initialized state, including
|
||||
empty accounts and accounts outside the owner's ATA. Only when no owner holding
|
||||
exists does the withdrawal use or idempotently create its ATA. A failed holdings
|
||||
read aborts rather than creating an additional account. This preserves the
|
||||
existing wallet's single-account-per-mint balance/transfer contract. The
|
||||
transaction requests a 600,000 compute-unit limit.
|
||||
The bitmap extension and initialized reward triplets are included when required.
|
||||
The integer principal math matches Raydium's
|
||||
[tick algorithm](https://github.com/raydium-io/raydium-clmm/blob/master/programs/amm/src/libraries/tick_math.rs).
|
||||
Minimum principal amounts are floored after applying explicit withdrawal
|
||||
slippage. An amount too small to protect at least one raw unit is rejected.
|
||||
No withdrawal always substitutes zero for both minimums.
|
||||
Price movement after the per-position read remains possible before execution;
|
||||
principal minimums protect received amounts, not the harvest-distance threshold.
|
||||
|
||||
The current EVE mint uses Token-2022 metadata pointer/token metadata extensions;
|
||||
SyrupUSDC uses legacy SPL. Read-only mainnet inspection on 2026-10-04 found the
|
||||
EVE/SyrupUSDC pool `EpZXeeShz64DK8HfKnjWPTkmhN7dZe77SjvzNDX8CARq` with decimals
|
||||
8/6, tick spacing 60 and no initialized rewards. Deployment still requires an
|
||||
explicit pool setting and repeats validation. Mint close-authority and metadata/
|
||||
group extensions are supported; other transfer-affecting extensions, including
|
||||
transfer fees/hooks, fail before signing. The withdrawal encoder supports legacy
|
||||
transactions fitting Solana's packet limit and rejects larger account sets.
|
||||
|
||||
## Offline verification
|
||||
|
||||
The Gradle JUnit fixtures exercise exact threshold equality, inversion, raw
|
||||
principal amounts, owner/pool filtering, reserves, independent failures,
|
||||
restart recovery and shutdown. Captured serialized transactions verify account
|
||||
order, full liquidity, minimum output, NFT retention, bitmap/reward accounts and
|
||||
one submission/confirmation attempt. Review regression fixtures also cover price
|
||||
changes during discovery or earlier withdrawals in both token orientations,
|
||||
quote consistency, and reuse/validation of existing pool and reward token accounts
|
||||
under both token programs. All financial dependencies are controlled;
|
||||
no verification submits live withdrawals, swaps or transfers.
|
||||
|
||||
```sh
|
||||
./gradlew compileJava compileTestJava test \
|
||||
--tests '*RaydiumClmm*Test' --tests '*EvelynLiquidityServiceImplTest' \
|
||||
--tests '*NenjimHubLiquidityConfigurationTest'
|
||||
openspec validate 89-add-evelyn-liquidity-service --strict
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Stop the process/JVM and remove its explicit Hub selection to disable operation.
|
||||
Existing positions and wallet balances remain the recovery state.
|
||||
+9
-2
@@ -268,14 +268,21 @@ nenjim.registry.service
|
||||
nenjim.journal.service
|
||||
assetaz.ticker.default
|
||||
jupiter-perps-alarm.default
|
||||
evelyn.service.prod
|
||||
evelyn.service.test
|
||||
evelyn.status.prod
|
||||
evelyn.status.test
|
||||
evelyn.iou-burner.prod
|
||||
nenjim.javafx-runtime.service
|
||||
evelyn.mission-control.default
|
||||
nenjim.process-manager.default
|
||||
```
|
||||
|
||||
Ved opgradering erstattes `evelyn.service.prod` med `evelyn.status.prod` og
|
||||
`evelyn.service.test` med `evelyn.status.test` i deploymentets private `conf/NenjimHub.conf`, hvor de forekommer.
|
||||
Bevar rækkefølge og aktive/udkommenterede valg. De gamle ID'er accepteres ikke længere. Hub'en konstruerer samme
|
||||
navngivne `Production`- og `Test`-instanser gennem `EvelynStatusServiceImpl` og injicerer deres
|
||||
`EvelynStatusService`-views i Mission Control. Process Manager viser de nye ID'er fra Hub'ens startup-snapshot.
|
||||
Historik ligger fortsat i `data/evelyn/<instance-name>/status.log` med formatversion 1 og kræver ingen migration.
|
||||
|
||||
Efter validering konstruerer og starter Hub'en Registry, registrerer det som `nenjim.registry.service` og registrerer
|
||||
startup-milepælen. Derefter konstrueres og registreres hele den nuværende passive graph uden at starte workers eller
|
||||
processer. Først herefter behandles resten af `[AUTO_START]` i den angivne rækkefølge, begyndende med Journal. Hver
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
| JavaFxRuntimeService | Registrerbart service-view med kun non-null `runLater(Runnable)`. Samme referenceimplementation er en Hub-styret `NenjimProcess`; service-consumers får den gennem constructor injection, men får ingen lifecycle-, status-, configuration-, window-, event-, stop- eller restart-operationer. |
|
||||
| TickerService | Lifecycle-frit domain/query-view med kun latest-price lookup. Samme `TickerServiceImpl`-objekt er separat en Hub-styret `NenjimProcess`; det ejer `PriceSource`-startup internt og stopper allerede startede sources som rollback, hvis Ticker-start fejler, men eksponerer ingen almindelig start- eller stop-operation. |
|
||||
| EvelynIOUBurnerService | Tomt, lifecycle-frit domain-service-view, som bevarer typed Registry- og dependency-identitet, indtil eksterne domain-operationer findes. Samme `EvelynIOUBurnerServiceImpl`-objekt er separat en Hub-styret `NenjimProcess`; service-consumers får ingen start-, stop- eller restart-operationer. |
|
||||
| EvelynStatusService | Service for én navngiven Evelyn-instans, som indsamler og persisterer statushistorik og udleverer den beregnede EVE-referencepris med eksplicit USDT-quote. Den bevarer sit eksisterende start/stop/close-contract; samme `EvelynStatusServiceImpl`-objekt startes af Hub'en som `NenjimProcess` under `evelyn.status.prod` eller `evelyn.status.test`. Evelyn er fortsat navnet på det samlede system. |
|
||||
| Context | Fremtidig beskrivelse af ønskede/resolverede artifactvalg og isoleret runtime; ikke implementeret af Journal-laget eller første Registry-version. |
|
||||
| Resolver | Fremtidig komponent for dependency graph og release-selection; Journal-laget repræsenterer reglerne, men vælger ikke en graf. |
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-09
|
||||
@@ -0,0 +1,32 @@
|
||||
## Context
|
||||
|
||||
See proposal.md. The authoritative baseline is Gitea 0.1-dev at f1dd5ed. Nenjim separates public component APIs from impl.ref processes; the Hub owns explicit construction and registry registration. The artifact JournalService is unrelated.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
Implement the two capability contracts. Keep primitive PDE parsing below event codecs and record integrity. Do not extend the v1 scope in the proposal.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Place events in com.r35157.assetaz.events, service API in services.journal, codecs under services.journal.codec and implementations under impl.ref. PDE belongs to com.r35157.libs.pde. Codec components expose read identity, domain type and whether they encode current writes; historical read-only codecs can coexist without implicit highest-version selection.
|
||||
- The Hub registers explicit codec instances and offers assetaz.journal.service through its process constructor catalogue. The journal resolves the registry catalogue at startup, validates duplicate identities/current encoders, and uses a single lifetime file channel and exclusive file lock. No service restart API is added.
|
||||
- One journal monitor serializes durable append, registration and queue publication. Each subscription has its own monitor, FIFO and worker. Every publication carries a sequence, including irrelevant records, so workers advance only after examining the ordered stream. Consumer code never runs under the journal monitor. An empty queue observed under the subscription monitor selects the ONLINE cutover; subsequent publications queue behind it while the same worker calls ONLINE.
|
||||
- Historical readers scan from the beginning to validate chain state but invoke callbacks only from replayFromSequence through captured registrationHead. This avoids adding indexes or snapshots. The reader closes before pending live records drain.
|
||||
- PDE uses the published little-endian integer/length type table; UUID conversion alone is big endian at codec level. Record hashes operate on original bounded ranges, not canonicalized representations.
|
||||
- JournalFormatVersionChanged has common event fields followed by a target version. Only format 1 is implemented; unsupported targets fail before append and during loading. Future parsers can switch after the ordinary event without a new physical record type.
|
||||
- Barriers are interruptible (InterruptedException alongside DegradedException); no timeout or recovery API is introduced.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- Slow consumers can accumulate FIFO entries → intentional independent v1 workers; no unrequested tuning or overflow policy.
|
||||
- Callbacks cannot be rolled back by the journal → document consumer atomic update responsibility and preserve safe cursor on failure.
|
||||
- Uncertain force/write can leave a complete or partial record → permanently fail closed and validate the authoritative file next JVM; no automatic repair.
|
||||
- Hash chains do not prevent a writer recomputing history → external anchors remain out of scope.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
New optional process, using data/assetaz/journal/events.pde. No migration of the Nenjim artifact journal or existing service histories. Add the process ID to the example configuration; existing user configurations remain opt-in. No commits or publication in this task.
|
||||
|
||||
## ValueTags
|
||||
|
||||
The owner name uses the existing Name tag (String). No shared Detag definitions are changed. Proposed future tags, pending separate approval, are AssetAZJournalSequence (parent long, Java long) for authoritative cursors, AssetAZEventType (parent String, Java String) for persistent short identifiers, AssetAZEventVersion and AssetAZJournalFormatVersion (parent int, Java int) to distinguish schema and envelope versions, and AssetOwnerId and AssetAZCorrelationId (parent UUID, Java UUID) to distinguish owner identity from workflow grouping. This implementation keeps the issue's primitive/UUID signatures where these tags do not yet exist.
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
AssetAZ needs an authoritative durable event history from which services can reconstruct their models and establish causal freshness. Gitea issue #86 defines a deliberately strict first version.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add an append-only PDE journal with SHA-256 integrity, durable sequence commitment and fail-closed writes.
|
||||
- Add domain events and registry-provided positional event codecs, independent of Java class naming.
|
||||
- Add independent virtual-thread subscriptions, replay, exact filtering, progress barriers and isolated degradation.
|
||||
- Add reusable bounded PDE primitive encoding and decoding.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `assetaz-journal`: Persistence, event schemas, replay and consumer consistency contracts.
|
||||
- `pde-encoding`: Minimal bounded PDE primitives and object framing.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
None.
|
||||
|
||||
## Impact
|
||||
|
||||
New AssetAZ API and reference implementation packages, a reusable PDE library, and explicit codec/process entries in the existing Hub composition root. No new dependencies. The Nenjim artifact journal is separate and unchanged.
|
||||
|
||||
## Non-goals
|
||||
|
||||
No snapshots, repair, truncation, retries, consumer recovery, unregister, restart support, adaptive limits, pools, generalized serialization, reflection discovery, migration, PDL, signatures, causation IDs or unrelated thread changes.
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
## Purpose
|
||||
|
||||
Define the authoritative AssetAZ fact journal and independent consumer reconstruction and causal freshness contracts.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Durable authoritative ordering
|
||||
The journal SHALL be append-only and serialize appends, assigning contiguous sequences from 1 independently of occurredAt. Fields 2..6 SHALL be encoded once, hashed, and copied into the final record. Only successful FileChannel.force(true) SHALL commit the candidate head, publish and return its sequence. Missing append codecs and oversized records MUST fail before any append. An uncertain write SHALL permanently fail closed in that JVM without retries, repair or truncation.
|
||||
|
||||
#### Scenario: Failed force
|
||||
- **WHEN** persistence may have left an uncertain tail
|
||||
- **THEN** the candidate is not committed or published and all later appends fail.
|
||||
|
||||
### Requirement: Record integrity and bounded readers
|
||||
Each record SHALL be exactly one top-level PDE OBJECT containing checksum, previousChecksum, sequence, stable short eventType, eventVersion and OBJECT payload in that order. Both hashes SHALL be 32 raw bytes. SHA-256 SHALL cover the original persisted fields 2..6; the first previousChecksum SHALL be SHA-256(empty). Each independent reader SHALL reuse one fixed 1024-byte buffer. The entire record including header MUST be at most 1024 bytes; reading SHALL reject declared excess or truncation immediately after framing and before payload decoding. Startup SHALL validate the entire history, checksums, chain, contiguous sequences, PDE, known codecs and exact schemas before succeeding.
|
||||
|
||||
#### Scenario: Corrupt history
|
||||
- **WHEN** a checksum, chain, sequence, schema, type/version or framing cannot be verified
|
||||
- **THEN** startup fails with the rejection layer identified; no record is skipped.
|
||||
|
||||
### Requirement: Event and codec contract
|
||||
Events SHALL represent facts, not commands. occurredAt SHALL be semantic time; note and correlationId are nullable. Correlation groups independently meaningful workflow events without atomicity or expected count; inseparable changes belong in one event. Codecs SHALL be registry components resolved by stable eventType and persistent version, never constructed in the journal. Domain class names MUST NOT encode persistent versions. Journal integrity, PDE validity and event schema validity SHALL remain separate layers.
|
||||
|
||||
#### Scenario: Historical codec
|
||||
- **WHEN** a known historical persistent version is read
|
||||
- **THEN** its codec yields the current domain event without changing the domain type name.
|
||||
|
||||
### Requirement: Owner payload
|
||||
AssetOwnerRegistered version 1 SHALL contain exactly seven positional values: signed long epoch seconds, nano-of-second, nullable UTF-8 note, nullable UUID correlationId, required UUID assetOwnerId, required textual assetOwnerType, required UTF-8 name. UUIDs SHALL be exactly 16 bytes, most-significant then least-significant long, both big endian. Nanos MUST be 0..999999999; owner type MUST be NATURAL_PERSON or LEGAL_PERSON, never ordinal. Required values, Instant range and exact field count SHALL be validated by the codec. No additional owner metadata SHALL be added.
|
||||
|
||||
#### Scenario: Extra payload value
|
||||
- **WHEN** an otherwise valid owner payload has an eighth value
|
||||
- **THEN** schema validation rejects it.
|
||||
|
||||
### Requirement: Format changes
|
||||
New journals SHALL implicitly start in format 1 with no header. JournalFormatVersionChanged SHALL be an ordinary event in the same envelope and hash chain, with a positive target version and common event fields. A change applies to subsequent records; returning to an earlier supported version SHALL be representable. Unsupported formats MUST fail closed; v1 implements only format 1.
|
||||
|
||||
#### Scenario: Chained format event
|
||||
- **WHEN** a format-1 change to format 1 is appended
|
||||
- **THEN** it is checksummed and replayed normally; unsupported target formats are rejected before append.
|
||||
|
||||
### Requirement: Registration and independent replay
|
||||
Registration SHALL atomically capture registrationHead, validate 1 <= replayFromSequence <= registrationHead + 1, defensively copy a non-null filter without null members and reject duplicate consumer identity (==). Empty filters SHALL mean all; other filters SHALL match exact runtime class only. Registration SHALL return without awaiting replay. Each subscription SHALL own a virtual-thread worker and independent reader, replay through registrationHead, close the reader and drain subsequent FIFO publications. Degraded registrations remain registered. registrationHead MUST NOT be public API.
|
||||
|
||||
#### Scenario: Late subscriber
|
||||
- **WHEN** another consumer registers while an existing one processes events
|
||||
- **THEN** only the new consumer replays; both receive their own ordered stream without gaps or duplicates.
|
||||
|
||||
### Requirement: Progress and barriers
|
||||
processedThrough SHALL mean all records through the cursor were successfully handled or examined as irrelevant, starting at replayFromSequence - 1. A barrier SHALL return whenever the safe cursor meets its minimum, including DEGRADED; otherwise wait while CATCHING_UP/ONLINE or throw DegradedException when DEGRADED. Progress and degradation SHALL wake waiters. The barrier MUST NOT imply a historical snapshot.
|
||||
|
||||
#### Scenario: Filtered progress
|
||||
- **WHEN** a consumer last handled 1000 and examined irrelevant records through 1100
|
||||
- **THEN** its cursor is 1100 and a barrier for 1100 succeeds.
|
||||
|
||||
### Requirement: States and atomic cutover
|
||||
Subscriptions SHALL begin CATCHING_UP, without requiring an initial state callback. After replay and pending events through an atomic cutover, the worker SHALL request ONLINE. Successful return establishes readiness to act; the callback should establish readiness without irreversible actions. Publication concurrent with cutover SHALL be processed before ONLINE or as the first normal event after ONLINE, with no gap. All callbacks for one consumer SHALL be sequential.
|
||||
|
||||
#### Scenario: Append during readiness callback
|
||||
- **WHEN** a new event arrives at the chosen cutover
|
||||
- **THEN** it is queued and processed next after readiness succeeds.
|
||||
|
||||
### Requirement: Isolated degradation and atomic models
|
||||
Callback or ONLINE transition failure SHALL retain the last safe cursor, mark only that subscription DEGRADED, stop delivery, wake barriers, and log the exception plus consumer identity, sequence and state. Consumers SHALL atomically apply each event or leave their externally visible model unchanged. Replay SHALL rebuild state without repeating trades, transfers or notifications. No OFFLINE, unregister or recovery SHALL be implemented.
|
||||
|
||||
#### Scenario: Consumer throws
|
||||
- **WHEN** one subscriber fails applying sequence N
|
||||
- **THEN** it retains its safe cursor, future barriers beyond that cursor fail, and other subscribers continue.
|
||||
@@ -0,0 +1,26 @@
|
||||
## Purpose
|
||||
|
||||
Provide reusable primitive binary encoding and bounded structural decoding for positional AssetAZ journal data.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Primitive representation
|
||||
The PDE layer SHALL implement Jenkov PDE integers, UTF-8, bytes, typed null and length-delimited OBJECT framing only. Integer magnitudes and lengths use little endian; negative integers encode -(value + 1). It MUST reject wrong types, malformed lengths, invalid UTF-8 and values outside the requested Java int/long range.
|
||||
|
||||
#### Scenario: Integer extremes
|
||||
- **WHEN** signed long extrema and nullable strings/bytes are encoded
|
||||
- **THEN** they round-trip without loss and null is distinguished from empty.
|
||||
|
||||
### Requirement: Bounded decoding
|
||||
Every decoder SHALL operate on byte[], offset and length; child objects SHALL have independent logical bounds. requireEnd SHALL reject unconsumed values using the logical limit, never backing-array size. Framing SHALL expose total object size before payload decoding.
|
||||
|
||||
#### Scenario: Reused backing storage
|
||||
- **WHEN** a short record follows a long record in one buffer
|
||||
- **THEN** stale bytes are invisible and truncated children are rejected.
|
||||
|
||||
### Requirement: Abstraction boundary
|
||||
PDE SHALL know nothing about UUID, Instant, events, sequence numbers or checksums. It MUST NOT implement object mapping, reflection, annotation schemas or PDL.
|
||||
|
||||
#### Scenario: Domain payload decoding
|
||||
- **WHEN** a UUID byte value is decoded
|
||||
- **THEN** PDE validates its binary framing while the codec validates its 16-byte schema.
|
||||
@@ -0,0 +1,23 @@
|
||||
## 1. Encoding and contracts
|
||||
|
||||
- [x] 1.1 Implement bounded PDE primitives and framing.
|
||||
- [x] 1.2 Add event/service contracts and registry codecs with exact schemas.
|
||||
|
||||
## 2. Journal and consumers
|
||||
|
||||
- [x] 2.1 Implement record hashing, bounded readers, startup validation and durable fail-closed append.
|
||||
- [x] 2.2 Implement independent virtual-thread replay, FIFO delivery, cutover and barriers.
|
||||
- [x] 2.3 Wire passive codecs and journal process in the Hub.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] 3.1 Run build and existing tests; verify issue invariants and diagnostics.
|
||||
- [x] 3.2 Validate OpenSpec and review unstaged changes against scope.
|
||||
|
||||
## Validation results
|
||||
|
||||
- Normal Gradle build passed, including all four existing tests (zero failures/errors/skips).
|
||||
- 78 temporary checks outside the repository passed: PDE ranges and bounds, exact schemas, raw hashes, full startup validation, 1024/1025-byte boundary, replay/live ordering, filtering, safe cursors, barriers, isolated degradation and uncertain force failure before publication.
|
||||
- OpenSpec strict validation and git diff whitespace checks passed. No repository tests were added, following project policy.
|
||||
- The fresh checkout omitted ignored conf/detag.conf; build validation used a local symlink to the existing configuration without changing its definitions, and a workspace-local Gradle cache.
|
||||
- All changes remain unstaged and uncommitted on the fetched Gitea baseline.
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-04
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
See `proposal.md` for motivation. Status sampling already calls a package-private `EvelynPriceIndexCalculator`, which holds the curve constants and decimal arithmetic. The implementation receives its canonical EVE/USDT pair from the Hub and validates storage only at startup. Constructor name reservations, file restoration and durable append behavior must survive the package move.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** Keep one calculation behind both the public reference-price operation and status sampling; preserve pure on-demand reads and separate Production/Test state while making their status-service identity explicit.
|
||||
|
||||
**Non-Goals:** No additional reference-data service, new ValueTags, curve configuration, lifecycle redesign, package-wide cleanup, trading activation, or storage provisioning/migration.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. Move only the API, implementation and calculator. Import `EvelynStatusIndexPoint` from its existing package. Retain the implementation's existing constructor shapes and lifecycle methods; a compatibility wrapper would leave two competing names and is unnecessary for the requested breaking rename.
|
||||
2. Implement `getReferencePrice(timestamp)` as a direct call to the calculator's existing expected-price method, wrapped in `AssetPrice` with the constructor-injected trading pair. Sampling continues to call `calculateIndex(actualPrice, samplingInstant)`, which delegates to that same expected-price method. Sharing the helper preserves identical constants and arithmetic without allocating a second service or changing index calculation.
|
||||
3. Give the public operation no lifecycle gate, synchronization, clock read, ticker lookup or storage access. The immutable trading pair and pure calculator suffice before startup and during stop. Null and pre-start rejection are inherited from the calculator.
|
||||
4. Rename Hub factory references, dependency fields, constants and process IDs together, and update Mission Control's parameter/field types. Process Manager already displays IDs from Hub snapshots and needs no new hardcoded mapping. Keep instance names `Production` and `Test` and the `data/evelyn` root.
|
||||
5. Document the private configuration substitutions without editing the deployment configuration. The versioned example currently selects only Registry and Journal, so its selected processes stay unchanged.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Consumers using old packages or IDs fail] → Update all versioned consumers and document exact deployment substitutions; intentionally provide no alias.
|
||||
- [Fractional exponentiation uses floating-point transcendental arithmetic] → Preserve the existing calculator unchanged and check the start, annual and representative fractional values.
|
||||
- [Verification could trigger operational side effects] → Compile normally and use offline deterministic checks with fake Ticker and temporary storage; never run the Hub or financial processes.
|
||||
- [No existing status/calculator verification source is present in this checkout] → Add only the deterministic acceptance checks explicitly required by #88, covering the public API and regression behavior rather than the rename itself.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Deploy the renamed code with `evelyn.service.prod` replaced by `evelyn.status.prod` and `evelyn.service.test` by `evelyn.status.test` wherever present in private `conf/NenjimHub.conf`. Preserve line order and enabled/commented selection. Retain existing status files byte-for-byte; the service reads the same paths and format. Rollback restores the old code and old IDs without a data migration.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
The current `Evelyn` status-history service is named as if it owns the entire Evelyn system, and its existing reference-price calculation is unavailable to other services. Issue [#88](https://git.r35157.com/r35157/com_r35157_nenjim-hubd-impl_ref/issues/88) gives Evelyn consumers one authoritative reference price through the existing status service.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING**: Move and rename the public API to `com.r35157.evelyn.service.status.EvelynStatusService` and its implementation to `com.r35157.evelyn.service.status.impl.ref.EvelynStatusServiceImpl`; move the package-private calculator alongside it.
|
||||
- Add `AssetPrice getReferencePrice(Instant timestamp)`, calculating on demand through the existing curve and returning the service's canonical EVE/USDT trading pair independently of sampling and storage.
|
||||
- **BREAKING**: Replace `evelyn.service.prod` / `evelyn.service.test` with `evelyn.status.prod` / `evelyn.status.test` in Hub wiring and recognized process IDs. Document the corresponding private deployment configuration edit.
|
||||
- Preserve named Production/Test storage, format/version, history, sampling, lifecycle, Mission Control charts, and inactive legacy rebalancing.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `evelyn-status-index-history`: Identify the status-service API and expose the shared reference-price calculation while preserving history and lifecycle behavior.
|
||||
- `nenjim-hub-configuration`: Recognize the renamed status process IDs and preserve the configured selection and dependency order.
|
||||
- `nenjim-component-registry`: Use the renamed IDs in the canonical component-ID contract.
|
||||
|
||||
## Impact
|
||||
|
||||
Affected source is limited to the status API, implementation, calculator, Hub composition, Mission Control's typed dependencies, and directly related documentation/specifications and verification. Other Evelyn packages and `EvelynStatusIndexPoint` stay in place. Existing deployments must replace only the two old IDs where present in their private `NenjimHub.conf`, retaining enabled/commented selections and order. No history migration, dependency addition, live financial operation, or commit is required.
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Evelyn calculates the live price index
|
||||
`EvelynStatusService` SHALL obtain the actual EVE/USDT price from the latest AssetAZ Ticker observation for the canonical EVE/USDT trading pair. It SHALL treat USDT as USD for this calculation and SHALL NOT access Raydium directly. At sampling instant t, on or after 2026-07-31T22:00:00Z, the reference price SHALL be 15 × 1.20^(elapsedSeconds / 31,536,000) and the Evelyn Price Index SHALL be ((actualPrice / referencePrice) - 1) × 10, calculated with adequate decimal precision and without arbitrary history rounding. Sampling and the public reference-price operation SHALL use the same reference calculation and constants at the same sampling instant.
|
||||
|
||||
#### Scenario: Calculate expected price at the configured start
|
||||
- **WHEN** the sampling instant is 2026-07-31T22:00:00Z
|
||||
- **THEN** the expected EVE price is exactly 15 USD
|
||||
|
||||
#### Scenario: Calculate expected price after one year
|
||||
- **WHEN** exactly 31,536,000 seconds have elapsed since the configured start
|
||||
- **THEN** the expected EVE price is exactly 18 USD
|
||||
|
||||
#### Scenario: Calculate representative fractional-year growth
|
||||
- **WHEN** 18 days, 3 hours, 8 minutes, and 14 seconds have elapsed
|
||||
- **THEN** the expected EVE price is approximately 15.136464436276 USD
|
||||
|
||||
#### Scenario: Calculate index from actual price
|
||||
- **WHEN** actual price equals expected price, is 10 percent above, is 10 percent below, is double, or is half the expected price
|
||||
- **THEN** the respective index is 0, 1, -1, 10, or -5
|
||||
|
||||
### Requirement: Evelyn instances have safe unique persistent names
|
||||
Every `EvelynStatusService` instance SHALL have an NFC-normalized Unicode name whose preserved spelling and capitalization selects the direct child directory data/evelyn/<name>. A name SHALL be rejected if it is empty or whitespace-only, has leading or trailing whitespace, is . or .., ends with a period, contains a control character or any of < > : " / \ | ? *, is a case-insensitive Windows-reserved filename including a reserved base with an extension, or normalizes as a path to anything other than one direct child of data/evelyn. Within one JVM, normalized names SHALL be reserved atomically and compared case-insensitively using locale-independent rules.
|
||||
|
||||
#### Scenario: A portable Unicode name is accepted
|
||||
- **WHEN** an instance is constructed with a valid Unicode name containing Danish characters
|
||||
- **THEN** NFC normalization is applied and the normalized spelling and capitalization identify its direct child data directory
|
||||
|
||||
#### Scenario: An unsafe name is rejected without reservation
|
||||
- **WHEN** construction receives an invalid name or any other invalid constructor argument
|
||||
- **THEN** construction fails before reserving a persistent identity
|
||||
|
||||
#### Scenario: Concurrent equivalent names conflict
|
||||
- **WHEN** two constructor calls in one JVM concurrently request names that are equal after NFC normalization and locale-independent case folding
|
||||
- **THEN** exactly one reserves the persistent identity and the other fails
|
||||
|
||||
### Requirement: Evelyn distinguishes restartable stop from permanent close
|
||||
Stopping `EvelynStatusService` SHALL terminate sampling, clear memory, preserve its persistent file, and retain its name reservation so the same object can restart. `EvelynStatusService` SHALL support permanent, idempotent close; closing an active instance SHALL first stop it safely, a successfully closed instance SHALL never start again, and its name SHALL be released only after its sampling thread has definitely terminated. A termination failure SHALL be reported and SHALL retain the reservation.
|
||||
|
||||
#### Scenario: A stopped instance restarts
|
||||
- **WHEN** a stopped but not closed `EvelynStatusService` instance is started again
|
||||
- **THEN** it retains exclusive ownership of the same name and reloads the complete persisted history before sampling
|
||||
|
||||
#### Scenario: An active instance closes successfully
|
||||
- **WHEN** close is called on an active instance and its sampling thread terminates
|
||||
- **THEN** memory is cleared, the name reservation is released, and later start calls fail
|
||||
|
||||
#### Scenario: Close cannot confirm termination
|
||||
- **WHEN** close cannot confirm that the sampling thread terminated
|
||||
- **THEN** close reports failure and retains the name reservation
|
||||
|
||||
#### Scenario: Close is repeated
|
||||
- **WHEN** close is called after the instance was successfully closed
|
||||
- **THEN** it completes without changing state or failing
|
||||
|
||||
### Requirement: Evelyn uses externally provisioned per-instance storage
|
||||
Each named instance SHALL use the existing regular file data/evelyn/<normalized-name>/status.log. Before every start, `EvelynStatusService` SHALL require the direct instance path to be an existing readable and writable directory and status.log to be an existing readable and writable regular file. `EvelynStatusService` SHALL NOT create, replace, repair, truncate, rename, or otherwise provision either path. Any validation or actual open failure SHALL identify the affected path, leave memory unpublished, and prevent sampling from starting.
|
||||
|
||||
#### Scenario: Required storage is absent or unsuitable
|
||||
- **WHEN** the instance directory or status file is missing, the path has the wrong type, or required access is unavailable
|
||||
- **THEN** start fails with the affected path and reason without modifying storage, publishing history, or starting sampling
|
||||
|
||||
#### Scenario: Storage changes while stopped
|
||||
- **WHEN** storage becomes invalid after stop and the same instance is started again
|
||||
- **THEN** the complete startup validation is repeated and restart fails without modifying storage
|
||||
|
||||
### Requirement: Evelyn validates status history format strictly
|
||||
The UTF-8 status.log format SHALL have supported version 1, declared by FORMAT_VERSION=1 as the first actual entry; blank lines and full-line comments MAY precede it when loading, while an externally provisioned new file SHALL place it on the physical first line. Missing, duplicate, malformed, misplaced, or unsupported declarations SHALL fail loading before record parsing. After the declaration, blank lines and comments SHALL be ignored, inline comments MAY follow data, and every other entry SHALL be a valid record with an explicit stable numeric type code.
|
||||
|
||||
Type 1 SHALL encode <UTC uuuuMMddHHmmssSSS'Z' timestamp>:1:<plain BigDecimal Evelyn Price Index>. It SHALL have exactly three fields. Unknown types, invalid timestamps or decimals, missing or surplus fields, and decreasing timestamps SHALL fail the complete load; equal timestamps SHALL be accepted. A valid declaration with no records SHALL represent empty history. Applicable failures SHALL identify the file path, one-based line number, and original line, and `EvelynStatusService` SHALL neither skip, sort, partially publish, nor repair invalid content.
|
||||
|
||||
#### Scenario: A valid versioned history loads
|
||||
- **WHEN** the file has a valid first actual version declaration and valid type-1 records in non-decreasing timestamp order
|
||||
- **THEN** the status service restores every record in file order with zero values for the five unimplemented indexes
|
||||
|
||||
#### Scenario: The format declaration is invalid
|
||||
- **WHEN** the declaration is missing, duplicated, malformed, misplaced, or not version 1
|
||||
- **THEN** startup fails before parsing records and reports useful file diagnostics
|
||||
|
||||
#### Scenario: A record is invalid
|
||||
- **WHEN** a record has an unknown type, malformed field, wrong field count, or timestamp earlier than its predecessor
|
||||
- **THEN** startup fails with path, line number, and original line without exposing partial history or altering the file
|
||||
|
||||
### Requirement: Evelyn samples without overlapping or catch-up attempts
|
||||
Each started `EvelynStatusService` instance SHALL own its sampling executor, attempt its first sample only after complete storage validation and history restoration, and make subsequent attempts approximately one minute after the preceding attempt completes. Sampling SHALL remain active for the status-service lifecycle independently of whether Mission Control is open. `EvelynStatusService` SHALL add exactly one timestamped point per successful attempt and SHALL isolate a failed or temporarily unavailable Ticker lookup or persistence operation without adding a fake or unpersisted point, terminating the status service, or cancelling the next attempt. Stopping `EvelynStatusService` SHALL terminate its sampling executor.
|
||||
|
||||
#### Scenario: Initial Ticker data is unavailable
|
||||
- **WHEN** the immediate sampling attempt cannot obtain a latest EVE/USDT price
|
||||
- **THEN** no point is persisted or published, diagnostic context is logged, and the next fixed-delay attempt remains scheduled
|
||||
|
||||
#### Scenario: A later sample succeeds
|
||||
- **WHEN** a later attempt obtains a latest EVE/USDT price whose millisecond timestamp is not earlier than the last persisted timestamp
|
||||
- **THEN** exactly one type-1 record is durably appended before the corresponding point becomes visible in memory
|
||||
|
||||
#### Scenario: Persistence fails for one sample
|
||||
- **WHEN** appending or durably flushing a calculated point fails
|
||||
- **THEN** the point is not published, instance and path context are logged, and the next fixed-delay attempt remains scheduled
|
||||
|
||||
#### Scenario: A sampled timestamp goes backwards
|
||||
- **WHEN** a newly sampled timestamp is earlier than the last persisted timestamp
|
||||
- **THEN** no record or point is added, the failure is logged, and later attempts remain scheduled
|
||||
|
||||
### Requirement: Evelyn provides historical status-index measurements
|
||||
Each named `EvelynStatusService` instance SHALL publish a safe, non-null, oldest-to-newest snapshot only after its complete persisted history has been validated and loaded. Each type-1 point SHALL contain its persisted or sampled timestamp, the Evelyn Price Index, and BigDecimal.ZERO placeholders for EVE_SYRUP Pool Depth Index, EVE_SYRUP Pool Balance Index, AAZDKK_USDT Pool Balance Index, AAZDKK_USDT Pool Price Index, and AAZDKK_USDT Pool Depth Index. Production and Test SHALL use independently named files, history collections, reservations, and lifecycles. A sampled point SHALL be durably appended using robust line-boundary handling before publication. Stop SHALL clear only memory; restart SHALL restore the complete file, and close SHALL preserve the file.
|
||||
|
||||
#### Scenario: Status-index history is requested during sampling
|
||||
- **WHEN** a caller requests a status-service instance's status-index history while persisted points may be appended
|
||||
- **THEN** it receives a safe snapshot containing only completely persisted points and all six typed fields
|
||||
|
||||
#### Scenario: A process starts two named Evelyn environments
|
||||
- **WHEN** Production and Test `EvelynStatusService` instances start with independently provisioned files
|
||||
- **THEN** each restores and extends only its own complete history even if calculated values are identical
|
||||
|
||||
#### Scenario: Existing file lacks a final line separator
|
||||
- **WHEN** a valid existing status file ends with a record but no line separator
|
||||
- **THEN** the next durable append first supplies a line boundary and preserves both records as distinct entries
|
||||
|
||||
#### Scenario: An Evelyn instance stops and restarts
|
||||
- **WHEN** a named instance is stopped and later restarted
|
||||
- **THEN** its memory is cleared at stop and its complete unchanged persistent history is revalidated and restored before sampling resumes
|
||||
|
||||
### Requirement: Evelyn Mission Control visualizes status-index measurements
|
||||
Evelyn Mission Control SHALL display separate live Production and Test Overview charts exclusively from their corresponding authoritative `EvelynStatusService` snapshots and SHALL never read or manage persistence directly. Each chart SHALL contain one enabled series named Evelyn Price Index; the five future series and their typed fields SHALL remain available as disabled source scaffolding. Opening or reopening EMC SHALL immediately reconstruct each chart from the complete restored current snapshot before periodically refreshing it. Successful points SHALL be added to chart objects only on the JavaFX Application Thread while the window remains open. Timestamp bounds SHALL expand as points arrive, and only enabled Evelyn Price Index values SHALL determine a dynamic Y-axis range that is symmetric around and always displays zero, using a reasonable default range for empty or all-zero history. Hiding or closing EMC SHALL release only EMC's JavaFX refresh resources and SHALL NOT start, stop, close, persist, or clear either status-service instance.
|
||||
|
||||
#### Scenario: Production and Test Overviews show restored history
|
||||
- **WHEN** Mission Control opens after two named `EvelynStatusService` instances have restored their histories
|
||||
- **THEN** it displays separate one-series charts immediately populated from the corresponding complete snapshots
|
||||
|
||||
#### Scenario: A successful point appears while EMC is open
|
||||
- **WHEN** a status-service history publishes a newly persisted point
|
||||
- **THEN** the corresponding chart adds it on the JavaFX Application Thread and updates its timestamp and symmetric price-index bounds without reopening EMC
|
||||
|
||||
#### Scenario: EMC reopens after collecting hidden-window samples
|
||||
- **WHEN** the same EMC instance is reopened after the status services persisted additional points while its window was hidden
|
||||
- **THEN** each chart is reconstructed immediately from its environment's complete current history without missing points because of stale rendering state
|
||||
|
||||
#### Scenario: Future fields contain larger values
|
||||
- **WHEN** any disabled future-index field has a greater absolute value than Evelyn Price Index
|
||||
- **THEN** that disabled value does not affect the visible Y-axis bounds
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Status service has an explicit public boundary
|
||||
The system SHALL expose `com.r35157.evelyn.service.status.EvelynStatusService` as the status-history and reference-price API. Its process IDs SHALL be `evelyn.status.prod` and `evelyn.status.test`. The service rename SHALL preserve the Production/Test storage identities, existing lifecycle operations, `EvelynStatusIndexPoint` and its six fields. Evelyn SHALL remain the overall system name, with NenjimHub owning individual process startup.
|
||||
|
||||
#### Scenario: Consumer uses the renamed status API
|
||||
- **WHEN** a consumer requests status history or a reference price
|
||||
- **THEN** it uses `EvelynStatusService` without accessing an implementation helper or a new service container
|
||||
|
||||
#### Scenario: Existing Production and Test storage survives the rename
|
||||
- **WHEN** the renamed services are constructed and started with the existing Production and Test identities
|
||||
- **THEN** they use the unchanged `data/evelyn/Production/status.log` and `data/evelyn/Test/status.log` files and format version 1 without migration or provisioning
|
||||
|
||||
### Requirement: Reference price is calculated on demand with an explicit quote currency
|
||||
`EvelynStatusService` SHALL expose `AssetPrice getReferencePrice(Instant timestamp)`. For each supplied instant on or after 2026-07-31T22:00:00Z it SHALL calculate 15.00 × 1.20^(elapsedSeconds / 31,536,000), returning the canonical EVE/USDT trading pair. It SHALL preserve the existing USDT-as-USD index convention without conversion, duplicate constants, separate configuration, cached historical measurements, or dependence on a sampling interval.
|
||||
|
||||
#### Scenario: Public reference price at the start
|
||||
- **WHEN** a consumer requests the reference price for 2026-07-31T22:00:00Z
|
||||
- **THEN** the price is 15 with the canonical EVE base and USDT quote currencies
|
||||
|
||||
#### Scenario: Public reference price after one year
|
||||
- **WHEN** a consumer requests the reference price exactly 31,536,000 seconds after the start
|
||||
- **THEN** the price is 18 with the same canonical trading pair
|
||||
|
||||
#### Scenario: Supplied timestamp determines the price
|
||||
- **WHEN** a consumer requests a valid instant distinct from the clock or latest historical point
|
||||
- **THEN** the returned value is calculated for that instant rather than the current clock or historical sample
|
||||
|
||||
### Requirement: Reference-price requests are independent of sampling and storage
|
||||
A reference-price request SHALL succeed before sampling starts and while sampling is stopped, including when history storage has not been provisioned. It SHALL NOT fetch market prices, access history files, start a worker, or mutate status history. A null timestamp SHALL be rejected with `NullPointerException`; an instant before the reference curve starts SHALL be rejected with `IllegalArgumentException`.
|
||||
|
||||
#### Scenario: Reference price before startup without storage
|
||||
- **WHEN** a constructed service with no provisioned history storage receives a valid reference-price request
|
||||
- **THEN** it returns the calculated value without a ticker lookup, file access, worker startup, or history change
|
||||
|
||||
#### Scenario: Reference price while sampling is stopped
|
||||
- **WHEN** sampling is stopped and a valid reference-price request is made
|
||||
- **THEN** the service returns the calculated value without restarting sampling or reading, writing, or mutating history
|
||||
|
||||
#### Scenario: Reference price during sampling preserves history
|
||||
- **WHEN** reference-price requests are made while the service holds a status-history snapshot
|
||||
- **THEN** the requests do not add or alter any status point or file record
|
||||
|
||||
#### Scenario: Null or pre-start timestamp is rejected
|
||||
- **WHEN** a caller supplies null or an instant before 2026-07-31T22:00:00Z
|
||||
- **THEN** the service rejects it with the respective null or argument exception without side effects
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Component IDs are canonical validated values
|
||||
`NenjimComponentId` SHALL reject null, strip surrounding Unicode whitespace before storage and validation, and accept only lower-case ASCII dot-separated segments whose first character is a letter and whose remaining subparts contain lower-case letters or digits separated by single internal hyphens. Standard record value equality and hashing SHALL define ID equality.
|
||||
|
||||
#### Scenario: Surrounding whitespace is canonicalized
|
||||
- **WHEN** an ID is constructed from ` nenjim.registry.service `
|
||||
- **THEN** its stored value is `nenjim.registry.service`
|
||||
|
||||
#### Scenario: Valid component IDs are accepted
|
||||
- **WHEN** IDs such as `nenjim.registry.service`, `assetaz.price-source.raydium-pool`, or `evelyn.status.prod` are constructed
|
||||
- **THEN** construction succeeds
|
||||
|
||||
#### Scenario: Invalid component IDs are rejected
|
||||
- **WHEN** an ID is null or contains uppercase letters, underscores, internal whitespace, an empty segment, a leading or trailing dot, or a malformed hyphen
|
||||
- **THEN** construction fails with the specified null or argument exception category and identifies the invalid canonicalized value without exposing secrets
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Selected processes are constructed and started lazily in exact order
|
||||
The Hub SHALL support the modern process IDs `nenjim.registry.service`, `nenjim.journal.service`, `assetaz.ticker.default`, `jupiter-perps-alarm.default`, `evelyn.status.prod`, `evelyn.status.test`, `evelyn.iou-burner.prod`, `nenjim.javafx-runtime.service`, `evelyn.mission-control.default`, and `nenjim.process-manager.default` through implementation-only construction capability that creates no process instance during validation. After passive composition, the Hub SHALL visit every configured entry after Registry in declared order, beginning with Journal. At each entry it SHALL construct only that process and any process-specific passive administration view, register the process under the exact configured ID, resolve it through the canonical `NenjimProcess` contract where applicable, call `start()`, and record the ID only after successful return.
|
||||
|
||||
The configuration SHALL be the complete manually ordered process dependency closure. The Hub SHALL not discover, insert, recursively construct, reorder, or automatically start dependencies. A process omitted from `[AUTO_START]` SHALL not be constructed, registered, started, or recorded, even when its implementation is available. Legacy process prototypes outside the modern Hub graph SHALL not be selectable.
|
||||
|
||||
#### Scenario: Configured process starts successfully
|
||||
- **WHEN** a configured process entry is reached and its dependencies are already available
|
||||
- **THEN** that process is constructed, registered, started, and recorded before the next entry is reached
|
||||
|
||||
#### Scenario: Process is omitted
|
||||
- **WHEN** a known process ID does not appear in `[AUTO_START]`
|
||||
- **THEN** no instance of that process is constructed or registered and its lifecycle is never started or recorded
|
||||
|
||||
#### Scenario: Jupiter Perps Alarm is selected
|
||||
- **WHEN** `jupiter-perps-alarm.default` is explicitly listed after its required predecessors
|
||||
- **THEN** the otherwise inactive modern alarm process is constructed, registered, started and recorded at that exact position
|
||||
|
||||
#### Scenario: Dependency order is invalid
|
||||
- **WHEN** a selected process is reached before a process dependency required by its current constructor or startup behavior
|
||||
- **THEN** construction or startup fails clearly without implicit dependency construction, repair, or reordering
|
||||
|
||||
#### Scenario: JavaFX processes are omitted
|
||||
- **WHEN** a valid headless plan omits `nenjim.javafx-runtime.service`, `evelyn.mission-control.default`, and `nenjim.process-manager.default`
|
||||
- **THEN** none of those objects is constructed or registered and the JavaFX toolkit remains uninitialized by the Hub
|
||||
|
||||
#### Scenario: Renamed status services retain dependency wiring
|
||||
- **WHEN** `evelyn.status.prod` and `evelyn.status.test` are listed after Ticker and before Mission Control
|
||||
- **THEN** the Hub constructs separate Production/Test status services, registers the renamed IDs, and injects the corresponding typed service views into Mission Control in the configured order
|
||||
|
||||
#### Scenario: Deployment still contains an old status ID
|
||||
- **WHEN** a private deployment configuration contains `evelyn.service.prod` or `evelyn.service.test`
|
||||
- **THEN** validation rejects the unknown ID before startup; deployment migration replaces only that ID with `evelyn.status.prod` or `evelyn.status.test`, preserving selection and order
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Status API and Calculation
|
||||
|
||||
- [x] 1.1 Move/rename the API and implementation and move the package-private calculator; verify only these three Evelyn files change packages and `EvelynStatusIndexPoint` stays unchanged.
|
||||
- [x] 1.2 Add documented `AssetPrice getReferencePrice(Instant)` delegating to the existing calculator and canonical pair; verify deterministic start/year/fractional values, invalid timestamps, no ticker/clock/storage access, unchanged history and operation before startup/while stopped.
|
||||
- [x] 1.3 Document the renamed API and reference-price contract; verify the documented curve, units and preserved lifecycle/storage match the code.
|
||||
|
||||
## 2. Hub and Mission Control Integration
|
||||
|
||||
- [x] 2.1 Rename Hub types, fields, factories and component IDs and Mission Control's typed dependencies; compile main/test sources and inspect Process Manager's use of Hub-provided IDs.
|
||||
- [x] 2.2 Document private deployment ID substitutions and update affected versioned examples without changing their selections; verify the example still selects only Registry and Journal and private runtime configuration is untouched.
|
||||
- [x] 2.3 Verify separate Production/Test histories, index calculation, durable append, immutable snapshots and restart behavior with offline temporary storage; review Mission Control chart wiring and retained six-field scaffolding.
|
||||
|
||||
## 3. Integration and Review
|
||||
|
||||
- [x] 3.1 Update the affected existing OpenSpec capabilities from the deltas and strictly validate this change; verify the renamed API, operation and IDs are represented without archiving the change.
|
||||
- [x] 3.2 Run the focused acceptance checks and existing offline verification, compile all source sets, run `git diff --check`, and review the diff for generated-file edits, runtime data/config changes, financial activation and commits.
|
||||
|
||||
## Verification Result
|
||||
|
||||
- Gradle 9.6.1 / JDK 25: `classes testClasses test --offline --console=plain --no-daemon` passed. A temporary writable Gradle home and the existing read-only dependency cache were used in this session.
|
||||
- Five status-service acceptance checks and four existing offline Solana transaction-construction checks passed: 9 tests, 0 failures, 0 errors.
|
||||
- Strict OpenSpec validation passed for this change and its three updated main capabilities.
|
||||
- `git diff --check` passed; generated source, private configuration and runtime data were not edited, the versioned bootstrap example and status-point record are unchanged, and the index calculator differs only in its package.
|
||||
- No Hub or live financial process was run. The change remains active, all eight tasks are complete, and no files were staged or committed.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-04
|
||||
@@ -0,0 +1,35 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
See proposal.md for motivation. Raydium already discovers position NFTs and decodes pool/position state but does not execute liquidity decrease. SolanaWallet provides external jup signer validation, signing/submission and SPL transfer with ATA creation; JupiterSwapService confirms exact-input swaps. The Hub has a constructor catalogue and explicit ordered process selection.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** Implement the specified pipeline with fresh direct RPC reads and exact eligibility math; leave an offline-verifiable, inactive deployment template.
|
||||
|
||||
**Non-Goals:** No generic strategy, service-container framework, durable pending transaction queue or new currency ValueTags.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Keep the public service a domain marker, mirroring the burner; the reference implementation additionally owns NenjimProcess startup and AutoCloseable shutdown. One scheduled executor uses fixed delay and propagates interruption across step boundaries.
|
||||
- Keep the existing shared Hub graph eager; construct/register liquidity deployment configuration and dedicated passive dependencies only at the selected liquidity entry, as reflected by the Hub/Registry deltas.
|
||||
- Build a dedicated direct Raydium instance when selected so cached pool/position state cannot trigger stale decisions. Resolve currencies through CIS and programs/precision from validated mint accounts.
|
||||
- Finish NFT discovery first, then read a fresh pool state immediately before evaluating each owned positive-liquidity position. Pass that same state to withdrawal minimum-output math. Do not reuse a price across discovery or earlier withdrawals; an unusable pool read stops the remaining withdrawals in that iteration while swap and payout remain independent.
|
||||
- Compare squared Q64.64 values by exact cross multiplication. Decimal factors cancel because pool and position share mints. For reversed ordering compare current raw price to lower-bound raw price times the retained percentage. This avoids display rounding and inversion rounding.
|
||||
- Port the official CLMM integer tick algorithm into a small Raydium math helper, reusing it for exact integer principal amounts. Existing display math remains unchanged.
|
||||
- Execute decrease_liquidity_v2 (legacy and Token-2022) with owner NFT holding, personal/protocol position PDA, vaults, tick arrays, owner destination token accounts and bitmap extension. Reuse an existing owner holding for each pool/reward mint, validating its account's mint, owner, program and initialized state. Only fall back to an existing or idempotently created ATA when no holding exists; failed holdings discovery must not create an extra account. This preserves the existing wallet's single-account-per-mint contract. Request a 600,000 compute-unit limit for the combined operation. Account details stay in the Raydium implementation; a private legacy transaction encoder uses existing address codec, PDA and wallet primitives.
|
||||
- Validate pool and personal-position discriminators, token metadata and owner holding before signing. Apply explicit slippage to integer expected principal. Fail closed on unsupported transfer-affecting Token-2022 extensions rather than silently weakening minimums. Required reward accounts, if present, only collect into this wallet and never enter swaps/payouts.
|
||||
- Parse only conf/evelyn-liquidity.conf with format-version validation, unknown/duplicate key rejection and required keys. Deployment addresses/signer remain placeholders in the ignored local runtime file and tracked example. Existing Hub example stays unchanged.
|
||||
- Use controlled JUnit fixtures explicitly requested by issue #89, including transaction bytes. No test constructs a real signer or submits a financial operation.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Pool moves during discovery or earlier withdrawals] → Refresh price and eligibility for each position. Minimum principal limits protect received principal, not the harvest-distance threshold. Market movement after the final decision remains possible before execution; later iterations reread state.
|
||||
- [Unknown transaction outcome] → Log signature when available, preserve ordinary underlying confirmation; subsequent iterations use current balances/positions as requested.
|
||||
- [Mint extension or program layout changes] → Validate discriminators/layout and fail safely on unsupported extensions; document official source references and exact supported behavior.
|
||||
- [SOL starvation] → Document funding for transaction fees and token-account rent; no hidden token reserves.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Copy/edit the example, bind a dedicated external signer, fund SOL and manually select evelyn.liquidity.prod in the Hub runtime plan. Stop/remove the selection to disable; positions and wallet balances remain the recovery state. Keep the OpenSpec change active for user review; no commit, push or deployment.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Evelyn needs to realize proceeds from manually funded EVE/SyrupUSDC CLMM positions before a falling price converts them back to EVE. Issue #89 adds the smallest automated realization and USDC payout pipeline supporting Evelyn's sustained 100 USD/day objective.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a domain-only EvelynLiquidityService and a Hub-owned sequential process with three independent withdrawal, exact-input swap and payout steps.
|
||||
- Add full Raydium CLMM liquidity decrease with slippage protection and retained position accounts/NFTs.
|
||||
- Add strict versioned deployment configuration and a public placeholder example with both reserves zero.
|
||||
- Lazily construct dedicated liquidity wallet, Jupiter and direct Raydium dependencies only for the selected process.
|
||||
- Verify with controlled offline fixtures and document operation/recovery.
|
||||
- Correct the review findings from commit `7dfd43d`: refresh the pool price for each position after discovery and reuse existing owner token accounts before creating destination ATAs.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `evelyn-liquidity-service`: Distance-based position realization, reserve-aware conversion/payout, configuration and runtime behavior.
|
||||
- `raydium-clmm-liquidity-withdrawal`: Protected full liquidity decrease preserving positions and NFTs.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `nenjim-hub-configuration`: Add the optional process to the known catalogue and defer its dedicated dependencies until selection.
|
||||
- `nenjim-component-registry`: Preserve eager shared composition while registering dedicated liquidity components only for the selected process.
|
||||
|
||||
## Impact
|
||||
|
||||
Adds API and reference implementation packages under com.r35157.evelyn.service.liquidity, extends Raydium, and adds one optional Hub constructor. Deployment configuration and signer are required only when explicitly selected. No position creation/refill/closure, reference-price dependency, notifications or persistent service queue. No live financial verification, commits or pushes.
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
## Purpose
|
||||
|
||||
Realize Evelyn's manually funded EVE/SyrupUSDC concentrated liquidity into USDC payouts using one dedicated wallet and current on-chain state.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Owned positions qualify by their normalized upper boundary
|
||||
The service SHALL discover positions owned by its wallet in its configured CLMM pool and withdraw positive liquidity only when the upper EVE/SyrupUSDC boundary U satisfies U <= P*(1-h/100). P SHALL come from a fresh on-chain pool read for each owned positive-liquidity position after NFT discovery and previous withdrawals have completed. The same state SHALL be used for that position's eligibility decision and withdrawal quote; an earlier position's price SHALL NOT be reused. Both orientations SHALL be normalized, and decisions SHALL use pool precision. Unusable P SHALL skip the remaining withdrawals in that iteration while allowing later steps.
|
||||
|
||||
#### Scenario: Inclusive threshold and upper boundary
|
||||
- **WHEN** P is 15, h is 10 and positions span 13–13.5 and 13.5–14
|
||||
- **THEN** only the first qualifies, including equality
|
||||
|
||||
#### Scenario: Reversed pool ordering
|
||||
- **WHEN** mint A is SyrupUSDC and mint B is EVE
|
||||
- **THEN** the inverted lower raw boundary determines the normalized upper price and the same eligibility rule applies
|
||||
|
||||
#### Scenario: Other or empty positions and falling market
|
||||
- **WHEN** a position belongs to another wallet or pool, has zero liquidity, or its upper boundary exceeds the threshold after a price decline
|
||||
- **THEN** it retains its state and the service neither withdraws it nor creates or refills positions
|
||||
|
||||
#### Scenario: Price declines during discovery or an earlier withdrawal
|
||||
- **WHEN** a position qualifies before discovery or an earlier withdrawal, but its upper boundary exceeds the threshold at the new pool price
|
||||
- **THEN** the service reads that new price before deciding and preserves the position's liquidity in either token orientation
|
||||
|
||||
#### Scenario: Pool read fails between positions
|
||||
- **WHEN** an earlier position was withdrawn but the next current pool price is unavailable or unusable
|
||||
- **THEN** no remaining position is withdrawn in that iteration and swap and payout still run on their independent balances
|
||||
|
||||
### Requirement: Swap and payout use independent current balances
|
||||
The service SHALL read SyrupUSDC at step 2 and USDC at step 3 and operate on the entire respective balance above its nonnegative reserve. Absent or at/below-reserve balances SHALL be skipped. Exact-input swap SHALL use explicit maximum slippage. SPL payout SHALL target the configured wallet owner with existing account-creation support. Amounts SHALL never round upward. EVE and reward tokens SHALL remain untouched by this pipeline.
|
||||
|
||||
#### Scenario: Nonzero reserves
|
||||
- **WHEN** SyrupUSDC balance is 1.01 with reserve 0.01 and USDC balance is 2.5 with reserve 0.5
|
||||
- **THEN** 1.00 SyrupUSDC is swapped and 2.0 USDC is sent
|
||||
|
||||
#### Scenario: Zero reserves and empty accounts
|
||||
- **WHEN** reserves are zero
|
||||
- **THEN** full positive balances are eligible, absent/zero balances are skipped and empty source accounts are not closed
|
||||
|
||||
### Requirement: Failure recovery uses on-chain state
|
||||
Each iteration SHALL attempt withdrawal, swap and payout independently in that order, isolating ordinary per-position failures. Subsequent iterations and restarts SHALL reread positions and balances without a persistent queue, attribution, pending-signature gate, immediate retries or replay. Logs SHALL identify operations, positions, amounts and returned signatures without signer secrets. Interruption SHALL stop work.
|
||||
|
||||
#### Scenario: Independent failures and restart
|
||||
- **WHEN** withdrawal or swap fails or returns an unknown outcome
|
||||
- **THEN** independent later work can proceed and the next iteration or restart acts on current state, skipping completed empty positions
|
||||
|
||||
### Requirement: Configuration is validated before worker start
|
||||
A dedicated version-1 runtime configuration SHALL validate wallet/signer binding, pool type/token identity, address syntax, 0<=h<100, positive fixed delay, nonnegative reserves with mint precision and explicit slippage limits. Currencies, mints and programs SHALL use canonical identity and chain metadata. The public example SHALL have zero reserves and SHALL never be a runtime fallback.
|
||||
|
||||
#### Scenario: Invalid input or missing deployment file
|
||||
- **WHEN** configuration is invalid or only the example exists
|
||||
- **THEN** startup fails before financial work
|
||||
|
||||
### Requirement: Optional process owns a sequential worker
|
||||
The Hub SHALL select evelyn.liquidity.prod through its existing catalogue and Registry, constructing deployment configuration and dedicated signer/swap dependencies only when selected. Existing selections and ordering SHALL be preserved. The domain interface SHALL expose no lifecycle operations. A single worker SHALL run the first and subsequent iterations identically, with a fixed delay after completion and ordinary shutdown interruption.
|
||||
|
||||
#### Scenario: Optional process is omitted
|
||||
- **WHEN** the Hub plan omits evelyn.liquidity.prod
|
||||
- **THEN** no liquidity configuration or dedicated signer is loaded and the checked-in Hub example activates no financial operation
|
||||
|
||||
#### Scenario: Slow iteration and shutdown
|
||||
- **WHEN** an iteration exceeds the configured interval or shutdown interrupts it
|
||||
- **THEN** no iterations overlap and interruption prevents further steps
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: NenjimHub owns composition and runtime startup
|
||||
`NenjimHub` SHALL remain the global runtime owner exposing only `start() throws Exception` plus an immutable ordered snapshot of successfully started process IDs. It SHALL be neither component nor process and SHALL not register itself. Its only accepted startup attempt SHALL validate the complete fixed-path Hub configuration before starting anything, construct and start Registry first, register Registry as `nenjim.registry.service`, record it, construct and register the existing shared passive graph, then lazily construct, register, start, and record only the remaining configured processes in exact order beginning with Journal. The selected liquidity process SHALL additionally compose its dedicated passive dependencies lazily at its configured entry. Registry and Journal managers SHALL remain distinct passive, unregistered handles retained privately by the Hub. Registration SHALL never imply startup. The Hub SHALL preserve the online point and blocking wait only after all configured processes succeed, without retry, rollback, dependency inference, or reordering.
|
||||
|
||||
#### Scenario: Registry starts before passive graph construction
|
||||
- **WHEN** the Hub has validated a complete startup plan and performs its first startup attempt
|
||||
- **THEN** it starts, registers, and records Registry first before constructing the passive component graph or any selected non-Registry process
|
||||
|
||||
#### Scenario: Journal follows passive composition but starts second
|
||||
- **WHEN** Registry bootstrap and passive graph construction have succeeded
|
||||
- **THEN** the Hub constructs, registers, starts, and records Journal before any later configured process, so Journal remains the second successful process
|
||||
|
||||
#### Scenario: Registry is process-discoverable
|
||||
- **WHEN** the Registry registration has completed
|
||||
- **THEN** the same object is retrievable as both `NenjimRegistryService` and `NenjimProcess`
|
||||
|
||||
#### Scenario: Journal is process-discoverable
|
||||
- **WHEN** the configured Journal construction and registration have completed
|
||||
- **THEN** the same object is retrievable as both `JournalService` and `NenjimProcess` under `nenjim.journal.service`, while its manager is not registered
|
||||
|
||||
#### Scenario: Selected JavaFX runtime is process-discoverable
|
||||
- **WHEN** `nenjim.javafx-runtime.service` is selected and its registration has completed
|
||||
- **THEN** one object is retrievable as both `JavaFxRuntimeService` and `NenjimProcess` under that ID
|
||||
|
||||
#### Scenario: Selected JavaFX applications share the runtime
|
||||
- **WHEN** JavaFX runtime precedes selected Mission Control or Process Manager entries
|
||||
- **THEN** each selected application retains the same already-constructed `JavaFxRuntimeService` instance registered by the Hub
|
||||
|
||||
#### Scenario: Selected Process Manager is process-discoverable
|
||||
- **WHEN** `nenjim.process-manager.default` is selected after JavaFX runtime and its construction and registration complete
|
||||
- **THEN** the object constructed with the current Hub and shared JavaFX runtime is retrievable as both `NenjimProcessManager` and `NenjimProcess`
|
||||
|
||||
#### Scenario: Required passive bindings are registered
|
||||
- **WHEN** Hub passive composition completes
|
||||
- **THEN** all current passive component IDs and implementation choices from issue #76 are present exactly as configured, while process IDs other than Registry remain absent until selected
|
||||
|
||||
#### Scenario: Configured startup subset preserves order
|
||||
- **WHEN** Hub startup completes successfully
|
||||
- **THEN** successful process IDs exactly equal the validated `[AUTO_START]` entries in their declared order
|
||||
|
||||
#### Scenario: Omitted processes do not enter the Registry
|
||||
- **WHEN** a modern process ID is omitted from `[AUTO_START]`
|
||||
- **THEN** that process is neither constructed, registered, started, nor recorded even though its implementation is known to the Hub
|
||||
|
||||
#### Scenario: Journal failure is fail-fast
|
||||
- **WHEN** Journal construction, registration, or process startup throws
|
||||
- **THEN** Registry remains the only recorded process, no later configured process is constructed or started, the exception propagates, and the Hub neither announces online nor enters its final wait
|
||||
|
||||
#### Scenario: JavaFX runtime failure is fail-fast
|
||||
- **WHEN** selected JavaFX runtime construction, registration, or startup throws
|
||||
- **THEN** its ID is not recorded, no later configured process is constructed or started, the exception propagates, and the Hub neither announces online nor enters its final wait
|
||||
|
||||
#### Scenario: Hub start is not retried
|
||||
- **WHEN** any first Hub startup attempt has begun and `start()` is requested again
|
||||
- **THEN** the later request fails with `IllegalStateException` whether the first attempt succeeded or failed
|
||||
|
||||
#### Scenario: Startup failure is fail-fast
|
||||
- **WHEN** any passive composition, selected construction, registration, or process startup operation throws
|
||||
- **THEN** the exception propagates, no later process is constructed or started, the Hub neither announces online nor enters its final wait, and no generic rollback or retry is attempted
|
||||
|
||||
#### Scenario: Dedicated liquidity bindings use the liquidity signer
|
||||
- **WHEN** `evelyn.liquidity.prod` is selected and its construction succeeds
|
||||
- **THEN** `solana.wallet.evelyn-liquidity-prod`, `jupiter.swap.evelyn-liquidity-prod` and `raydium.service.evelyn-liquidity-prod` are registered, the swap service uses the dedicated liquidity wallet and Raydium uses direct chain reads
|
||||
|
||||
#### Scenario: Liquidity service has orthogonal domain and lifecycle views
|
||||
- **WHEN** the selected liquidity process is registered
|
||||
- **THEN** the same object resolves as `EvelynLiquidityService` and `NenjimProcess`, while the domain interface exposes no lifecycle operations
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Registry bootstraps eager passive composition
|
||||
After validating the entire plan, the Hub SHALL construct the configured Registry subsystem, start Registry, register its service and process object as `nenjim.registry.service`, and record that ID only after successful startup. The Hub SHALL then eagerly construct and register the existing shared passive component graph. Dedicated liquidity deployment configuration, wallet signer, Jupiter swap service and direct Raydium service SHALL instead be constructed and registered only when the configured `evelyn.liquidity.prod` entry is reached. This passive phase SHALL not construct any canonical `NenjimProcess`, start background workers, initialize JavaFX, add startup IDs, infer process dependencies, or implicitly create an omitted process. Plain internal dependencies that are not `NenjimComponent` objects MAY be constructed as part of passive composition.
|
||||
|
||||
#### Scenario: Registry surrounds passive composition
|
||||
- **WHEN** a valid Hub startup begins
|
||||
- **THEN** Registry starts, is registered, and is recorded before the existing shared passive component graph is constructed and registered
|
||||
|
||||
#### Scenario: Passive composition completes
|
||||
- **WHEN** Registry bootstrap succeeds
|
||||
- **THEN** all existing shared non-process building blocks, adapters, and services are constructed and registered without starting a process
|
||||
|
||||
#### Scenario: Passive construction fails
|
||||
- **WHEN** any passive component cannot be constructed or registered
|
||||
- **THEN** startup stops immediately with Registry remaining recorded and no configured process after Registry constructed or started
|
||||
|
||||
#### Scenario: Liquidity is omitted
|
||||
- **WHEN** the plan omits `evelyn.liquidity.prod`
|
||||
- **THEN** its deployment configuration, dedicated signer/wallet, Jupiter service and direct Raydium service are neither loaded nor constructed nor registered
|
||||
|
||||
### Requirement: Selected processes are constructed and started lazily in exact order
|
||||
The Hub SHALL support the modern process IDs `nenjim.registry.service`, `nenjim.journal.service`, `assetaz.ticker.default`, `jupiter-perps-alarm.default`, `evelyn.status.prod`, `evelyn.status.test`, `evelyn.iou-burner.prod`, `evelyn.liquidity.prod`, `nenjim.javafx-runtime.service`, `evelyn.mission-control.default`, and `nenjim.process-manager.default` through implementation-only construction capability that creates no process instance during validation. After passive composition, the Hub SHALL visit every configured entry after Registry in declared order, beginning with Journal. At each entry it SHALL construct only that process and any explicitly composed process-specific passive dependencies or administration view, register the process under the exact configured ID, resolve it through the canonical `NenjimProcess` contract where applicable, call `start()`, and record the ID only after successful return.
|
||||
|
||||
The configuration SHALL be the complete manually ordered process dependency closure. The Hub SHALL not discover, insert, recursively construct, reorder, or automatically start dependencies. A process omitted from `[AUTO_START]` SHALL not be constructed, registered, started, or recorded, even when its implementation is available. Legacy process prototypes outside the modern Hub graph SHALL not be selectable.
|
||||
|
||||
#### Scenario: Configured process starts successfully
|
||||
- **WHEN** a configured process entry is reached and its dependencies are already available
|
||||
- **THEN** that process is constructed, registered, started, and recorded before the next entry is reached
|
||||
|
||||
#### Scenario: Process is omitted
|
||||
- **WHEN** a known process ID does not appear in `[AUTO_START]`
|
||||
- **THEN** no instance of that process is constructed or registered and its lifecycle is never started or recorded
|
||||
|
||||
#### Scenario: Jupiter Perps Alarm is selected
|
||||
- **WHEN** `jupiter-perps-alarm.default` is explicitly listed after its required predecessors
|
||||
- **THEN** the otherwise inactive modern alarm process is constructed, registered, started and recorded at that exact position
|
||||
|
||||
#### Scenario: Dependency order is invalid
|
||||
- **WHEN** a selected process is reached before a process dependency required by its current constructor or startup behavior
|
||||
- **THEN** construction or startup fails clearly without implicit dependency construction, repair, or reordering
|
||||
|
||||
#### Scenario: JavaFX processes are omitted
|
||||
- **WHEN** a valid headless plan omits `nenjim.javafx-runtime.service`, `evelyn.mission-control.default`, and `nenjim.process-manager.default`
|
||||
- **THEN** none of those objects is constructed or registered and the JavaFX toolkit remains uninitialized by the Hub
|
||||
|
||||
#### Scenario: Renamed status services retain dependency wiring
|
||||
- **WHEN** `evelyn.status.prod` and `evelyn.status.test` are listed after Ticker and before Mission Control
|
||||
- **THEN** the Hub constructs separate Production/Test status services, registers the renamed IDs, and injects the corresponding typed service views into Mission Control in the configured order
|
||||
|
||||
#### Scenario: Deployment still contains an old status ID
|
||||
- **WHEN** a private deployment configuration contains `evelyn.service.prod` or `evelyn.service.test`
|
||||
- **THEN** validation rejects the unknown ID before startup; deployment migration replaces only that ID with `evelyn.status.prod` or `evelyn.status.test`, preserving selection and order
|
||||
|
||||
#### Scenario: Liquidity is explicitly selected
|
||||
- **WHEN** `evelyn.liquidity.prod` is reached in a valid startup plan
|
||||
- **THEN** the Hub loads its runtime configuration, binds the dedicated signer/wallet and Jupiter service to each other, constructs direct Raydium access and registers/starts the liquidity process without inserting a Ticker or status dependency or reordering the plan
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
## Purpose
|
||||
|
||||
Provide a narrowly scoped protected Raydium CLMM full liquidity decrease using the owner's existing Solana signing and submission primitives.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Full withdrawal preserves the reusable position
|
||||
The operation SHALL validate the NFT owner, pool and positive current liquidity, decrease that complete liquidity into the same wallet, collect pool-token fees and preserve the position account, NFT and NFT token account. For each pool or reward mint it SHALL reuse an existing owner token holding, including a non-associated account, after validating its mint, owner, program and initialized state. It SHALL use or create an ATA only when no owner holding exists and SHALL NOT fall back to account creation when holdings discovery fails. Transaction/account details SHALL remain behind Raydium. It SHALL submit once and use existing confirmation behavior without close, burn or service-level retry.
|
||||
|
||||
#### Scenario: Full protected withdrawal
|
||||
- **WHEN** a wallet withdraws its position's complete liquidity
|
||||
- **THEN** the transaction names the expected wallet, pool, liquidity and nonzero minimum output protection and contains no close-position, NFT burn or token-account close instruction
|
||||
|
||||
#### Scenario: Wrong owner or changed position
|
||||
- **WHEN** ownership, pool or current position state is inconsistent with the requested withdrawal
|
||||
- **THEN** the operation fails before signing
|
||||
|
||||
#### Scenario: Existing non-associated token accounts
|
||||
- **WHEN** the wallet already has a pool-token or reward holding outside its ATA under either supported token program
|
||||
- **THEN** the withdrawal targets that validated account and creates no additional account for that mint, preserving subsequent wallet balance reads
|
||||
|
||||
#### Scenario: Existing holding cannot be trusted
|
||||
- **WHEN** holdings discovery fails or the selected account has a wrong mint, owner, token program or initialized state
|
||||
- **THEN** withdrawal fails before signing without creating a replacement token account
|
||||
|
||||
### Requirement: Minimum output derives from precise state and explicit slippage
|
||||
Withdrawal minimums SHALL derive from current Q64.64 pool state, exact CLMM tick/liquidity math and configured slippage with downward raw-token rounding. Both minimums SHALL NOT be unconditionally zero. Required legacy/Token-2022 accounts, tick arrays and bitmap extension SHALL follow the official decrease instruction. Unsupported token extensions SHALL fail safely before signing.
|
||||
|
||||
#### Scenario: One-sided principal and fees
|
||||
- **WHEN** the range is fully below the normalized current price
|
||||
- **THEN** SyrupUSDC principal is protected, any EVE fees return to the wallet and all received EVE remains there
|
||||
|
||||
#### Scenario: Precision and tiny position
|
||||
- **WHEN** expected principal is too small to provide a positive raw-unit minimum
|
||||
- **THEN** the operation fails rather than submitting an unchecked withdrawal
|
||||
@@ -0,0 +1,38 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Protected Raydium withdrawal
|
||||
|
||||
- [x] 1.1 Add exact CLMM tick, eligibility and raw principal math; verify official boundary fixtures, equality and inversion.
|
||||
- [x] 1.2 Add full decrease operation with owner/account validation, slippage and retained NFT; verify captured transaction bytes with offline fixtures and document API/source references.
|
||||
|
||||
## 2. Liquidity service and deployment configuration
|
||||
|
||||
- [x] 2.1 Add versioned strict configuration and zero-reserve public template; verify invalid ranges, addresses, precision, duplicate/unknown keys and missing-file behavior.
|
||||
- [x] 2.2 Implement independent withdrawal/swap/payout steps and sequential interruptible lifecycle; verify reserves, failure/restart recovery, empty/foreign positions, price decline, no overlap and shutdown using controlled dependencies.
|
||||
- [x] 2.3 Document configuration, dedicated signer/SOL requirements and pipeline/recovery behavior; inspect documentation against issue #89.
|
||||
|
||||
## 3. Hub integration and overall validation
|
||||
|
||||
- [x] 3.1 Lazily register the optional process and dedicated wallet/Jupiter/direct Raydium dependencies; verify optional selection preserves existing startup plan and checked-in example inactivity.
|
||||
- [x] 3.2 Compile main/verification sources, run affected offline tests, validate OpenSpec strictly and run git diff --check; record results without live financial operations, commits or pushes.
|
||||
|
||||
## 4. Review corrections for commit 7dfd43d
|
||||
|
||||
- [x] 4.1 Refresh pool state per position after discovery and previous withdrawals; verify price declines in both orientations, quote consistency and independent later steps after a pool-read failure.
|
||||
- [x] 4.2 Reuse validated existing pool/reward token holdings before creating ATAs; verify both token programs, invalid accounts, failed holdings reads and absence of duplicate-account creation.
|
||||
- [x] 4.3 Update operation documentation, compile and run all offline tests, validate OpenSpec strictly and check the diff without live financial operations, commits or pushes.
|
||||
|
||||
## Verification result (2026-10-04)
|
||||
|
||||
- Main and verification compilation passed with JavaFX JDK 25 and Gradle 9.6.1.
|
||||
- All 34 repository JUnit tests passed (25 liquidity/math/withdrawal/Hub tests and 9 existing tests); no failures, errors or skips.
|
||||
- Strict OpenSpec change validation passed; git diff --check and new-file whitespace checks passed.
|
||||
- Public mainnet inspection was read-only. All financial operation verification used controlled offline dependencies; no live withdrawal, swap, transfer, signer invocation, commit or push.
|
||||
- Runtime configuration contains deployment placeholders and is ignored by Git. Both example reserves are zero; the Hub runtime selection and checked-in Hub example are unchanged.
|
||||
|
||||
## Review correction verification (2026-10-04)
|
||||
|
||||
- Eight new regression tests reproduced the findings before their respective fixes and passed afterward: four cover per-position price freshness, quote consistency and independent later steps; four cover existing pool/reward accounts, invalid accounts and failed holdings reads.
|
||||
- Main/verification compilation and all 42 repository JUnit tests passed with no failures, errors or skips.
|
||||
- Strict OpenSpec validation and git diff --check passed. The active change has 10/10 completed tasks.
|
||||
- Verification used controlled offline dependencies only. No live financial operation, real signer invocation, commit, push or deployment was performed.
|
||||
@@ -0,0 +1,69 @@
|
||||
# assetaz-journal Specification
|
||||
|
||||
## Purpose
|
||||
Define the authoritative AssetAZ fact journal and independent consumer reconstruction and causal freshness contracts.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Durable authoritative ordering
|
||||
The journal SHALL be append-only and serialize appends, assigning contiguous sequences from 1 independently of occurredAt. Fields 2..6 SHALL be encoded once, hashed, and copied into the final record. Only successful FileChannel.force(true) SHALL commit the candidate head, publish and return its sequence. Missing append codecs and oversized records MUST fail before any append. An uncertain write SHALL permanently fail closed in that JVM without retries, repair or truncation.
|
||||
|
||||
#### Scenario: Failed force
|
||||
- **WHEN** persistence may have left an uncertain tail
|
||||
- **THEN** the candidate is not committed or published and all later appends fail.
|
||||
|
||||
### Requirement: Record integrity and bounded readers
|
||||
Each record SHALL be exactly one top-level PDE OBJECT containing checksum, previousChecksum, sequence, stable short eventType, eventVersion and OBJECT payload in that order. Both hashes SHALL be 32 raw bytes. SHA-256 SHALL cover the original persisted fields 2..6; the first previousChecksum SHALL be SHA-256(empty). Each independent reader SHALL reuse one fixed 1024-byte buffer. The entire record including header MUST be at most 1024 bytes; reading SHALL reject declared excess or truncation immediately after framing and before payload decoding. Startup SHALL validate the entire history, checksums, chain, contiguous sequences, PDE, known codecs and exact schemas before succeeding.
|
||||
|
||||
#### Scenario: Corrupt history
|
||||
- **WHEN** a checksum, chain, sequence, schema, type/version or framing cannot be verified
|
||||
- **THEN** startup fails with the rejection layer identified; no record is skipped.
|
||||
|
||||
### Requirement: Event and codec contract
|
||||
Events SHALL represent facts, not commands. occurredAt SHALL be semantic time; note and correlationId are nullable. Correlation groups independently meaningful workflow events without atomicity or expected count; inseparable changes belong in one event. Codecs SHALL be registry components resolved by stable eventType and persistent version, never constructed in the journal. Domain class names MUST NOT encode persistent versions. Journal integrity, PDE validity and event schema validity SHALL remain separate layers.
|
||||
|
||||
#### Scenario: Historical codec
|
||||
- **WHEN** a known historical persistent version is read
|
||||
- **THEN** its codec yields the current domain event without changing the domain type name.
|
||||
|
||||
### Requirement: Owner payload
|
||||
AssetOwnerRegistered version 1 SHALL contain exactly seven positional values: signed long epoch seconds, nano-of-second, nullable UTF-8 note, nullable UUID correlationId, required UUID assetOwnerId, required textual assetOwnerType, required UTF-8 name. UUIDs SHALL be exactly 16 bytes, most-significant then least-significant long, both big endian. Nanos MUST be 0..999999999; owner type MUST be NATURAL_PERSON or LEGAL_PERSON, never ordinal. Required values, Instant range and exact field count SHALL be validated by the codec. No additional owner metadata SHALL be added.
|
||||
|
||||
#### Scenario: Extra payload value
|
||||
- **WHEN** an otherwise valid owner payload has an eighth value
|
||||
- **THEN** schema validation rejects it.
|
||||
|
||||
### Requirement: Format changes
|
||||
New journals SHALL implicitly start in format 1 with no header. JournalFormatVersionChanged SHALL be an ordinary event in the same envelope and hash chain, with a positive target version and common event fields. A change applies to subsequent records; returning to an earlier supported version SHALL be representable. Unsupported formats MUST fail closed; v1 implements only format 1.
|
||||
|
||||
#### Scenario: Chained format event
|
||||
- **WHEN** a format-1 change to format 1 is appended
|
||||
- **THEN** it is checksummed and replayed normally; unsupported target formats are rejected before append.
|
||||
|
||||
### Requirement: Registration and independent replay
|
||||
Registration SHALL atomically capture registrationHead, validate 1 <= replayFromSequence <= registrationHead + 1, defensively copy a non-null filter without null members and reject duplicate consumer identity (==). Empty filters SHALL mean all; other filters SHALL match exact runtime class only. Registration SHALL return without awaiting replay. Each subscription SHALL own a virtual-thread worker and independent reader, replay through registrationHead, close the reader and drain subsequent FIFO publications. Degraded registrations remain registered. registrationHead MUST NOT be public API.
|
||||
|
||||
#### Scenario: Late subscriber
|
||||
- **WHEN** another consumer registers while an existing one processes events
|
||||
- **THEN** only the new consumer replays; both receive their own ordered stream without gaps or duplicates.
|
||||
|
||||
### Requirement: Progress and barriers
|
||||
processedThrough SHALL mean all records through the cursor were successfully handled or examined as irrelevant, starting at replayFromSequence - 1. A barrier SHALL return whenever the safe cursor meets its minimum, including DEGRADED; otherwise wait while CATCHING_UP/ONLINE or throw DegradedException when DEGRADED. Progress and degradation SHALL wake waiters. The barrier MUST NOT imply a historical snapshot.
|
||||
|
||||
#### Scenario: Filtered progress
|
||||
- **WHEN** a consumer last handled 1000 and examined irrelevant records through 1100
|
||||
- **THEN** its cursor is 1100 and a barrier for 1100 succeeds.
|
||||
|
||||
### Requirement: States and atomic cutover
|
||||
Subscriptions SHALL begin CATCHING_UP, without requiring an initial state callback. After replay and pending events through an atomic cutover, the worker SHALL request ONLINE. Successful return establishes readiness to act; the callback should establish readiness without irreversible actions. Publication concurrent with cutover SHALL be processed before ONLINE or as the first normal event after ONLINE, with no gap. All callbacks for one consumer SHALL be sequential.
|
||||
|
||||
#### Scenario: Append during readiness callback
|
||||
- **WHEN** a new event arrives at the chosen cutover
|
||||
- **THEN** it is queued and processed next after readiness succeeds.
|
||||
|
||||
### Requirement: Isolated degradation and atomic models
|
||||
Callback or ONLINE transition failure SHALL retain the last safe cursor, mark only that subscription DEGRADED, stop delivery, wake barriers, and log the exception plus consumer identity, sequence and state. Consumers SHALL atomically apply each event or leave their externally visible model unchanged. Replay SHALL rebuild state without repeating trades, transfers or notifications. No OFFLINE, unregister or recovery SHALL be implemented.
|
||||
|
||||
#### Scenario: Consumer throws
|
||||
- **WHEN** one subscriber fails applying sequence N
|
||||
- **THEN** it retains its safe cursor, future barriers beyond that cursor fail, and other subscribers continue.
|
||||
@@ -0,0 +1,66 @@
|
||||
# evelyn-liquidity-service Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Realize Evelyn's manually funded EVE/SyrupUSDC concentrated liquidity into USDC payouts using one dedicated wallet and current on-chain state.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Owned positions qualify by their normalized upper boundary
|
||||
The service SHALL discover positions owned by its wallet in its configured CLMM pool and withdraw positive liquidity only when the upper EVE/SyrupUSDC boundary U satisfies U <= P*(1-h/100). P SHALL come from a fresh on-chain pool read for each owned positive-liquidity position after NFT discovery and previous withdrawals have completed. The same state SHALL be used for that position's eligibility decision and withdrawal quote; an earlier position's price SHALL NOT be reused. Both orientations SHALL be normalized, and decisions SHALL use pool precision. Unusable P SHALL skip the remaining withdrawals in that iteration while allowing later steps.
|
||||
|
||||
#### Scenario: Inclusive threshold and upper boundary
|
||||
- **WHEN** P is 15, h is 10 and positions span 13–13.5 and 13.5–14
|
||||
- **THEN** only the first qualifies, including equality
|
||||
|
||||
#### Scenario: Reversed pool ordering
|
||||
- **WHEN** mint A is SyrupUSDC and mint B is EVE
|
||||
- **THEN** the inverted lower raw boundary determines the normalized upper price and the same eligibility rule applies
|
||||
|
||||
#### Scenario: Other or empty positions and falling market
|
||||
- **WHEN** a position belongs to another wallet or pool, has zero liquidity, or its upper boundary exceeds the threshold after a price decline
|
||||
- **THEN** it retains its state and the service neither withdraws it nor creates or refills positions
|
||||
|
||||
#### Scenario: Price declines during discovery or an earlier withdrawal
|
||||
- **WHEN** a position qualifies before discovery or an earlier withdrawal, but its upper boundary exceeds the threshold at the new pool price
|
||||
- **THEN** the service reads that new price before deciding and preserves the position's liquidity in either token orientation
|
||||
|
||||
#### Scenario: Pool read fails between positions
|
||||
- **WHEN** an earlier position was withdrawn but the next current pool price is unavailable or unusable
|
||||
- **THEN** no remaining position is withdrawn in that iteration and swap and payout still run on their independent balances
|
||||
|
||||
### Requirement: Swap and payout use independent current balances
|
||||
The service SHALL read SyrupUSDC at step 2 and USDC at step 3 and operate on the entire respective balance above its nonnegative reserve. Absent or at/below-reserve balances SHALL be skipped. Exact-input swap SHALL use explicit maximum slippage. SPL payout SHALL target the configured wallet owner with existing account-creation support. Amounts SHALL never round upward. EVE and reward tokens SHALL remain untouched by this pipeline.
|
||||
|
||||
#### Scenario: Nonzero reserves
|
||||
- **WHEN** SyrupUSDC balance is 1.01 with reserve 0.01 and USDC balance is 2.5 with reserve 0.5
|
||||
- **THEN** 1.00 SyrupUSDC is swapped and 2.0 USDC is sent
|
||||
|
||||
#### Scenario: Zero reserves and empty accounts
|
||||
- **WHEN** reserves are zero
|
||||
- **THEN** full positive balances are eligible, absent/zero balances are skipped and empty source accounts are not closed
|
||||
|
||||
### Requirement: Failure recovery uses on-chain state
|
||||
Each iteration SHALL attempt withdrawal, swap and payout independently in that order, isolating ordinary per-position failures. Subsequent iterations and restarts SHALL reread positions and balances without a persistent queue, attribution, pending-signature gate, immediate retries or replay. Logs SHALL identify operations, positions, amounts and returned signatures without signer secrets. Interruption SHALL stop work.
|
||||
|
||||
#### Scenario: Independent failures and restart
|
||||
- **WHEN** withdrawal or swap fails or returns an unknown outcome
|
||||
- **THEN** independent later work can proceed and the next iteration or restart acts on current state, skipping completed empty positions
|
||||
|
||||
### Requirement: Configuration is validated before worker start
|
||||
A dedicated version-1 runtime configuration SHALL validate wallet/signer binding, pool type/token identity, address syntax, 0<=h<100, positive fixed delay, nonnegative reserves with mint precision and explicit slippage limits. Currencies, mints and programs SHALL use canonical identity and chain metadata. The public example SHALL have zero reserves and SHALL never be a runtime fallback.
|
||||
|
||||
#### Scenario: Invalid input or missing deployment file
|
||||
- **WHEN** configuration is invalid or only the example exists
|
||||
- **THEN** startup fails before financial work
|
||||
|
||||
### Requirement: Optional process owns a sequential worker
|
||||
The Hub SHALL select evelyn.liquidity.prod through its existing catalogue and Registry, constructing deployment configuration and dedicated signer/swap dependencies only when selected. Existing selections and ordering SHALL be preserved. The domain interface SHALL expose no lifecycle operations. A single worker SHALL run the first and subsequent iterations identically, with a fixed delay after completion and ordinary shutdown interruption.
|
||||
|
||||
#### Scenario: Optional process is omitted
|
||||
- **WHEN** the Hub plan omits evelyn.liquidity.prod
|
||||
- **THEN** no liquidity configuration or dedicated signer is loaded and the checked-in Hub example activates no financial operation
|
||||
|
||||
#### Scenario: Slow iteration and shutdown
|
||||
- **WHEN** an iteration exceeds the configured interval or shutdown interrupts it
|
||||
- **THEN** no iterations overlap and interruption prevents further steps
|
||||
@@ -7,7 +7,7 @@ Provide typed process-local Evelyn status measurements and visualize the current
|
||||
## Requirements
|
||||
|
||||
### Requirement: Evelyn calculates the live price index
|
||||
Evelyn SHALL obtain the actual EVE/USDT price from the latest AssetAZ Ticker observation for the canonical EVE/USDT trading pair. It SHALL treat USDT as USD for this calculation and SHALL NOT access Raydium directly. At sampling instant t, on or after 2026-07-31T22:00:00Z, the expected price SHALL be 15 × 1.20^(elapsedSeconds / 31,536,000) and the Evelyn Price Index SHALL be ((actualPrice / expectedPrice) - 1) × 10, calculated with adequate decimal precision and without arbitrary history rounding.
|
||||
`EvelynStatusService` SHALL obtain the actual EVE/USDT price from the latest AssetAZ Ticker observation for the canonical EVE/USDT trading pair. It SHALL treat USDT as USD for this calculation and SHALL NOT access Raydium directly. At sampling instant t, on or after 2026-07-31T22:00:00Z, the reference price SHALL be 15 × 1.20^(elapsedSeconds / 31,536,000) and the Evelyn Price Index SHALL be ((actualPrice / referencePrice) - 1) × 10, calculated with adequate decimal precision and without arbitrary history rounding. Sampling and the public reference-price operation SHALL use the same reference calculation and constants at the same sampling instant.
|
||||
|
||||
#### Scenario: Calculate expected price at the configured start
|
||||
- **WHEN** the sampling instant is 2026-07-31T22:00:00Z
|
||||
@@ -26,7 +26,7 @@ Evelyn SHALL obtain the actual EVE/USDT price from the latest AssetAZ Ticker obs
|
||||
- **THEN** the respective index is 0, 1, -1, 10, or -5
|
||||
|
||||
### Requirement: Evelyn instances have safe unique persistent names
|
||||
Every Evelyn instance SHALL have an NFC-normalized Unicode name whose preserved spelling and capitalization selects the direct child directory data/evelyn/<name>. A name SHALL be rejected if it is empty or whitespace-only, has leading or trailing whitespace, is . or .., ends with a period, contains a control character or any of < > : " / \ | ? *, is a case-insensitive Windows-reserved filename including a reserved base with an extension, or normalizes as a path to anything other than one direct child of data/evelyn. Within one JVM, normalized names SHALL be reserved atomically and compared case-insensitively using locale-independent rules.
|
||||
Every `EvelynStatusService` instance SHALL have an NFC-normalized Unicode name whose preserved spelling and capitalization selects the direct child directory data/evelyn/<name>. A name SHALL be rejected if it is empty or whitespace-only, has leading or trailing whitespace, is . or .., ends with a period, contains a control character or any of < > : " / \ | ? *, is a case-insensitive Windows-reserved filename including a reserved base with an extension, or normalizes as a path to anything other than one direct child of data/evelyn. Within one JVM, normalized names SHALL be reserved atomically and compared case-insensitively using locale-independent rules.
|
||||
|
||||
#### Scenario: A portable Unicode name is accepted
|
||||
- **WHEN** an instance is constructed with a valid Unicode name containing Danish characters
|
||||
@@ -41,10 +41,10 @@ Every Evelyn instance SHALL have an NFC-normalized Unicode name whose preserved
|
||||
- **THEN** exactly one reserves the persistent identity and the other fails
|
||||
|
||||
### Requirement: Evelyn distinguishes restartable stop from permanent close
|
||||
Stopping Evelyn SHALL terminate sampling, clear memory, preserve its persistent file, and retain its name reservation so the same object can restart. Evelyn SHALL support permanent, idempotent close; closing an active instance SHALL first stop it safely, a successfully closed instance SHALL never start again, and its name SHALL be released only after its sampling thread has definitely terminated. A termination failure SHALL be reported and SHALL retain the reservation.
|
||||
Stopping `EvelynStatusService` SHALL terminate sampling, clear memory, preserve its persistent file, and retain its name reservation so the same object can restart. `EvelynStatusService` SHALL support permanent, idempotent close; closing an active instance SHALL first stop it safely, a successfully closed instance SHALL never start again, and its name SHALL be released only after its sampling thread has definitely terminated. A termination failure SHALL be reported and SHALL retain the reservation.
|
||||
|
||||
#### Scenario: A stopped instance restarts
|
||||
- **WHEN** a stopped but not closed Evelyn instance is started again
|
||||
- **WHEN** a stopped but not closed `EvelynStatusService` instance is started again
|
||||
- **THEN** it retains exclusive ownership of the same name and reloads the complete persisted history before sampling
|
||||
|
||||
#### Scenario: An active instance closes successfully
|
||||
@@ -60,7 +60,7 @@ Stopping Evelyn SHALL terminate sampling, clear memory, preserve its persistent
|
||||
- **THEN** it completes without changing state or failing
|
||||
|
||||
### Requirement: Evelyn uses externally provisioned per-instance storage
|
||||
Each named instance SHALL use the existing regular file data/evelyn/<normalized-name>/status.log. Before every start, Evelyn SHALL require the direct instance path to be an existing readable and writable directory and status.log to be an existing readable and writable regular file. Evelyn SHALL NOT create, replace, repair, truncate, rename, or otherwise provision either path. Any validation or actual open failure SHALL identify the affected path, leave memory unpublished, and prevent sampling from starting.
|
||||
Each named instance SHALL use the existing regular file data/evelyn/<normalized-name>/status.log. Before every start, `EvelynStatusService` SHALL require the direct instance path to be an existing readable and writable directory and status.log to be an existing readable and writable regular file. `EvelynStatusService` SHALL NOT create, replace, repair, truncate, rename, or otherwise provision either path. Any validation or actual open failure SHALL identify the affected path, leave memory unpublished, and prevent sampling from starting.
|
||||
|
||||
#### Scenario: Required storage is absent or unsuitable
|
||||
- **WHEN** the instance directory or status file is missing, the path has the wrong type, or required access is unavailable
|
||||
@@ -73,11 +73,11 @@ Each named instance SHALL use the existing regular file data/evelyn/<normalized-
|
||||
### Requirement: Evelyn validates status history format strictly
|
||||
The UTF-8 status.log format SHALL have supported version 1, declared by FORMAT_VERSION=1 as the first actual entry; blank lines and full-line comments MAY precede it when loading, while an externally provisioned new file SHALL place it on the physical first line. Missing, duplicate, malformed, misplaced, or unsupported declarations SHALL fail loading before record parsing. After the declaration, blank lines and comments SHALL be ignored, inline comments MAY follow data, and every other entry SHALL be a valid record with an explicit stable numeric type code.
|
||||
|
||||
Type 1 SHALL encode <UTC uuuuMMddHHmmssSSS'Z' timestamp>:1:<plain BigDecimal Evelyn Price Index>. It SHALL have exactly three fields. Unknown types, invalid timestamps or decimals, missing or surplus fields, and decreasing timestamps SHALL fail the complete load; equal timestamps SHALL be accepted. A valid declaration with no records SHALL represent empty history. Applicable failures SHALL identify the file path, one-based line number, and original line, and Evelyn SHALL neither skip, sort, partially publish, nor repair invalid content.
|
||||
Type 1 SHALL encode <UTC uuuuMMddHHmmssSSS'Z' timestamp>:1:<plain BigDecimal Evelyn Price Index>. It SHALL have exactly three fields. Unknown types, invalid timestamps or decimals, missing or surplus fields, and decreasing timestamps SHALL fail the complete load; equal timestamps SHALL be accepted. A valid declaration with no records SHALL represent empty history. Applicable failures SHALL identify the file path, one-based line number, and original line, and `EvelynStatusService` SHALL neither skip, sort, partially publish, nor repair invalid content.
|
||||
|
||||
#### Scenario: A valid versioned history loads
|
||||
- **WHEN** the file has a valid first actual version declaration and valid type-1 records in non-decreasing timestamp order
|
||||
- **THEN** Evelyn restores every record in file order with zero values for the five unimplemented indexes
|
||||
- **THEN** the status service restores every record in file order with zero values for the five unimplemented indexes
|
||||
|
||||
#### Scenario: The format declaration is invalid
|
||||
- **WHEN** the declaration is missing, duplicated, malformed, misplaced, or not version 1
|
||||
@@ -88,7 +88,7 @@ Type 1 SHALL encode <UTC uuuuMMddHHmmssSSS'Z' timestamp>:1:<plain BigDecimal Eve
|
||||
- **THEN** startup fails with path, line number, and original line without exposing partial history or altering the file
|
||||
|
||||
### Requirement: Evelyn samples without overlapping or catch-up attempts
|
||||
Each started Evelyn environment SHALL own its sampling executor, attempt its first sample only after complete storage validation and history restoration, and make subsequent attempts approximately one minute after the preceding attempt completes. Sampling SHALL remain active for the Evelyn lifecycle independently of whether Mission Control is open. Evelyn SHALL add exactly one timestamped point per successful attempt and SHALL isolate a failed or temporarily unavailable Ticker lookup or persistence operation without adding a fake or unpersisted point, terminating Evelyn, or cancelling the next attempt. Stopping Evelyn SHALL terminate its sampling executor.
|
||||
Each started `EvelynStatusService` instance SHALL own its sampling executor, attempt its first sample only after complete storage validation and history restoration, and make subsequent attempts approximately one minute after the preceding attempt completes. Sampling SHALL remain active for the status-service lifecycle independently of whether Mission Control is open. `EvelynStatusService` SHALL add exactly one timestamped point per successful attempt and SHALL isolate a failed or temporarily unavailable Ticker lookup or persistence operation without adding a fake or unpersisted point, terminating the status service, or cancelling the next attempt. Stopping `EvelynStatusService` SHALL terminate its sampling executor.
|
||||
|
||||
#### Scenario: Initial Ticker data is unavailable
|
||||
- **WHEN** the immediate sampling attempt cannot obtain a latest EVE/USDT price
|
||||
@@ -107,14 +107,14 @@ Each started Evelyn environment SHALL own its sampling executor, attempt its fir
|
||||
- **THEN** no record or point is added, the failure is logged, and later attempts remain scheduled
|
||||
|
||||
### Requirement: Evelyn provides historical status-index measurements
|
||||
Each named Evelyn instance SHALL publish a safe, non-null, oldest-to-newest snapshot only after its complete persisted history has been validated and loaded. Each type-1 point SHALL contain its persisted or sampled timestamp, the Evelyn Price Index, and BigDecimal.ZERO placeholders for EVE_SYRUP Pool Depth Index, EVE_SYRUP Pool Balance Index, AAZDKK_USDT Pool Balance Index, AAZDKK_USDT Pool Price Index, and AAZDKK_USDT Pool Depth Index. Production and Test SHALL use independently named files, history collections, reservations, and lifecycles. A sampled point SHALL be durably appended using robust line-boundary handling before publication. Stop SHALL clear only memory; restart SHALL restore the complete file, and close SHALL preserve the file.
|
||||
Each named `EvelynStatusService` instance SHALL publish a safe, non-null, oldest-to-newest snapshot only after its complete persisted history has been validated and loaded. Each type-1 point SHALL contain its persisted or sampled timestamp, the Evelyn Price Index, and BigDecimal.ZERO placeholders for EVE_SYRUP Pool Depth Index, EVE_SYRUP Pool Balance Index, AAZDKK_USDT Pool Balance Index, AAZDKK_USDT Pool Price Index, and AAZDKK_USDT Pool Depth Index. Production and Test SHALL use independently named files, history collections, reservations, and lifecycles. A sampled point SHALL be durably appended using robust line-boundary handling before publication. Stop SHALL clear only memory; restart SHALL restore the complete file, and close SHALL preserve the file.
|
||||
|
||||
#### Scenario: Status-index history is requested during sampling
|
||||
- **WHEN** a caller requests an Evelyn environment's status-index history while persisted points may be appended
|
||||
- **WHEN** a caller requests a status-service instance's status-index history while persisted points may be appended
|
||||
- **THEN** it receives a safe snapshot containing only completely persisted points and all six typed fields
|
||||
|
||||
#### Scenario: A process starts two named Evelyn environments
|
||||
- **WHEN** Production and Test Evelyn instances start with independently provisioned files
|
||||
- **WHEN** Production and Test `EvelynStatusService` instances start with independently provisioned files
|
||||
- **THEN** each restores and extends only its own complete history even if calculated values are identical
|
||||
|
||||
#### Scenario: Existing file lacks a final line separator
|
||||
@@ -126,20 +126,65 @@ Each named Evelyn instance SHALL publish a safe, non-null, oldest-to-newest snap
|
||||
- **THEN** its memory is cleared at stop and its complete unchanged persistent history is revalidated and restored before sampling resumes
|
||||
|
||||
### Requirement: Evelyn Mission Control visualizes status-index measurements
|
||||
Evelyn Mission Control SHALL display separate live Production and Test Overview charts exclusively from their corresponding authoritative Evelyn snapshots and SHALL never read or manage persistence directly. Each chart SHALL contain one enabled series named Evelyn Price Index; the five future series and their typed fields SHALL remain available as disabled source scaffolding. Opening or reopening EMC SHALL immediately reconstruct each chart from the complete restored current snapshot before periodically refreshing it. Successful points SHALL be added to chart objects only on the JavaFX Application Thread while the window remains open. Timestamp bounds SHALL expand as points arrive, and only enabled Evelyn Price Index values SHALL determine a dynamic Y-axis range that is symmetric around and always displays zero, using a reasonable default range for empty or all-zero history. Hiding or closing EMC SHALL release only EMC's JavaFX refresh resources and SHALL NOT start, stop, close, persist, or clear either Evelyn environment.
|
||||
Evelyn Mission Control SHALL display separate live Production and Test Overview charts exclusively from their corresponding authoritative `EvelynStatusService` snapshots and SHALL never read or manage persistence directly. Each chart SHALL contain one enabled series named Evelyn Price Index; the five future series and their typed fields SHALL remain available as disabled source scaffolding. Opening or reopening EMC SHALL immediately reconstruct each chart from the complete restored current snapshot before periodically refreshing it. Successful points SHALL be added to chart objects only on the JavaFX Application Thread while the window remains open. Timestamp bounds SHALL expand as points arrive, and only enabled Evelyn Price Index values SHALL determine a dynamic Y-axis range that is symmetric around and always displays zero, using a reasonable default range for empty or all-zero history. Hiding or closing EMC SHALL release only EMC's JavaFX refresh resources and SHALL NOT start, stop, close, persist, or clear either status-service instance.
|
||||
|
||||
#### Scenario: Production and Test Overviews show restored history
|
||||
- **WHEN** Mission Control opens after two named Evelyn environments have restored their histories
|
||||
- **WHEN** Mission Control opens after two named `EvelynStatusService` instances have restored their histories
|
||||
- **THEN** it displays separate one-series charts immediately populated from the corresponding complete snapshots
|
||||
|
||||
#### Scenario: A successful point appears while EMC is open
|
||||
- **WHEN** an Evelyn history publishes a newly persisted point
|
||||
- **WHEN** a status-service history publishes a newly persisted point
|
||||
- **THEN** the corresponding chart adds it on the JavaFX Application Thread and updates its timestamp and symmetric price-index bounds without reopening EMC
|
||||
|
||||
#### Scenario: EMC reopens after collecting hidden-window samples
|
||||
- **WHEN** the same EMC instance is reopened after Evelyn persisted additional points while its window was hidden
|
||||
- **WHEN** the same EMC instance is reopened after the status services persisted additional points while its window was hidden
|
||||
- **THEN** each chart is reconstructed immediately from its environment's complete current history without missing points because of stale rendering state
|
||||
|
||||
#### Scenario: Future fields contain larger values
|
||||
- **WHEN** any disabled future-index field has a greater absolute value than Evelyn Price Index
|
||||
- **THEN** that disabled value does not affect the visible Y-axis bounds
|
||||
|
||||
### Requirement: Status service has an explicit public boundary
|
||||
The system SHALL expose `com.r35157.evelyn.service.status.EvelynStatusService` as the status-history and reference-price API. Its process IDs SHALL be `evelyn.status.prod` and `evelyn.status.test`. The service rename SHALL preserve the Production/Test storage identities, existing lifecycle operations, `EvelynStatusIndexPoint` and its six fields. Evelyn SHALL remain the overall system name, with NenjimHub owning individual process startup.
|
||||
|
||||
#### Scenario: Consumer uses the renamed status API
|
||||
- **WHEN** a consumer requests status history or a reference price
|
||||
- **THEN** it uses `EvelynStatusService` without accessing an implementation helper or a new service container
|
||||
|
||||
#### Scenario: Existing Production and Test storage survives the rename
|
||||
- **WHEN** the renamed services are constructed and started with the existing Production and Test identities
|
||||
- **THEN** they use the unchanged `data/evelyn/Production/status.log` and `data/evelyn/Test/status.log` files and format version 1 without migration or provisioning
|
||||
|
||||
### Requirement: Reference price is calculated on demand with an explicit quote currency
|
||||
`EvelynStatusService` SHALL expose `AssetPrice getReferencePrice(Instant timestamp)`. For each supplied instant on or after 2026-07-31T22:00:00Z it SHALL calculate 15.00 × 1.20^(elapsedSeconds / 31,536,000), returning the canonical EVE/USDT trading pair. It SHALL preserve the existing USDT-as-USD index convention without conversion, duplicate constants, separate configuration, cached historical measurements, or dependence on a sampling interval.
|
||||
|
||||
#### Scenario: Public reference price at the start
|
||||
- **WHEN** a consumer requests the reference price for 2026-07-31T22:00:00Z
|
||||
- **THEN** the price is 15 with the canonical EVE base and USDT quote currencies
|
||||
|
||||
#### Scenario: Public reference price after one year
|
||||
- **WHEN** a consumer requests the reference price exactly 31,536,000 seconds after the start
|
||||
- **THEN** the price is 18 with the same canonical trading pair
|
||||
|
||||
#### Scenario: Supplied timestamp determines the price
|
||||
- **WHEN** a consumer requests a valid instant distinct from the clock or latest historical point
|
||||
- **THEN** the returned value is calculated for that instant rather than the current clock or historical sample
|
||||
|
||||
### Requirement: Reference-price requests are independent of sampling and storage
|
||||
A reference-price request SHALL succeed before sampling starts and while sampling is stopped, including when history storage has not been provisioned. It SHALL NOT fetch market prices, access history files, start a worker, or mutate status history. A null timestamp SHALL be rejected with `NullPointerException`; an instant before the reference curve starts SHALL be rejected with `IllegalArgumentException`.
|
||||
|
||||
#### Scenario: Reference price before startup without storage
|
||||
- **WHEN** a constructed service with no provisioned history storage receives a valid reference-price request
|
||||
- **THEN** it returns the calculated value without a ticker lookup, file access, worker startup, or history change
|
||||
|
||||
#### Scenario: Reference price while sampling is stopped
|
||||
- **WHEN** sampling is stopped and a valid reference-price request is made
|
||||
- **THEN** the service returns the calculated value without restarting sampling or reading, writing, or mutating history
|
||||
|
||||
#### Scenario: Reference price during sampling preserves history
|
||||
- **WHEN** reference-price requests are made while the service holds a status-history snapshot
|
||||
- **THEN** the requests do not add or alter any status point or file record
|
||||
|
||||
#### Scenario: Null or pre-start timestamp is rejected
|
||||
- **WHEN** a caller supplies null or an instant before 2026-07-31T22:00:00Z
|
||||
- **THEN** the service rejects it with the respective null or argument exception without side effects
|
||||
|
||||
@@ -12,7 +12,7 @@ Provide one typed, read-only catalogue of constructed Nenjim components and a li
|
||||
- **THEN** its stored value is `nenjim.registry.service`
|
||||
|
||||
#### Scenario: Valid component IDs are accepted
|
||||
- **WHEN** IDs such as `nenjim.registry.service`, `assetaz.price-source.raydium-pool`, or `evelyn.service.prod` are constructed
|
||||
- **WHEN** IDs such as `nenjim.registry.service`, `assetaz.price-source.raydium-pool`, or `evelyn.status.prod` are constructed
|
||||
- **THEN** construction succeeds
|
||||
|
||||
#### Scenario: Invalid component IDs are rejected
|
||||
@@ -186,7 +186,7 @@ The reference Registry construction boundary SHALL create one Registry object ex
|
||||
- **THEN** no component ID identifies the manager and no lookup returns it as a component or process
|
||||
|
||||
### Requirement: NenjimHub owns composition and runtime startup
|
||||
`NenjimHub` SHALL remain the global runtime owner exposing only `start() throws Exception` plus an immutable ordered snapshot of successfully started process IDs. It SHALL be neither component nor process and SHALL not register itself. Its only accepted startup attempt SHALL validate the complete fixed-path Hub configuration before starting anything, construct and start Registry first, register Registry as `nenjim.registry.service`, record it, construct and register the complete passive graph, then lazily construct, register, start, and record only the remaining configured processes in exact order beginning with Journal. Registry and Journal managers SHALL remain distinct passive, unregistered handles retained privately by the Hub. Registration SHALL never imply startup. The Hub SHALL preserve the online point and blocking wait only after all configured processes succeed, without retry, rollback, dependency inference, or reordering.
|
||||
`NenjimHub` SHALL remain the global runtime owner exposing only `start() throws Exception` plus an immutable ordered snapshot of successfully started process IDs. It SHALL be neither component nor process and SHALL not register itself. Its only accepted startup attempt SHALL validate the complete fixed-path Hub configuration before starting anything, construct and start Registry first, register Registry as `nenjim.registry.service`, record it, construct and register the existing shared passive graph, then lazily construct, register, start, and record only the remaining configured processes in exact order beginning with Journal. The selected liquidity process SHALL additionally compose its dedicated passive dependencies lazily at its configured entry. Registry and Journal managers SHALL remain distinct passive, unregistered handles retained privately by the Hub. Registration SHALL never imply startup. The Hub SHALL preserve the online point and blocking wait only after all configured processes succeed, without retry, rollback, dependency inference, or reordering.
|
||||
|
||||
#### Scenario: Registry starts before passive graph construction
|
||||
- **WHEN** the Hub has validated a complete startup plan and performs its first startup attempt
|
||||
@@ -244,6 +244,14 @@ The reference Registry construction boundary SHALL create one Registry object ex
|
||||
- **WHEN** any passive composition, selected construction, registration, or process startup operation throws
|
||||
- **THEN** the exception propagates, no later process is constructed or started, the Hub neither announces online nor enters its final wait, and no generic rollback or retry is attempted
|
||||
|
||||
#### Scenario: Dedicated liquidity bindings use the liquidity signer
|
||||
- **WHEN** `evelyn.liquidity.prod` is selected and its construction succeeds
|
||||
- **THEN** `solana.wallet.evelyn-liquidity-prod`, `jupiter.swap.evelyn-liquidity-prod` and `raydium.service.evelyn-liquidity-prod` are registered, the swap service uses the dedicated liquidity wallet and Raydium uses direct chain reads
|
||||
|
||||
#### Scenario: Liquidity service has orthogonal domain and lifecycle views
|
||||
- **WHEN** the selected liquidity process is registered
|
||||
- **THEN** the same object resolves as `EvelynLiquidityService` and `NenjimProcess`, while the domain interface exposes no lifecycle operations
|
||||
|
||||
### Requirement: Started-process snapshots are ordered immutable and truthful
|
||||
`NenjimHub.getStartedProcessIds()` SHALL be safe before, during, and after startup and SHALL return an immutable point-in-time snapshot in successful startup order. A process ID SHALL be appended only after that process's `start()` call returns successfully, including `nenjim.javafx-runtime.service` and `nenjim.process-manager.default`. Earlier successful entries SHALL remain visible after a later failure, while a failing process SHALL never be included. The snapshot SHALL make no health or continued-liveness guarantee.
|
||||
|
||||
|
||||
@@ -70,22 +70,26 @@ Every active `[AUTO_START]` line SHALL be parsed through the canonical `NenjimCo
|
||||
- **THEN** validation fails before Registry startup and identifies the required placement
|
||||
|
||||
### Requirement: Registry bootstraps eager passive composition
|
||||
After validating the entire plan, the Hub SHALL construct the configured Registry subsystem, start Registry, register its service and process object as `nenjim.registry.service`, and record that ID only after successful startup. The Hub SHALL then eagerly construct and register the complete current passive component graph needed by selectable processes. This passive phase SHALL not construct any canonical `NenjimProcess`, start background workers, initialize JavaFX, add startup IDs, infer process dependencies, or implicitly create an omitted process. Plain internal dependencies that are not `NenjimComponent` objects MAY be constructed as part of passive composition.
|
||||
After validating the entire plan, the Hub SHALL construct the configured Registry subsystem, start Registry, register its service and process object as `nenjim.registry.service`, and record that ID only after successful startup. The Hub SHALL then eagerly construct and register the existing shared passive component graph. Dedicated liquidity deployment configuration, wallet signer, Jupiter swap service and direct Raydium service SHALL instead be constructed and registered only when the configured `evelyn.liquidity.prod` entry is reached. This passive phase SHALL not construct any canonical `NenjimProcess`, start background workers, initialize JavaFX, add startup IDs, infer process dependencies, or implicitly create an omitted process. Plain internal dependencies that are not `NenjimComponent` objects MAY be constructed as part of passive composition.
|
||||
|
||||
#### Scenario: Registry surrounds passive composition
|
||||
- **WHEN** a valid Hub startup begins
|
||||
- **THEN** Registry starts, is registered, and is recorded before the complete passive component graph is constructed and registered
|
||||
- **THEN** Registry starts, is registered, and is recorded before the existing shared passive component graph is constructed and registered
|
||||
|
||||
#### Scenario: Passive composition completes
|
||||
- **WHEN** Registry bootstrap succeeds
|
||||
- **THEN** all current non-process building blocks, adapters, and services are constructed and registered without starting a process
|
||||
- **THEN** all existing shared non-process building blocks, adapters, and services are constructed and registered without starting a process
|
||||
|
||||
#### Scenario: Passive construction fails
|
||||
- **WHEN** any passive component cannot be constructed or registered
|
||||
- **THEN** startup stops immediately with Registry remaining recorded and no configured process after Registry constructed or started
|
||||
|
||||
#### Scenario: Liquidity is omitted
|
||||
- **WHEN** the plan omits `evelyn.liquidity.prod`
|
||||
- **THEN** its deployment configuration, dedicated signer/wallet, Jupiter service and direct Raydium service are neither loaded nor constructed nor registered
|
||||
|
||||
### Requirement: Selected processes are constructed and started lazily in exact order
|
||||
The Hub SHALL support the modern process IDs `nenjim.registry.service`, `nenjim.journal.service`, `assetaz.ticker.default`, `jupiter-perps-alarm.default`, `evelyn.service.prod`, `evelyn.service.test`, `evelyn.iou-burner.prod`, `nenjim.javafx-runtime.service`, `evelyn.mission-control.default`, and `nenjim.process-manager.default` through implementation-only construction capability that creates no process instance during validation. After passive composition, the Hub SHALL visit every configured entry after Registry in declared order, beginning with Journal. At each entry it SHALL construct only that process and any process-specific passive administration view, register the process under the exact configured ID, resolve it through the canonical `NenjimProcess` contract where applicable, call `start()`, and record the ID only after successful return.
|
||||
The Hub SHALL support the modern process IDs `nenjim.registry.service`, `nenjim.journal.service`, `assetaz.ticker.default`, `jupiter-perps-alarm.default`, `evelyn.status.prod`, `evelyn.status.test`, `evelyn.iou-burner.prod`, `evelyn.liquidity.prod`, `nenjim.javafx-runtime.service`, `evelyn.mission-control.default`, and `nenjim.process-manager.default` through implementation-only construction capability that creates no process instance during validation. After passive composition, the Hub SHALL visit every configured entry after Registry in declared order, beginning with Journal. At each entry it SHALL construct only that process and any explicitly composed process-specific passive dependencies or administration view, register the process under the exact configured ID, resolve it through the canonical `NenjimProcess` contract where applicable, call `start()`, and record the ID only after successful return.
|
||||
|
||||
The configuration SHALL be the complete manually ordered process dependency closure. The Hub SHALL not discover, insert, recursively construct, reorder, or automatically start dependencies. A process omitted from `[AUTO_START]` SHALL not be constructed, registered, started, or recorded, even when its implementation is available. Legacy process prototypes outside the modern Hub graph SHALL not be selectable.
|
||||
|
||||
@@ -109,6 +113,18 @@ The configuration SHALL be the complete manually ordered process dependency clos
|
||||
- **WHEN** a valid headless plan omits `nenjim.javafx-runtime.service`, `evelyn.mission-control.default`, and `nenjim.process-manager.default`
|
||||
- **THEN** none of those objects is constructed or registered and the JavaFX toolkit remains uninitialized by the Hub
|
||||
|
||||
#### Scenario: Renamed status services retain dependency wiring
|
||||
- **WHEN** `evelyn.status.prod` and `evelyn.status.test` are listed after Ticker and before Mission Control
|
||||
- **THEN** the Hub constructs separate Production/Test status services, registers the renamed IDs, and injects the corresponding typed service views into Mission Control in the configured order
|
||||
|
||||
#### Scenario: Deployment still contains an old status ID
|
||||
- **WHEN** a private deployment configuration contains `evelyn.service.prod` or `evelyn.service.test`
|
||||
- **THEN** validation rejects the unknown ID before startup; deployment migration replaces only that ID with `evelyn.status.prod` or `evelyn.status.test`, preserving selection and order
|
||||
|
||||
#### Scenario: Liquidity is explicitly selected
|
||||
- **WHEN** `evelyn.liquidity.prod` is reached in a valid startup plan
|
||||
- **THEN** the Hub loads its runtime configuration, binds the dedicated signer/wallet and Jupiter service to each other, constructs direct Raydium access and registers/starts the liquidity process without inserting a Ticker or status dependency or reordering the plan
|
||||
|
||||
### Requirement: Configured startup is fail-fast and reports actual milestones
|
||||
Registry and every selected process SHALL be registered before its `start()` call and appended to the Hub's immutable ordered startup snapshot only after `start()` returns successfully. Construction failure SHALL leave the selected process unregistered. Registration or startup failure SHALL stop the plan before any later entry is constructed or started; a startup failure MAY leave that process registered but SHALL not record it. Earlier successful processes SHALL remain in snapshots, no rollback or retry SHALL occur, and the Hub SHALL announce its existing online point and enter its blocking runtime wait only after every configured process succeeds.
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# pde-encoding Specification
|
||||
|
||||
## Purpose
|
||||
Provide reusable primitive binary encoding and bounded structural decoding for positional AssetAZ journal data.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Primitive representation
|
||||
The PDE layer SHALL implement Jenkov PDE integers, UTF-8, bytes, typed null and length-delimited OBJECT framing only. Integer magnitudes and lengths use little endian; negative integers encode -(value + 1). It MUST reject wrong types, malformed lengths, invalid UTF-8 and values outside the requested Java int/long range.
|
||||
|
||||
#### Scenario: Integer extremes
|
||||
- **WHEN** signed long extrema and nullable strings/bytes are encoded
|
||||
- **THEN** they round-trip without loss and null is distinguished from empty.
|
||||
|
||||
### Requirement: Bounded decoding
|
||||
Every decoder SHALL operate on byte[], offset and length; child objects SHALL have independent logical bounds. requireEnd SHALL reject unconsumed values using the logical limit, never backing-array size. Framing SHALL expose total object size before payload decoding.
|
||||
|
||||
#### Scenario: Reused backing storage
|
||||
- **WHEN** a short record follows a long record in one buffer
|
||||
- **THEN** stale bytes are invisible and truncated children are rejected.
|
||||
|
||||
### Requirement: Abstraction boundary
|
||||
PDE SHALL know nothing about UUID, Instant, events, sequence numbers or checksums. It MUST NOT implement object mapping, reflection, annotation schemas or PDL.
|
||||
|
||||
#### Scenario: Domain payload decoding
|
||||
- **WHEN** a UUID byte value is decoded
|
||||
- **THEN** PDE validates its binary framing while the codec validates its 16-byte schema.
|
||||
@@ -0,0 +1,37 @@
|
||||
# raydium-clmm-liquidity-withdrawal Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Provide a narrowly scoped protected Raydium CLMM full liquidity decrease using the owner's existing Solana signing and submission primitives.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Full withdrawal preserves the reusable position
|
||||
The operation SHALL validate the NFT owner, pool and positive current liquidity, decrease that complete liquidity into the same wallet, collect pool-token fees and preserve the position account, NFT and NFT token account. For each pool or reward mint it SHALL reuse an existing owner token holding, including a non-associated account, after validating its mint, owner, program and initialized state. It SHALL use or create an ATA only when no owner holding exists and SHALL NOT fall back to account creation when holdings discovery fails. Transaction/account details SHALL remain behind Raydium. It SHALL submit once and use existing confirmation behavior without close, burn or service-level retry.
|
||||
|
||||
#### Scenario: Full protected withdrawal
|
||||
- **WHEN** a wallet withdraws its position's complete liquidity
|
||||
- **THEN** the transaction names the expected wallet, pool, liquidity and nonzero minimum output protection and contains no close-position, NFT burn or token-account close instruction
|
||||
|
||||
#### Scenario: Wrong owner or changed position
|
||||
- **WHEN** ownership, pool or current position state is inconsistent with the requested withdrawal
|
||||
- **THEN** the operation fails before signing
|
||||
|
||||
#### Scenario: Existing non-associated token accounts
|
||||
- **WHEN** the wallet already has a pool-token or reward holding outside its ATA under either supported token program
|
||||
- **THEN** the withdrawal targets that validated account and creates no additional account for that mint, preserving subsequent wallet balance reads
|
||||
|
||||
#### Scenario: Existing holding cannot be trusted
|
||||
- **WHEN** holdings discovery fails or the selected account has a wrong mint, owner, token program or initialized state
|
||||
- **THEN** withdrawal fails before signing without creating a replacement token account
|
||||
|
||||
### Requirement: Minimum output derives from precise state and explicit slippage
|
||||
Withdrawal minimums SHALL derive from current Q64.64 pool state, exact CLMM tick/liquidity math and configured slippage with downward raw-token rounding. Both minimums SHALL NOT be unconditionally zero. Required legacy/Token-2022 accounts, tick arrays and bitmap extension SHALL follow the official decrease instruction. Unsupported token extensions SHALL fail safely before signing.
|
||||
|
||||
#### Scenario: One-sided principal and fees
|
||||
- **WHEN** the range is fully below the normalized current price
|
||||
- **THEN** SyrupUSDC principal is protected, any EVE fees return to the wallet and all received EVE remains there
|
||||
|
||||
#### Scenario: Precision and tiny position
|
||||
- **WHEN** expected principal is too small to provide a positive raw-unit minimum
|
||||
- **THEN** the operation fails rather than submitting an unchecked withdrawal
|
||||
@@ -1,41 +0,0 @@
|
||||
package com.fanitas.evelyn.core;
|
||||
|
||||
import com.r35157.nenjim.component.NenjimComponent;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Collects and exposes the status-index history for one named Evelyn instance.
|
||||
*/
|
||||
public interface Evelyn extends NenjimComponent, AutoCloseable {
|
||||
/**
|
||||
* Executes the legacy Evelyn service operation.
|
||||
*
|
||||
* @throws Exception when service execution fails
|
||||
*/
|
||||
void executeService() throws Exception;
|
||||
|
||||
/**
|
||||
* Restores persisted history and starts status-index sampling.
|
||||
*/
|
||||
void start();
|
||||
|
||||
/**
|
||||
* Stops sampling and clears memory while retaining persistent ownership.
|
||||
*/
|
||||
void stop();
|
||||
|
||||
/**
|
||||
* Permanently closes this instance and releases its persistent name.
|
||||
*/
|
||||
@Override
|
||||
void close();
|
||||
|
||||
/**
|
||||
* Returns an immutable snapshot containing only durably persisted points.
|
||||
*
|
||||
* @return complete current history ordered from oldest to newest
|
||||
*/
|
||||
@NotNull List<EvelynStatusIndexPoint> getStatusIndexHistory();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.r35157.assetaz.events;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/** A fact/state change, never a command. Sequence, not semantic time, orders facts.
|
||||
* Replay rebuilds state without repeating external side effects. Inseparable changes
|
||||
* belong in one event; correlation groups independent facts, without atomicity.
|
||||
*/
|
||||
public interface AssetAZEvent {
|
||||
@NotNull Instant occurredAt();
|
||||
@Nullable String note();
|
||||
@Nullable UUID correlationId();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.r35157.assetaz.events;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record AssetOwnerRegistered(
|
||||
@NotNull Instant occurredAt,
|
||||
@NotNull UUID assetOwnerId,
|
||||
@NotNull AssetOwnerType assetOwnerType,
|
||||
@NotNull ΩNameΩ name,
|
||||
@Nullable String note,
|
||||
@Nullable UUID correlationId
|
||||
) implements AssetAZEvent { }
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.r35157.assetaz.events;
|
||||
|
||||
/** Juridical owner category. Persistent values are textual, never enum ordinals. */
|
||||
public enum AssetOwnerType { NATURAL_PERSON, LEGAL_PERSON }
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.r35157.assetaz.events;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Ordinary chained fact; target format takes effect for subsequent records. */
|
||||
public record JournalFormatVersionChanged(
|
||||
@NotNull Instant occurredAt,
|
||||
int journalFormatVersion,
|
||||
@Nullable String note,
|
||||
@Nullable UUID correlationId
|
||||
) implements AssetAZEvent { }
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.r35157.assetaz.services.journal;
|
||||
|
||||
import com.r35157.assetaz.events.AssetAZEvent;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/**
|
||||
* Sequential callbacks on one subscription worker, identical for replay and live facts.
|
||||
* Each event must update the externally visible model atomically, or leave it unchanged
|
||||
* on failure. Rebuild state only during catch-up; never replay historical side effects.
|
||||
*/
|
||||
public interface AssetAZJournalConsumer {
|
||||
void onEvent(long sequence, @NotNull AssetAZEvent event) throws Exception;
|
||||
/** Successful ONLINE return establishes readiness to act. This callback should
|
||||
* establish readiness without itself performing irreversible domain actions. */
|
||||
void requestState(@NotNull AssetAZJournalConsumerState state) throws Exception;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.r35157.assetaz.services.journal;
|
||||
|
||||
public enum AssetAZJournalConsumerState { CATCHING_UP, ONLINE, DEGRADED }
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.r35157.assetaz.services.journal;
|
||||
|
||||
import com.r35157.assetaz.events.AssetAZEvent;
|
||||
import com.r35157.nenjim.component.NenjimComponent;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.Set;
|
||||
|
||||
/** Authoritative append-only fact journal. Lifecycle belongs to the Nenjim process view. */
|
||||
public interface AssetAZJournalService extends NenjimComponent {
|
||||
/** Returns the committed global sequence only after durable persistence. */
|
||||
long append(@NotNull AssetAZEvent event);
|
||||
/** Returns without waiting for replay. Scan starts inclusively at replayFromSequence,
|
||||
* in [1, registration head + 1]. Empty filters mean all; nonempty filters match
|
||||
* exact runtime classes. The same consumer object cannot register twice. */
|
||||
@NotNull AssetAZJournalSubscription registerConsumer(
|
||||
long replayFromSequence,
|
||||
@NotNull Set<Class<? extends AssetAZEvent>> eventTypes,
|
||||
@NotNull AssetAZJournalConsumer consumer);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.r35157.assetaz.services.journal;
|
||||
|
||||
import com.r35157.assetaz.services.journal.exception.DegradedException;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/** A permanent registration; v1 has no unregister or degraded recovery. */
|
||||
public interface AssetAZJournalSubscription {
|
||||
/** All records through this cursor were handled or examined as irrelevant. */
|
||||
long getProcessedThroughSequence();
|
||||
@NotNull AssetAZJournalConsumerState getState();
|
||||
/** Minimum freshness, not a historical snapshot. Succeeds even when degraded
|
||||
* if the safe cursor already meets the minimum; otherwise degradation throws. */
|
||||
void awaitProcessedThrough(long requiredSequence) throws DegradedException, InterruptedException;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.r35157.assetaz.services.journal.codec;
|
||||
|
||||
import com.r35157.assetaz.events.AssetAZEvent;
|
||||
import com.r35157.libs.pde.PDEEncoder;
|
||||
import com.r35157.libs.pde.PDEDecoder;
|
||||
import com.r35157.nenjim.component.NenjimComponent;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/** One persistent schema mapped to a current domain type, obtained through Registry.
|
||||
* Implementations must be thread-safe. Only one codec per domain type is a writer;
|
||||
* historical read-only versions may coexist. No reflection discovery is involved.
|
||||
*/
|
||||
public interface AssetAZEventCodec extends NenjimComponent {
|
||||
@NotNull String eventType();
|
||||
int eventVersion();
|
||||
@NotNull Class<? extends AssetAZEvent> eventClass();
|
||||
boolean supportsEncoding();
|
||||
void encode(@NotNull AssetAZEvent event, @NotNull PDEEncoder payload);
|
||||
@NotNull AssetAZEvent decode(@NotNull PDEDecoder payload);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.r35157.assetaz.services.journal.codec;
|
||||
|
||||
/** Domain schema rejection, distinct from PDE validity and record integrity. */
|
||||
public final class EventSchemaException extends IllegalArgumentException {
|
||||
public EventSchemaException(String message) { super("event/schema validation failure: " + message); }
|
||||
public EventSchemaException(String message, Throwable cause) { super("event/schema validation failure: " + message, cause); }
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.r35157.assetaz.services.journal.codec.impl.ref;
|
||||
|
||||
import com.r35157.assetaz.events.AssetAZEvent;
|
||||
import com.r35157.assetaz.events.AssetOwnerRegistered;
|
||||
import com.r35157.assetaz.events.AssetOwnerType;
|
||||
import com.r35157.assetaz.services.journal.codec.AssetAZEventCodec;
|
||||
import com.r35157.assetaz.services.journal.codec.EventSchemaException;
|
||||
import com.r35157.libs.pde.PDEDecoder;
|
||||
import com.r35157.libs.pde.PDEEncoder;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Persistent AssetOwnerRegistered schema 1, with exactly seven positional values. */
|
||||
public final class AssetOwnerRegisteredCodec implements AssetAZEventCodec {
|
||||
@Override public @NotNull String eventType() { return "AssetOwnerRegistered"; }
|
||||
@Override public int eventVersion() { return 1; }
|
||||
@Override public @NotNull Class<AssetOwnerRegistered> eventClass() { return AssetOwnerRegistered.class; }
|
||||
@Override public boolean supportsEncoding() { return true; }
|
||||
@Override public void encode(@NotNull AssetAZEvent event, @NotNull PDEEncoder out) {
|
||||
if (!(event instanceof AssetOwnerRegistered owner)) throw new EventSchemaException("expected AssetOwnerRegistered");
|
||||
EventFields.writeCommon(owner, out);
|
||||
out.writeBytes(EventFields.uuid(EventFields.required(owner.assetOwnerId(), "assetOwnerId")));
|
||||
out.writeUtf8(EventFields.required(owner.assetOwnerType(), "assetOwnerType").name());
|
||||
out.writeUtf8(EventFields.required(owner.name(), "name"));
|
||||
}
|
||||
@Override public @NotNull AssetOwnerRegistered decode(@NotNull PDEDecoder in) {
|
||||
Instant occurredAt = EventFields.instant(in);
|
||||
String note = in.readNullableUtf8();
|
||||
UUID correlationId = EventFields.uuid(in.readNullableBytes());
|
||||
UUID ownerId = EventFields.required(EventFields.uuid(in.readNullableBytes()), "assetOwnerId");
|
||||
String type = EventFields.required(in.readNullableUtf8(), "assetOwnerType");
|
||||
AssetOwnerType ownerType;
|
||||
try { ownerType = AssetOwnerType.valueOf(type); }
|
||||
catch (IllegalArgumentException failure) { throw new EventSchemaException("unknown AssetOwnerType " + type, failure); }
|
||||
ΩNameΩ name = EventFields.required(in.readNullableUtf8(), "name");
|
||||
EventFields.end(in);
|
||||
return new AssetOwnerRegistered(occurredAt, ownerId, ownerType, name, note, correlationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.r35157.assetaz.services.journal.codec.impl.ref;
|
||||
|
||||
import com.r35157.assetaz.events.AssetAZEvent;
|
||||
import com.r35157.assetaz.services.journal.codec.EventSchemaException;
|
||||
import com.r35157.libs.pde.PDEDecoder;
|
||||
import com.r35157.libs.pde.PDEEncoder;
|
||||
import com.r35157.libs.pde.PDEException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Codec-level domain conversions. PDE itself has no UUID or Instant semantics. */
|
||||
final class EventFields {
|
||||
private EventFields() { }
|
||||
static void writeCommon(AssetAZEvent event, PDEEncoder out) {
|
||||
required(event.occurredAt(), "occurredAt");
|
||||
out.writeInteger(event.occurredAt().getEpochSecond());
|
||||
out.writeInteger(event.occurredAt().getNano());
|
||||
out.writeNullableUtf8(event.note());
|
||||
out.writeNullableBytes(uuid(event.correlationId()));
|
||||
}
|
||||
static Instant instant(PDEDecoder in) {
|
||||
long seconds = in.readLong();
|
||||
int nanos = in.readInt();
|
||||
if (nanos < 0 || nanos > 999999999) throw new EventSchemaException("invalid nano-of-second");
|
||||
try { return Instant.ofEpochSecond(seconds, nanos); }
|
||||
catch (DateTimeException failure) { throw new EventSchemaException("invalid occurredAt", failure); }
|
||||
}
|
||||
static byte[] uuid(UUID value) {
|
||||
return value == null ? null : ByteBuffer.allocate(16)
|
||||
.putLong(value.getMostSignificantBits()).putLong(value.getLeastSignificantBits()).array();
|
||||
}
|
||||
static UUID uuid(byte[] bytes) {
|
||||
if (bytes == null) return null;
|
||||
if (bytes.length != 16) throw new EventSchemaException("UUID must contain exactly 16 bytes");
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
return new UUID(buffer.getLong(), buffer.getLong());
|
||||
}
|
||||
static <T> T required(T value, String name) {
|
||||
if (value == null) throw new EventSchemaException(name + " is required");
|
||||
return value;
|
||||
}
|
||||
static void end(PDEDecoder in) {
|
||||
try { in.requireEnd(); }
|
||||
catch (PDEException failure) { throw new EventSchemaException("unexpected trailing payload values", failure); }
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.r35157.assetaz.services.journal.codec.impl.ref;
|
||||
|
||||
import com.r35157.assetaz.events.AssetAZEvent;
|
||||
import com.r35157.assetaz.events.JournalFormatVersionChanged;
|
||||
import com.r35157.assetaz.services.journal.codec.AssetAZEventCodec;
|
||||
import com.r35157.assetaz.services.journal.codec.EventSchemaException;
|
||||
import com.r35157.libs.pde.PDEDecoder;
|
||||
import com.r35157.libs.pde.PDEEncoder;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Common event fields followed by the positive target journal format. */
|
||||
public final class JournalFormatVersionChangedCodec implements AssetAZEventCodec {
|
||||
@Override public @NotNull String eventType() { return "JournalFormatVersionChanged"; }
|
||||
@Override public int eventVersion() { return 1; }
|
||||
@Override public @NotNull Class<JournalFormatVersionChanged> eventClass() { return JournalFormatVersionChanged.class; }
|
||||
@Override public boolean supportsEncoding() { return true; }
|
||||
@Override public void encode(@NotNull AssetAZEvent event, @NotNull PDEEncoder out) {
|
||||
if (!(event instanceof JournalFormatVersionChanged changed)) throw new EventSchemaException("expected JournalFormatVersionChanged");
|
||||
EventFields.writeCommon(changed, out);
|
||||
out.writeInteger(positive(changed.journalFormatVersion()));
|
||||
}
|
||||
@Override public @NotNull JournalFormatVersionChanged decode(@NotNull PDEDecoder in) {
|
||||
Instant occurredAt = EventFields.instant(in);
|
||||
String note = in.readNullableUtf8();
|
||||
UUID correlation = EventFields.uuid(in.readNullableBytes());
|
||||
int version = positive(in.readInt());
|
||||
EventFields.end(in);
|
||||
return new JournalFormatVersionChanged(occurredAt, version, note, correlation);
|
||||
}
|
||||
private static int positive(int version) {
|
||||
if (version < 1) throw new EventSchemaException("journal format must be positive");
|
||||
return version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.r35157.assetaz.services.journal.exception;
|
||||
|
||||
/** Requested freshness exceeds this consumer's last safely processed sequence. */
|
||||
public final class DegradedException extends Exception {
|
||||
public DegradedException(String message, Throwable cause) { super(message, cause); }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.r35157.assetaz.services.journal.exception;
|
||||
|
||||
/** Authoritative history could not be verified or durably appended. */
|
||||
public final class JournalException extends IllegalStateException {
|
||||
public JournalException(String message) { super(message); }
|
||||
public JournalException(String message, Throwable cause) { super(message, cause); }
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.r35157.assetaz.services.journal.impl.ref;
|
||||
|
||||
import com.r35157.assetaz.events.AssetAZEvent;
|
||||
import com.r35157.assetaz.services.journal.AssetAZJournalConsumer;
|
||||
import com.r35157.assetaz.services.journal.AssetAZJournalService;
|
||||
import com.r35157.assetaz.services.journal.AssetAZJournalSubscription;
|
||||
import com.r35157.assetaz.services.journal.exception.JournalException;
|
||||
import com.r35157.nenjim.process.NenjimProcess;
|
||||
import com.r35157.nenjim.service.registry.NenjimRegistryService;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Hub-managed lifetime process; public API has no restart/recovery operations. */
|
||||
public final class AssetAZJournalServiceImpl implements AssetAZJournalService, NenjimProcess {
|
||||
private final NenjimRegistryService registry;
|
||||
private final Path path;
|
||||
private final IdentityHashMap<AssetAZJournalConsumer, JournalSubscription> subscriptions = new IdentityHashMap<>();
|
||||
private boolean startAttempted;
|
||||
private boolean started;
|
||||
private Throwable writeFailure;
|
||||
private FileChannel channel;
|
||||
private FileLock fileLock;
|
||||
private EventCodecs codecs;
|
||||
private long head;
|
||||
private byte[] checksum;
|
||||
|
||||
public AssetAZJournalServiceImpl(@NotNull NenjimRegistryService registry, @NotNull Path path) {
|
||||
this.registry = Objects.requireNonNull(registry, "registry");
|
||||
this.path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
|
||||
}
|
||||
@Override public synchronized void start() throws IOException {
|
||||
if (startAttempted) throw new IllegalStateException("AssetAZ journal start already attempted");
|
||||
startAttempted = true;
|
||||
codecs = new EventCodecs(registry);
|
||||
Files.createDirectories(path.getParent());
|
||||
FileChannel opened = FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.READ, StandardOpenOption.WRITE);
|
||||
try {
|
||||
fileLock = opened.tryLock();
|
||||
if (fileLock == null) throw new JournalException("AssetAZ journal is already owned by another process");
|
||||
try (JournalRecords.Reader reader = new JournalRecords.Reader(path, codecs)) {
|
||||
while (reader.next() != null) { /* Complete authoritative validation. */ }
|
||||
head = reader.sequence();
|
||||
checksum = reader.checksum();
|
||||
}
|
||||
opened.position(opened.size());
|
||||
channel = opened;
|
||||
started = true;
|
||||
} catch (IOException | RuntimeException | Error failure) {
|
||||
try { opened.close(); } catch (IOException closeFailure) { failure.addSuppressed(closeFailure); }
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
@Override public synchronized long append(@NotNull AssetAZEvent event) {
|
||||
requireStarted();
|
||||
if (writeFailure != null) throw new JournalException("AssetAZ journal failed closed after uncertain write", writeFailure);
|
||||
Objects.requireNonNull(event, "event");
|
||||
if (head == Long.MAX_VALUE) throw new JournalException("Journal sequence exhausted");
|
||||
long candidate = head + 1;
|
||||
JournalRecords.Encoded encoded = JournalRecords.encode(candidate, checksum, event, codecs);
|
||||
try {
|
||||
ByteBuffer bytes = ByteBuffer.wrap(encoded.bytes());
|
||||
while (bytes.hasRemaining()) channel.write(bytes);
|
||||
channel.force(true);
|
||||
} catch (IOException | RuntimeException | Error failure) {
|
||||
// Retain the file lock and failed state for this JVM. Never retry/truncate.
|
||||
writeFailure = failure;
|
||||
throw new JournalException("Uncertain append at candidate sequence " + candidate + "; journal failed closed", failure);
|
||||
}
|
||||
head = candidate;
|
||||
checksum = encoded.checksum();
|
||||
JournalRecords.Record record = new JournalRecords.Record(candidate, event, checksum);
|
||||
for (JournalSubscription subscription : subscriptions.values()) subscription.publish(record);
|
||||
return candidate;
|
||||
}
|
||||
@Override public synchronized @NotNull AssetAZJournalSubscription registerConsumer(
|
||||
long replayFromSequence, @NotNull Set<Class<? extends AssetAZEvent>> eventTypes,
|
||||
@NotNull AssetAZJournalConsumer consumer) {
|
||||
requireStarted();
|
||||
Objects.requireNonNull(consumer, "consumer");
|
||||
Set<Class<? extends AssetAZEvent>> types = Set.copyOf(Objects.requireNonNull(eventTypes, "eventTypes"));
|
||||
if (replayFromSequence < 1 || (replayFromSequence > head && replayFromSequence - 1 != head)) {
|
||||
throw new IllegalArgumentException("replayFromSequence must be in [1, registration head + 1]");
|
||||
}
|
||||
if (subscriptions.containsKey(consumer)) throw new IllegalArgumentException("Consumer instance already registered");
|
||||
JournalSubscription subscription = new JournalSubscription(path, codecs, replayFromSequence, head, types, consumer);
|
||||
subscriptions.put(consumer, subscription);
|
||||
subscription.start();
|
||||
return subscription;
|
||||
}
|
||||
private void requireStarted() {
|
||||
if (!started) throw new IllegalStateException("AssetAZ journal has not successfully started");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.r35157.assetaz.services.journal.impl.ref;
|
||||
|
||||
import com.r35157.assetaz.events.AssetAZEvent;
|
||||
import com.r35157.assetaz.services.journal.codec.AssetAZEventCodec;
|
||||
import com.r35157.assetaz.services.journal.exception.JournalException;
|
||||
import com.r35157.nenjim.service.registry.NenjimRegistryService;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Immutable catalogue snapshot; no concrete codec construction or reflection. */
|
||||
final class EventCodecs {
|
||||
private record Key(String type, int version) { }
|
||||
private final Map<Key, AssetAZEventCodec> readers = new HashMap<>();
|
||||
private final Map<Class<? extends AssetAZEvent>, AssetAZEventCodec> writers = new HashMap<>();
|
||||
EventCodecs(NenjimRegistryService registry) {
|
||||
for (var id : registry.getComponentIds(AssetAZEventCodec.class)) {
|
||||
AssetAZEventCodec codec = Objects.requireNonNull(registry.getComponent(id, AssetAZEventCodec.class), "registered codec");
|
||||
String type = Objects.requireNonNull(codec.eventType(), "eventType");
|
||||
if (!type.matches("[A-Za-z][A-Za-z0-9_]*") || codec.eventVersion() < 1) {
|
||||
throw new JournalException("Invalid stable codec identity: " + id);
|
||||
}
|
||||
Objects.requireNonNull(codec.eventClass(), "eventClass");
|
||||
if (readers.putIfAbsent(new Key(type, codec.eventVersion()), codec) != null) {
|
||||
throw new JournalException("Duplicate event codec: " + type + "/" + codec.eventVersion());
|
||||
}
|
||||
if (codec.supportsEncoding() && writers.putIfAbsent(codec.eventClass(), codec) != null) {
|
||||
throw new JournalException("Duplicate current encoder: " + codec.eventClass().getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
AssetAZEventCodec reader(String type, int version) {
|
||||
AssetAZEventCodec codec = readers.get(new Key(type, version));
|
||||
if (codec == null) throw new JournalException("unknown event codec: " + type + "/" + version);
|
||||
return codec;
|
||||
}
|
||||
AssetAZEventCodec writer(AssetAZEvent event) {
|
||||
AssetAZEventCodec codec = writers.get(event.getClass());
|
||||
if (codec == null) throw new JournalException("unknown event codec for append: " + event.getClass().getName());
|
||||
return codec;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.r35157.assetaz.services.journal.impl.ref;
|
||||
|
||||
import com.r35157.assetaz.events.AssetAZEvent;
|
||||
import com.r35157.assetaz.events.JournalFormatVersionChanged;
|
||||
import com.r35157.assetaz.services.journal.codec.AssetAZEventCodec;
|
||||
import com.r35157.assetaz.services.journal.codec.EventSchemaException;
|
||||
import com.r35157.assetaz.services.journal.exception.JournalException;
|
||||
import com.r35157.libs.pde.impl.ref.PDEDecoderImpl;
|
||||
import com.r35157.libs.pde.impl.ref.PDEEncoderImpl;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Arrays;
|
||||
|
||||
/** Record integrity owns hashes, chain, sequence and file boundaries, never schemas. */
|
||||
final class JournalRecords {
|
||||
private static final int MAX_RECORD_SIZE_BYTES = 1024;
|
||||
private JournalRecords() { }
|
||||
record Record(long sequence, AssetAZEvent event, byte[] checksum) { }
|
||||
record Encoded(byte[] bytes, byte[] checksum) { }
|
||||
|
||||
static byte[] initialChecksum() { return digest(new byte[0], 0, 0); }
|
||||
static Encoded encode(long sequence, byte[] previous, AssetAZEvent event, EventCodecs codecs) {
|
||||
requireSupportedFormat(event);
|
||||
AssetAZEventCodec codec = codecs.writer(event);
|
||||
PDEEncoderImpl body = new PDEEncoderImpl();
|
||||
body.writeBytes(previous);
|
||||
body.writeInteger(sequence);
|
||||
body.writeUtf8(codec.eventType());
|
||||
body.writeInteger(codec.eventVersion());
|
||||
body.writeObject(payload -> codec.encode(event, payload));
|
||||
byte[] bodyBytes = body.toByteArray();
|
||||
byte[] checksum = digest(bodyBytes, 0, bodyBytes.length);
|
||||
PDEEncoderImpl checksumField = new PDEEncoderImpl();
|
||||
checksumField.writeBytes(checksum);
|
||||
byte[] prefix = checksumField.toByteArray();
|
||||
byte[] fields = Arrays.copyOf(prefix, Math.addExact(prefix.length, bodyBytes.length));
|
||||
System.arraycopy(bodyBytes, 0, fields, prefix.length, bodyBytes.length);
|
||||
PDEEncoderImpl record = new PDEEncoderImpl();
|
||||
record.writeEncodedObject(fields);
|
||||
byte[] bytes = record.toByteArray();
|
||||
if (bytes.length > MAX_RECORD_SIZE_BYTES) throw integrity("record exceeds 1024 bytes");
|
||||
return new Encoded(bytes, checksum);
|
||||
}
|
||||
static void requireSupportedFormat(AssetAZEvent event) {
|
||||
if (event instanceof JournalFormatVersionChanged changed && changed.journalFormatVersion() != 1) {
|
||||
throw new JournalException("Unsupported journal format: " + changed.journalFormatVersion());
|
||||
}
|
||||
}
|
||||
private static byte[] digest(byte[] bytes, int offset, int length) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
digest.update(bytes, offset, length);
|
||||
return digest.digest();
|
||||
} catch (NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
|
||||
}
|
||||
private static JournalException integrity(String message) {
|
||||
return new JournalException("record integrity failure: " + message);
|
||||
}
|
||||
|
||||
/** Each instance owns exactly one fixed record buffer, reused without clearing. */
|
||||
static final class Reader implements AutoCloseable {
|
||||
private final FileChannel channel;
|
||||
private final EventCodecs codecs;
|
||||
private final byte[] buffer = new byte[MAX_RECORD_SIZE_BYTES];
|
||||
private long offset;
|
||||
private long sequence;
|
||||
private byte[] previous = initialChecksum();
|
||||
|
||||
Reader(Path path, EventCodecs codecs) throws IOException {
|
||||
channel = FileChannel.open(path, StandardOpenOption.READ);
|
||||
this.codecs = codecs;
|
||||
}
|
||||
long sequence() { return sequence; }
|
||||
byte[] checksum() { return previous; }
|
||||
Record next() throws IOException {
|
||||
long remaining = channel.size() - offset;
|
||||
if (remaining == 0) return null;
|
||||
try {
|
||||
if (remaining < 1) throw integrity("file shrank during reading");
|
||||
readFully(0, 1);
|
||||
int headerSize = 1 + PDEDecoderImpl.objectLengthWidth(buffer[0] & 255);
|
||||
if (headerSize > remaining) throw integrity("truncated OBJECT header");
|
||||
readFully(1, headerSize - 1);
|
||||
long size = PDEDecoderImpl.objectSize(buffer, 0, headerSize);
|
||||
if (size > MAX_RECORD_SIZE_BYTES) throw integrity("declared record exceeds 1024 bytes");
|
||||
if (size > remaining) throw integrity("declared record extends beyond journal file");
|
||||
readFully(headerSize, (int) size - headerSize);
|
||||
PDEDecoderImpl outer = new PDEDecoderImpl(buffer, 0, (int) size);
|
||||
PDEDecoderImpl fields = outer.readObject();
|
||||
outer.requireEnd();
|
||||
byte[] checksum = fields.readBytes();
|
||||
if (checksum.length != 32) throw integrity("checksum must be 32 bytes");
|
||||
byte[] calculated = digest(buffer, fields.position(), fields.limit() - fields.position());
|
||||
if (!MessageDigest.isEqual(checksum, calculated)) throw integrity("checksum mismatch");
|
||||
byte[] predecessor = fields.readBytes();
|
||||
if (predecessor.length != 32 || !MessageDigest.isEqual(previous, predecessor)) {
|
||||
throw integrity("previousChecksum mismatch");
|
||||
}
|
||||
long nextSequence = fields.readLong();
|
||||
if (sequence == Long.MAX_VALUE || nextSequence != sequence + 1) throw integrity("sequence discontinuity");
|
||||
String type = fields.readUtf8();
|
||||
int version = fields.readInt();
|
||||
PDEDecoderImpl payload = fields.readObject();
|
||||
fields.requireEnd();
|
||||
AssetAZEventCodec codec = codecs.reader(type, version);
|
||||
AssetAZEvent event = codec.decode(payload);
|
||||
// The codec owns its schema, but cannot leave an envelope payload unread.
|
||||
payload.requireEnd();
|
||||
if (event == null || event.getClass() != codec.eventClass()) {
|
||||
throw new EventSchemaException("codec returned wrong domain type");
|
||||
}
|
||||
requireSupportedFormat(event);
|
||||
offset += size;
|
||||
sequence = nextSequence;
|
||||
previous = checksum;
|
||||
return new Record(sequence, event, checksum);
|
||||
} catch (RuntimeException failure) {
|
||||
throw new JournalException("Journal byte offset " + offset + ", expected sequence "
|
||||
+ (sequence + 1) + ": " + failure.getMessage(), failure);
|
||||
}
|
||||
}
|
||||
private void readFully(int start, int length) throws IOException {
|
||||
ByteBuffer target = ByteBuffer.wrap(buffer, start, length);
|
||||
while (target.hasRemaining()) {
|
||||
if (channel.read(target) < 0) throw integrity("truncated record");
|
||||
}
|
||||
}
|
||||
@Override public void close() throws IOException { channel.close(); }
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package com.r35157.assetaz.services.journal.impl.ref;
|
||||
|
||||
import com.r35157.assetaz.events.AssetAZEvent;
|
||||
import com.r35157.assetaz.services.journal.AssetAZJournalConsumer;
|
||||
import com.r35157.assetaz.services.journal.AssetAZJournalConsumerState;
|
||||
import com.r35157.assetaz.services.journal.AssetAZJournalSubscription;
|
||||
import com.r35157.assetaz.services.journal.exception.DegradedException;
|
||||
import com.r35157.assetaz.services.journal.exception.JournalException;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Set;
|
||||
import static com.r35157.assetaz.services.journal.AssetAZJournalConsumerState.*;
|
||||
|
||||
/** One independent, permanently registered sequential worker. */
|
||||
final class JournalSubscription implements AssetAZJournalSubscription {
|
||||
private static final Logger log = LoggerFactory.getLogger(JournalSubscription.class);
|
||||
private final Path path;
|
||||
private final EventCodecs codecs;
|
||||
private final long replayFrom;
|
||||
private final long registrationHead;
|
||||
private final Set<Class<? extends AssetAZEvent>> types;
|
||||
private final AssetAZJournalConsumer consumer;
|
||||
private final ArrayDeque<JournalRecords.Record> pending = new ArrayDeque<>();
|
||||
private long processedThrough;
|
||||
private AssetAZJournalConsumerState state = CATCHING_UP;
|
||||
private Throwable failure;
|
||||
|
||||
JournalSubscription(Path path, EventCodecs codecs, long replayFrom, long registrationHead,
|
||||
Set<Class<? extends AssetAZEvent>> types, AssetAZJournalConsumer consumer) {
|
||||
this.path = path; this.codecs = codecs; this.replayFrom = replayFrom;
|
||||
this.registrationHead = registrationHead; this.types = types; this.consumer = consumer;
|
||||
processedThrough = replayFrom - 1;
|
||||
}
|
||||
void start() {
|
||||
Thread.ofVirtual().name("assetaz-journal-" + identity()).start(this::run);
|
||||
}
|
||||
synchronized void publish(JournalRecords.Record record) {
|
||||
if (state == DEGRADED) return;
|
||||
pending.addLast(record);
|
||||
notifyAll();
|
||||
}
|
||||
@Override public synchronized long getProcessedThroughSequence() { return processedThrough; }
|
||||
@Override public synchronized @NotNull AssetAZJournalConsumerState getState() { return state; }
|
||||
@Override public synchronized void awaitProcessedThrough(long requiredSequence)
|
||||
throws DegradedException, InterruptedException {
|
||||
while (processedThrough < requiredSequence) {
|
||||
if (state == DEGRADED) throw new DegradedException(
|
||||
"Consumer " + identity() + " is degraded through " + processedThrough
|
||||
+ "; required " + requiredSequence, failure);
|
||||
wait();
|
||||
}
|
||||
}
|
||||
private void run() {
|
||||
long currentSequence = replayFrom;
|
||||
try {
|
||||
if (replayFrom <= registrationHead) {
|
||||
try (JournalRecords.Reader reader = new JournalRecords.Reader(path, codecs)) {
|
||||
while (reader.sequence() < registrationHead) {
|
||||
JournalRecords.Record record = reader.next();
|
||||
if (record == null) throw new JournalException("record integrity failure: replay ended before registration head");
|
||||
currentSequence = record.sequence();
|
||||
if (record.sequence() >= replayFrom) process(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean online = false;
|
||||
while (true) {
|
||||
JournalRecords.Record record;
|
||||
synchronized (this) {
|
||||
while (online && pending.isEmpty()) wait();
|
||||
record = pending.pollFirst();
|
||||
// Empty queue under the publication monitor chooses the cutover.
|
||||
// Arrivals after this point queue as normal post-ONLINE events.
|
||||
}
|
||||
if (record == null) {
|
||||
currentSequence = getProcessedThroughSequence();
|
||||
consumer.requestState(ONLINE);
|
||||
synchronized (this) { state = ONLINE; notifyAll(); }
|
||||
online = true;
|
||||
} else {
|
||||
currentSequence = record.sequence();
|
||||
process(record);
|
||||
}
|
||||
}
|
||||
} catch (Throwable exception) {
|
||||
if (exception instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||
degrade(exception, currentSequence);
|
||||
}
|
||||
}
|
||||
private void process(JournalRecords.Record record) throws Exception {
|
||||
if (types.isEmpty() || types.contains(record.event().getClass())) {
|
||||
consumer.onEvent(record.sequence(), record.event());
|
||||
}
|
||||
synchronized (this) { processedThrough = record.sequence(); notifyAll(); }
|
||||
}
|
||||
private void degrade(Throwable exception, long sequence) {
|
||||
AssetAZJournalConsumerState oldState;
|
||||
synchronized (this) {
|
||||
oldState = state; failure = exception; state = DEGRADED;
|
||||
pending.clear(); notifyAll();
|
||||
}
|
||||
log.error("AssetAZ journal consumer {} failed at sequence {}, state {}, safe cursor {}",
|
||||
identity(), sequence, oldState, getProcessedThroughSequence(), exception);
|
||||
}
|
||||
private String identity() {
|
||||
return consumer.getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(consumer));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.r35157.evelyn.emc.impl.ref;
|
||||
|
||||
import com.fanitas.evelyn.core.Evelyn;
|
||||
import com.r35157.evelyn.service.status.EvelynStatusService;
|
||||
import com.fanitas.evelyn.core.EvelynStatusIndexPoint;
|
||||
import com.r35157.evelyn.emc.EvelynMissionControl;
|
||||
import com.r35157.nenjim.process.NenjimProcess;
|
||||
@@ -33,12 +33,12 @@ public final class EvelynMissionControlImpl implements
|
||||
NenjimProcess {
|
||||
|
||||
public EvelynMissionControlImpl(
|
||||
@NotNull Evelyn evelynServiceProd,
|
||||
@NotNull Evelyn evelynServiceTest,
|
||||
@NotNull EvelynStatusService evelynStatusServiceProd,
|
||||
@NotNull EvelynStatusService evelynStatusServiceTest,
|
||||
@NotNull JavaFxRuntimeService javaFxRuntimeService
|
||||
) {
|
||||
this.evelynProd = Objects.requireNonNull(evelynServiceProd, "evelynServiceProd");
|
||||
this.evelynTest = Objects.requireNonNull(evelynServiceTest, "evelynServiceTest");
|
||||
this.evelynStatusProd = Objects.requireNonNull(evelynStatusServiceProd, "evelynStatusServiceProd");
|
||||
this.evelynStatusTest = Objects.requireNonNull(evelynStatusServiceTest, "evelynStatusServiceTest");
|
||||
this.javaFxRuntimeService = Objects.requireNonNull(
|
||||
javaFxRuntimeService,
|
||||
"javaFxRuntimeService");
|
||||
@@ -253,8 +253,8 @@ public final class EvelynMissionControlImpl implements
|
||||
}
|
||||
|
||||
private void refreshCharts() {
|
||||
refreshChart(productionChart, evelynProd.getStatusIndexHistory());
|
||||
refreshChart(testChart, evelynTest.getStatusIndexHistory());
|
||||
refreshChart(productionChart, evelynStatusProd.getStatusIndexHistory());
|
||||
refreshChart(testChart, evelynStatusTest.getStatusIndexHistory());
|
||||
}
|
||||
|
||||
static final class StatusIndexChart {
|
||||
@@ -313,8 +313,8 @@ public final class EvelynMissionControlImpl implements
|
||||
}
|
||||
};
|
||||
|
||||
private final Evelyn evelynProd;
|
||||
private final Evelyn evelynTest;
|
||||
private final EvelynStatusService evelynStatusProd;
|
||||
private final EvelynStatusService evelynStatusTest;
|
||||
private final JavaFxRuntimeService javaFxRuntimeService;
|
||||
private final Timeline refreshTimeline = new Timeline(new KeyFrame(
|
||||
Duration.seconds(CHART_REFRESH_SECONDS),
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.r35157.evelyn.service.liquidity;
|
||||
|
||||
import com.r35157.nenjim.component.NenjimComponent;
|
||||
|
||||
/** Realizes manually funded Evelyn liquidity into USDC payouts; lifecycle belongs to the Hub. */
|
||||
public interface EvelynLiquidityService extends NenjimComponent { }
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.r35157.evelyn.service.liquidity.impl.ref;
|
||||
|
||||
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
|
||||
import com.r35157.libs.configuration.ConfigurationFormatVersionValidator;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/** Versioned deployment input. Signer configuration itself remains external to this file. */
|
||||
public record EvelynLiquidityConfiguration(
|
||||
@NotNull ΩSolanaWalletIdΩ walletAddress,
|
||||
@NotNull String signerKeyName,
|
||||
@NotNull ΩRaydiumLiquidityPoolConcentratedIdΩ poolId,
|
||||
@NotNull BigDecimal harvestDistancePercent,
|
||||
@NotNull Duration iterationInterval,
|
||||
@NotNull ΩSolanaWalletIdΩ usdcRecipient,
|
||||
@NotNull ΩAmountΩ minimumSyrupUSDCReserve,
|
||||
@NotNull ΩUSDCAmountΩ minimumUSDCReserve,
|
||||
int maximumSwapSlippageBps,
|
||||
int maximumWithdrawalSlippageBps) {
|
||||
public EvelynLiquidityConfiguration {
|
||||
requireAddress(walletAddress, "LIQUIDITY_WALLET");
|
||||
requireAddress(poolId, "CLMM_POOL");
|
||||
requireAddress(usdcRecipient, "USDC_RECIPIENT");
|
||||
if (walletAddress.equals(usdcRecipient)) throw new IllegalArgumentException("USDC recipient must differ from liquidity wallet");
|
||||
if (signerKeyName == null || !signerKeyName.matches("[A-Za-z0-9][A-Za-z0-9._-]*")) {
|
||||
throw new IllegalArgumentException("SIGNER_KEY_NAME must name an external jup signer");
|
||||
}
|
||||
Objects.requireNonNull(harvestDistancePercent, "harvestDistancePercent");
|
||||
if (harvestDistancePercent.signum() < 0 || harvestDistancePercent.compareTo(new BigDecimal("100")) >= 0) {
|
||||
throw new IllegalArgumentException("HARVEST_DISTANCE_PERCENT must be in [0,100)");
|
||||
}
|
||||
Objects.requireNonNull(iterationInterval, "iterationInterval");
|
||||
if (iterationInterval.isNegative() || iterationInterval.isZero()) throw new IllegalArgumentException("ITERATION_INTERVAL_SECONDS must be positive");
|
||||
try { iterationInterval.toNanos(); }
|
||||
catch (ArithmeticException exception) { throw new IllegalArgumentException("Iteration interval is too large", exception); }
|
||||
requireReserve(minimumSyrupUSDCReserve, "MINIMUM_SYRUPUSDC_RESERVE");
|
||||
requireReserve(minimumUSDCReserve, "MINIMUM_USDC_RESERVE");
|
||||
if (maximumSwapSlippageBps < 0 || maximumSwapSlippageBps > 10000) throw new IllegalArgumentException("MAXIMUM_SWAP_SLIPPAGE_BPS must be in [0,10000]");
|
||||
if (maximumWithdrawalSlippageBps < 0 || maximumWithdrawalSlippageBps >= 10000) throw new IllegalArgumentException("MAXIMUM_WITHDRAWAL_SLIPPAGE_BPS must be in [0,10000)");
|
||||
}
|
||||
|
||||
public static @NotNull EvelynLiquidityConfiguration parse(@NotNull Path path) throws IOException {
|
||||
// Deliberately no .example fallback.
|
||||
List<String> lines = Files.readAllLines(path);
|
||||
int versionLine = ConfigurationFormatVersionValidator.validate(path, lines, 1);
|
||||
Map<String, String> values = new HashMap<>();
|
||||
for (int i = versionLine + 1; i < lines.size(); i++) {
|
||||
String line = lines.get(i).split("#", 2)[0].strip();
|
||||
if (line.isEmpty()) continue;
|
||||
String[] parts = line.split("=", 2);
|
||||
if (parts.length != 2 || !KEYS.contains(parts[0].strip()) || parts[1].isBlank()) {
|
||||
throw new IllegalArgumentException(path + ":" + (i + 1) + ": Invalid liquidity configuration entry");
|
||||
}
|
||||
if (values.putIfAbsent(parts[0].strip(), parts[1].strip()) != null) {
|
||||
throw new IllegalArgumentException(path + ":" + (i + 1) + ": Duplicate " + parts[0].strip());
|
||||
}
|
||||
}
|
||||
if (!values.keySet().equals(KEYS)) {
|
||||
Set<String> missing = new TreeSet<>(KEYS); missing.removeAll(values.keySet());
|
||||
throw new IllegalArgumentException(path + ": Missing settings " + missing);
|
||||
}
|
||||
try {
|
||||
return new EvelynLiquidityConfiguration(values.get("LIQUIDITY_WALLET"), values.get("SIGNER_KEY_NAME"),
|
||||
values.get("CLMM_POOL"), new BigDecimal(values.get("HARVEST_DISTANCE_PERCENT")),
|
||||
Duration.ofSeconds(Long.parseLong(values.get("ITERATION_INTERVAL_SECONDS"))),
|
||||
values.get("USDC_RECIPIENT"), new BigDecimal(values.get("MINIMUM_SYRUPUSDC_RESERVE")),
|
||||
new BigDecimal(values.get("MINIMUM_USDC_RESERVE")), Integer.parseInt(values.get("MAXIMUM_SWAP_SLIPPAGE_BPS")),
|
||||
Integer.parseInt(values.get("MAXIMUM_WITHDRAWAL_SLIPPAGE_BPS")));
|
||||
} catch (IllegalArgumentException | ArithmeticException exception) {
|
||||
throw new IllegalArgumentException(path + ": Invalid liquidity settings: " + exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireAddress(String value, String setting) {
|
||||
try {
|
||||
if (value == null || new Base58CodecImpl().decode(value).length != 32) throw new IllegalArgumentException();
|
||||
} catch (IllegalArgumentException exception) { throw new IllegalArgumentException(setting + " must be a 32-byte Solana address", exception); }
|
||||
}
|
||||
private static void requireReserve(BigDecimal value, String setting) {
|
||||
if (value == null || value.signum() < 0) throw new IllegalArgumentException(setting + " must be nonnegative");
|
||||
}
|
||||
private static final Set<String> KEYS = Set.of("LIQUIDITY_WALLET", "SIGNER_KEY_NAME", "CLMM_POOL", "HARVEST_DISTANCE_PERCENT",
|
||||
"ITERATION_INTERVAL_SECONDS", "USDC_RECIPIENT", "MINIMUM_SYRUPUSDC_RESERVE", "MINIMUM_USDC_RESERVE",
|
||||
"MAXIMUM_SWAP_SLIPPAGE_BPS", "MAXIMUM_WITHDRAWAL_SLIPPAGE_BPS");
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package com.r35157.evelyn.service.liquidity.impl.ref;
|
||||
|
||||
import com.r35157.assetaz.services.cis.CurrencyIdentityService;
|
||||
import com.r35157.evelyn.service.liquidity.EvelynLiquidityService;
|
||||
import com.r35157.cryptowallet.solana.MissingRecipientTokenAccountPolicy;
|
||||
import com.r35157.cryptowallet.solana.SolanaWallet;
|
||||
import com.r35157.libs.jupiter.swap.JupiterSwapService;
|
||||
import com.r35157.libs.jupiter.swap.JupiterSwapResult;
|
||||
import com.r35157.libs.raydium.*;
|
||||
import com.r35157.libs.solana.*;
|
||||
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
|
||||
import com.r35157.nenjim.process.NenjimProcess;
|
||||
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.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.*;
|
||||
import static com.r35157.assetaz.services.cis.ExternalCurrencyReference.SOLANA_MINT_NAMESPACE;
|
||||
|
||||
/** Three independent steps; the chain and wallet are the only durable queue. */
|
||||
public final class EvelynLiquidityServiceImpl implements EvelynLiquidityService, NenjimProcess, AutoCloseable {
|
||||
public EvelynLiquidityServiceImpl(@NotNull SolanaWallet wallet, @NotNull JupiterSwapService swap,
|
||||
@NotNull Raydium raydium, @NotNull SolanaBlockChain chain,
|
||||
@NotNull CurrencyIdentityService cis, @NotNull EvelynLiquidityConfiguration configuration) {
|
||||
this.wallet = Objects.requireNonNull(wallet);
|
||||
this.swap = Objects.requireNonNull(swap);
|
||||
this.raydium = Objects.requireNonNull(raydium);
|
||||
this.chain = Objects.requireNonNull(chain);
|
||||
this.cis = Objects.requireNonNull(cis);
|
||||
this.configuration = Objects.requireNonNull(configuration);
|
||||
if (!wallet.getAddress().equals(configuration.walletAddress())) throw new IllegalArgumentException("Liquidity wallet/configuration mismatch");
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void start() throws IOException, InterruptedException {
|
||||
if (startAttempted || closed) throw new IllegalStateException("Liquidity service start is single-use");
|
||||
startAttempted = true;
|
||||
initialize(); // All configuration, identities and chain metadata before worker creation.
|
||||
worker = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "evelyn-liquidity"); thread.setDaemon(true); return thread;
|
||||
});
|
||||
shutdownHook = new Thread(this::close, "evelyn-liquidity-shutdown");
|
||||
try {
|
||||
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
||||
worker.scheduleWithFixedDelay(this::runIterationSafely, 0, configuration.iterationInterval().toNanos(), TimeUnit.NANOSECONDS);
|
||||
} catch (RuntimeException exception) { close(); throw exception; }
|
||||
}
|
||||
|
||||
// Package-visible synchronous seam for offline controlled verification. Does no financial work.
|
||||
void initialize() throws IOException, InterruptedException {
|
||||
SolanaAccountInfo recipient = chain.getAccountInfo(configuration.usdcRecipient());
|
||||
if (recipient != null && !"11111111111111111111111111111111".equals(recipient.owner())) {
|
||||
throw new IllegalArgumentException("USDC recipient must be a wallet owner, not a token or program account");
|
||||
}
|
||||
eve = resolveToken(EVE_ID); syrup = resolveToken(SYRUPUSDC_ID); usdc = resolveToken(USDC_ID);
|
||||
requirePrecision(configuration.minimumSyrupUSDCReserve(), syrup.decimals(), "SyrupUSDC reserve");
|
||||
requirePrecision(configuration.minimumUSDCReserve(), usdc.decimals(), "USDC reserve");
|
||||
poolInfo = raydium.fetchConcentratedPoolInfoOnChain(configuration.poolId());
|
||||
if (!configuration.poolId().equals(poolInfo.poolId())) throw new IllegalArgumentException("CLMM pool mismatch");
|
||||
baseIsMintA = eve.mint().equals(poolInfo.mintA()) && syrup.mint().equals(poolInfo.mintB());
|
||||
boolean reversed = syrup.mint().equals(poolInfo.mintA()) && eve.mint().equals(poolInfo.mintB());
|
||||
if ((!baseIsMintA && !reversed)
|
||||
|| poolInfo.mintADecimals() != (baseIsMintA ? eve.decimals() : syrup.decimals())
|
||||
|| poolInfo.mintBDecimals() != (baseIsMintA ? syrup.decimals() : eve.decimals())) {
|
||||
throw new IllegalArgumentException("CLMM pool must contain canonical EVE/SyrupUSDC with matching precision");
|
||||
}
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
void runIteration() throws InterruptedException {
|
||||
if (!initialized) throw new IllegalStateException("Liquidity service not initialized");
|
||||
checkInterrupted();
|
||||
independently("withdraw", this::withdrawEligiblePositions);
|
||||
checkInterrupted();
|
||||
independently("swap", this::swapAvailableSyrup);
|
||||
checkInterrupted();
|
||||
independently("payout", this::sendAvailableUsdc);
|
||||
}
|
||||
|
||||
private void withdrawEligiblePositions() throws Exception {
|
||||
for (ΩRaydiumLiquidityPoolPositionNftIdΩ nft : raydium.fetchConcentratedPositionNftIds(wallet.getAddress())) {
|
||||
checkInterrupted();
|
||||
RaydiumConcentratedPositionState position;
|
||||
try {
|
||||
position = raydium.fetchConcentratedPositionState(nft);
|
||||
if (!nft.equals(position.nftId()) || !configuration.poolId().equals(position.poolId()) || position.liquidity().signum() <= 0) continue;
|
||||
} catch (InterruptedException exception) { throw exception; }
|
||||
catch (Exception exception) { log.warn("withdraw position={} failed", nft, exception); continue; }
|
||||
|
||||
// Discovery and earlier withdrawals may take time; never reuse their price.
|
||||
// A failed pool read ends withdrawals for this iteration, leaving later steps independent.
|
||||
RaydiumConcentratedPoolState state = raydium.fetchConcentratedPoolState(configuration.poolId());
|
||||
if (!configuration.poolId().equals(state.poolId()) || state.sqrtPriceX64() == null || state.sqrtPriceX64().signum() <= 0) {
|
||||
throw new IOException("Unusable current CLMM pool price");
|
||||
}
|
||||
try {
|
||||
if (!RaydiumClmmMath.isAtOrBelowHarvestThreshold(position, state, baseIsMintA, configuration.harvestDistancePercent())) continue;
|
||||
log.info("withdraw position={} liquidity={}", nft, position.liquidity());
|
||||
ΩSolanaTransactionSignatureΩ signature = raydium.withdrawConcentratedPositionLiquidity(wallet, position, state, configuration.maximumWithdrawalSlippageBps());
|
||||
log.info("withdraw position={} liquidity={} signature={}", nft, position.liquidity(), signature);
|
||||
} catch (InterruptedException exception) { throw exception; }
|
||||
catch (Exception exception) { log.warn("withdraw position={} failed", nft, exception); }
|
||||
}
|
||||
}
|
||||
|
||||
private void swapAvailableSyrup() throws Exception {
|
||||
ΩAmountΩ amount = available(syrup, configuration.minimumSyrupUSDCReserve());
|
||||
if (amount.signum() <= 0) return;
|
||||
checkInterrupted();
|
||||
log.info("swap SyrupUSDC amount={} to USDC", amount);
|
||||
JupiterSwapResult result = swap.swap(syrup.mint(), amount, usdc.mint(), configuration.maximumSwapSlippageBps());
|
||||
log.info("swap SyrupUSDC spent={} USDC received={} signature={}", result.spentInputTokenAmount(), result.receivedOutputTokenAmount(), result.transactionSignature());
|
||||
}
|
||||
|
||||
private void sendAvailableUsdc() throws Exception {
|
||||
ΩAmountΩ amount = available(usdc, configuration.minimumUSDCReserve());
|
||||
if (amount.signum() <= 0) return;
|
||||
checkInterrupted();
|
||||
log.info("payout USDC amount={} recipient={}", amount, configuration.usdcRecipient());
|
||||
ΩSolanaTransactionSignatureΩ signature = wallet.sendSPLToken(usdc.mint(), configuration.usdcRecipient(), amount, MissingRecipientTokenAccountPolicy.CREATE_AT_SENDER_EXPENSE);
|
||||
log.info("payout USDC amount={} signature={}", amount, signature);
|
||||
// Existing wallet transfer submits once; wait before the next iteration can reuse balances.
|
||||
SolanaTransactionOutcome outcome = chain.awaitTransaction(signature, SolanaCommitment.CONFIRMED, Duration.ofSeconds(60));
|
||||
if (outcome.status() != SolanaTransactionOutcome.Status.SUCCEEDED) throw new IOException("payout signature=" + signature + " outcome=" + outcome);
|
||||
}
|
||||
|
||||
private ΩAmountΩ available(Token token, ΩAmountΩ reserve) throws IOException, InterruptedException {
|
||||
ΩAmountΩ balance = wallet.getSPLTokenBalance(token.mint(), token.program());
|
||||
if (balance == null || balance.compareTo(reserve) <= 0) return BigDecimal.ZERO;
|
||||
return balance.subtract(reserve).setScale(token.decimals(), RoundingMode.DOWN);
|
||||
}
|
||||
|
||||
private Token resolveToken(UUID id) throws IOException, InterruptedException {
|
||||
cis.resolve(id);
|
||||
List<ΩSPLMintAddressΩ> mints = cis.findExternalReferences(id).stream().filter(reference -> SOLANA_MINT_NAMESPACE.equals(reference.namespace()))
|
||||
.map(reference -> reference.externalId()).filter(mint -> mint != null && !mint.isBlank()).toList();
|
||||
if (mints.size() != 1) throw new IllegalArgumentException("Currency must have one canonical Solana mint: " + id);
|
||||
ΩSPLMintAddressΩ mint = mints.getFirst();
|
||||
SolanaAccountInfo info = chain.getAccountInfo(mint);
|
||||
SolanaSPLTokenProgram program = Arrays.stream(SolanaSPLTokenProgram.values())
|
||||
.filter(candidate -> info != null && candidate.getAddress().equals(info.owner())).findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Missing/unsupported canonical mint: " + mint));
|
||||
byte[] data;
|
||||
try { data = Base64.getDecoder().decode(info.data()); }
|
||||
catch (RuntimeException exception) { throw new IllegalArgumentException("Invalid canonical mint data", exception); }
|
||||
if (data.length < 82 || data[45] != 1) throw new IllegalArgumentException("Canonical mint is not initialized");
|
||||
int decimals = Byte.toUnsignedInt(data[44]);
|
||||
return new Token(mint, program, decimals);
|
||||
}
|
||||
private static void requirePrecision(BigDecimal amount, int decimals, String name) {
|
||||
try { amount.setScale(decimals, RoundingMode.UNNECESSARY); }
|
||||
catch (ArithmeticException exception) { throw new IllegalArgumentException(name + " exceeds mint precision", exception); }
|
||||
}
|
||||
private void independently(String step, Step operation) throws InterruptedException {
|
||||
try { operation.run(); }
|
||||
catch (InterruptedException exception) { throw exception; }
|
||||
catch (Exception exception) { log.warn("{} step failed; independent work continues", step, exception); }
|
||||
}
|
||||
private void runIterationSafely() {
|
||||
try { runIteration(); }
|
||||
catch (InterruptedException exception) { Thread.currentThread().interrupt(); close(); }
|
||||
catch (RuntimeException exception) { log.error("Liquidity iteration failed", exception); }
|
||||
}
|
||||
private void checkInterrupted() throws InterruptedException {
|
||||
if (closed || Thread.currentThread().isInterrupted()) throw new InterruptedException("Liquidity service interrupted");
|
||||
}
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
closed = true;
|
||||
if (worker != null) worker.shutdownNow();
|
||||
if (shutdownHook != null && Thread.currentThread() != shutdownHook) {
|
||||
try { Runtime.getRuntime().removeShutdownHook(shutdownHook); }
|
||||
catch (IllegalStateException ignored) { /* JVM is already shutting down. */ }
|
||||
}
|
||||
}
|
||||
@FunctionalInterface private interface Step { void run() throws Exception; }
|
||||
private record Token(ΩSPLMintAddressΩ mint, SolanaSPLTokenProgram program, ΩamountDecimalsΩ decimals) { }
|
||||
private static final Logger log = LoggerFactory.getLogger(EvelynLiquidityServiceImpl.class);
|
||||
private final SolanaWallet wallet;
|
||||
private final JupiterSwapService swap;
|
||||
private final Raydium raydium;
|
||||
private final SolanaBlockChain chain;
|
||||
private final CurrencyIdentityService cis;
|
||||
private final EvelynLiquidityConfiguration configuration;
|
||||
private Token eve, syrup, usdc;
|
||||
private RaydiumConcentratedPoolInfo poolInfo;
|
||||
private boolean baseIsMintA, initialized, startAttempted;
|
||||
private volatile boolean closed;
|
||||
private ScheduledExecutorService worker;
|
||||
private Thread shutdownHook;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.r35157.evelyn.service.status;
|
||||
|
||||
import com.fanitas.evelyn.core.EvelynStatusIndexPoint;
|
||||
import com.r35157.libs.valuetypes.basic.AssetPrice;
|
||||
import com.r35157.nenjim.component.NenjimComponent;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Collects status-index history and exposes the calculated reference price
|
||||
* for one named Evelyn instance.
|
||||
*/
|
||||
public interface EvelynStatusService extends NenjimComponent, AutoCloseable {
|
||||
/**
|
||||
* Executes the legacy Evelyn service operation.
|
||||
*
|
||||
* @throws Exception when service execution fails
|
||||
*/
|
||||
void executeService() throws Exception;
|
||||
|
||||
/**
|
||||
* Restores persisted history and starts status-index sampling.
|
||||
*/
|
||||
void start();
|
||||
|
||||
/**
|
||||
* Stops sampling and clears memory while retaining persistent ownership.
|
||||
*/
|
||||
void stop();
|
||||
|
||||
/**
|
||||
* Permanently closes this instance and releases its persistent name.
|
||||
*/
|
||||
@Override
|
||||
void close();
|
||||
|
||||
/**
|
||||
* Returns an immutable snapshot containing only durably persisted points.
|
||||
*
|
||||
* @return complete current history ordered from oldest to newest
|
||||
*/
|
||||
@NotNull List<EvelynStatusIndexPoint> getStatusIndexHistory();
|
||||
|
||||
/**
|
||||
* Calculates the EVE reference price for the supplied instant, quoted in
|
||||
* the service's canonical EVE/USDT trading pair. The curve starts at 15.00
|
||||
* on 2026-07-31T22:00:00Z and grows by a factor of 1.20 per 365-day year.
|
||||
* This operation works before sampling starts and while sampling is stopped;
|
||||
* it does not access market prices or history storage or mutate history.
|
||||
*
|
||||
* @param timestamp instant for which to calculate the reference price
|
||||
* @return calculated reference price with its explicit trading pair
|
||||
* @throws NullPointerException if the timestamp is null
|
||||
* @throws IllegalArgumentException if the timestamp precedes the curve start
|
||||
*/
|
||||
@NotNull AssetPrice getReferencePrice(@NotNull Instant timestamp);
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.fanitas.evelyn.core.impl.ref;
|
||||
package com.r35157.evelyn.service.status.impl.ref;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
+28
-19
@@ -1,8 +1,9 @@
|
||||
package com.fanitas.evelyn.core.impl.ref;
|
||||
package com.r35157.evelyn.service.status.impl.ref;
|
||||
|
||||
import com.fanitas.evelyn.core.*;
|
||||
import com.fanitas.evelyn.core.EvelynStatusIndexPoint;
|
||||
import com.fanitas.evelyn.raydium.RaydiumLiquidityPoolPositionConcentrated;
|
||||
|
||||
import com.r35157.evelyn.service.status.EvelynStatusService;
|
||||
import com.r35157.libs.basic.Pair;
|
||||
import com.r35157.libs.basic.Triblet;
|
||||
import com.r35157.libs.raydium.Raydium;
|
||||
@@ -46,9 +47,9 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM;
|
||||
import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.TOKEN_2022_PROGRAM;
|
||||
|
||||
public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
public class EvelynStatusServiceImpl implements EvelynStatusService, NenjimProcess {
|
||||
|
||||
public EvelynImpl(
|
||||
public EvelynStatusServiceImpl(
|
||||
@NotNull String instanceName,
|
||||
@NotNull TickerService tickerService,
|
||||
@NotNull TradingPair eveUsdtTradingPair,
|
||||
@@ -65,7 +66,7 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
);
|
||||
}
|
||||
|
||||
EvelynImpl(
|
||||
EvelynStatusServiceImpl(
|
||||
@NotNull String instanceName,
|
||||
@NotNull TickerService tickerService,
|
||||
@NotNull TradingPair eveUsdtTradingPair,
|
||||
@@ -97,7 +98,7 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
.normalize();
|
||||
if (!validatedDataRoot.equals(validatedInstancePath.getParent())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Evelyn instance name must resolve to a direct child of "
|
||||
"EvelynStatusService instance name must resolve to a direct child of "
|
||||
+ validatedDataRoot + ": " + normalizedInstanceName
|
||||
);
|
||||
}
|
||||
@@ -108,7 +109,7 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
|
||||
if (!RESERVED_INSTANCE_NAMES.add(reservationKey)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Evelyn instance name is already reserved in this JVM: "
|
||||
"EvelynStatusService instance name is already reserved in this JVM: "
|
||||
+ normalizedInstanceName
|
||||
);
|
||||
}
|
||||
@@ -135,13 +136,13 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
public synchronized void start() {
|
||||
if (closeRequested || lifecycleState == LifecycleState.CLOSED) {
|
||||
throw new IllegalStateException(
|
||||
"Closing or closed Evelyn instance cannot be started: "
|
||||
"Closing or closed EvelynStatusService instance cannot be started: "
|
||||
+ instanceName
|
||||
);
|
||||
}
|
||||
if (lifecycleState != LifecycleState.STOPPED) {
|
||||
throw new IllegalStateException(
|
||||
"Evelyn instance cannot start while in state "
|
||||
"EvelynStatusService instance cannot start while in state "
|
||||
+ lifecycleState + ": " + instanceName
|
||||
);
|
||||
}
|
||||
@@ -154,7 +155,7 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
clearStatusIndexHistory();
|
||||
lifecycleState = LifecycleState.STOPPED;
|
||||
throw new IllegalStateException(
|
||||
"Could not start Evelyn instance '" + instanceName
|
||||
"Could not start EvelynStatusService instance '" + instanceName
|
||||
+ "' from " + statusHistoryPath + ": "
|
||||
+ exception.getMessage(),
|
||||
exception
|
||||
@@ -177,7 +178,7 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
clearStatusIndexHistory();
|
||||
lifecycleState = LifecycleState.STOPPED;
|
||||
throw new IllegalStateException(
|
||||
"Could not create Evelyn sampling executor for instance '"
|
||||
"Could not create EvelynStatusService sampling executor for instance '"
|
||||
+ instanceName + "'",
|
||||
exception
|
||||
);
|
||||
@@ -203,7 +204,7 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
clearStatusIndexHistory();
|
||||
lifecycleState = LifecycleState.STOPPED;
|
||||
throw new IllegalStateException(
|
||||
"Could not start Evelyn sampling for instance '"
|
||||
"Could not start EvelynStatusService sampling for instance '"
|
||||
+ instanceName + "'",
|
||||
exception
|
||||
);
|
||||
@@ -222,7 +223,7 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
if (lifecycleState != LifecycleState.STARTED
|
||||
&& lifecycleState != LifecycleState.TERMINATION_FAILED) {
|
||||
throw new IllegalStateException(
|
||||
"Evelyn instance cannot stop while in state "
|
||||
"EvelynStatusService instance cannot stop while in state "
|
||||
+ lifecycleState + ": " + instanceName
|
||||
);
|
||||
}
|
||||
@@ -244,7 +245,7 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
lifecycleState = LifecycleState.TERMINATION_FAILED;
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Interrupted while stopping Evelyn instance '"
|
||||
"Interrupted while stopping EvelynStatusService instance '"
|
||||
+ instanceName + "'; name reservation retained",
|
||||
exception
|
||||
);
|
||||
@@ -255,7 +256,7 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
lifecycleState = LifecycleState.TERMINATION_FAILED;
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Evelyn sampling thread did not terminate for instance '"
|
||||
"EvelynStatusService sampling thread did not terminate for instance '"
|
||||
+ instanceName + "'; name reservation retained"
|
||||
);
|
||||
}
|
||||
@@ -282,7 +283,7 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
if (lifecycleState != LifecycleState.STOPPED
|
||||
|| statusIndexScheduler != null) {
|
||||
throw new IllegalStateException(
|
||||
"Cannot close Evelyn instance while sampling termination "
|
||||
"Cannot close EvelynStatusService instance while sampling termination "
|
||||
+ "is unconfirmed: " + instanceName
|
||||
);
|
||||
}
|
||||
@@ -298,6 +299,14 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull AssetPrice getReferencePrice(@NotNull Instant timestamp) {
|
||||
return new AssetPrice(
|
||||
EvelynPriceIndexCalculator.calculateExpectedPrice(timestamp),
|
||||
eveUsdtTradingPair
|
||||
);
|
||||
}
|
||||
|
||||
private void clearStatusIndexHistory() {
|
||||
synchronized (statusIndexHistory) {
|
||||
statusIndexHistory.clear();
|
||||
@@ -686,7 +695,7 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
private static String validateInstanceName(String instanceName) {
|
||||
Objects.requireNonNull(instanceName, "instanceName");
|
||||
String normalizedName = Normalizer.normalize(instanceName, Normalizer.Form.NFC);
|
||||
if (normalizedName.codePoints().allMatch(EvelynImpl::isWhitespace)
|
||||
if (normalizedName.codePoints().allMatch(EvelynStatusServiceImpl::isWhitespace)
|
||||
|| hasWhitespaceAtBoundary(normalizedName)
|
||||
|| normalizedName.equals(".")
|
||||
|| normalizedName.equals("..")
|
||||
@@ -698,7 +707,7 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
)
|
||||
|| isWindowsReservedFilename(normalizedName)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Unsafe Evelyn instance name: " + normalizedName
|
||||
"Unsafe EvelynStatusService instance name: " + normalizedName
|
||||
);
|
||||
}
|
||||
return normalizedName;
|
||||
@@ -734,7 +743,7 @@ public class EvelynImpl implements Evelyn, NenjimProcess {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(EvelynImpl.class);
|
||||
private static final Logger log = LoggerFactory.getLogger(EvelynStatusServiceImpl.class);
|
||||
private static final Path DATA_ROOT = Path.of("data", "evelyn");
|
||||
private static final String STATUS_HISTORY_FILENAME = "status.log";
|
||||
private static final String FORMAT_VERSION_KEY = "FORMAT_VERSION";
|
||||
@@ -4,6 +4,25 @@ import com.r35157.libs.codec.Base58Codec;
|
||||
|
||||
public class Base58CodecImpl implements Base58Codec {
|
||||
|
||||
/** Decodes Base58 without assuming a particular address/signature width. */
|
||||
public @org.jetbrains.annotations.NotNull byte[] decode(@org.jetbrains.annotations.NotNull ΩBase58StringΩ input) {
|
||||
java.util.Objects.requireNonNull(input, "input");
|
||||
java.math.BigInteger value = java.math.BigInteger.ZERO;
|
||||
String alphabet = new String(ALPHABET);
|
||||
for (char character : input.toCharArray()) {
|
||||
int digit = alphabet.indexOf(character);
|
||||
if (digit < 0) throw new IllegalArgumentException("Invalid Base58 character");
|
||||
value = value.multiply(java.math.BigInteger.valueOf(58)).add(java.math.BigInteger.valueOf(digit));
|
||||
}
|
||||
int zeros = 0;
|
||||
while (zeros < input.length() && input.charAt(zeros) == '1') zeros++;
|
||||
byte[] encoded = value.toByteArray();
|
||||
int start = encoded[0] == 0 ? 1 : 0;
|
||||
byte[] result = new byte[zeros + encoded.length - start];
|
||||
System.arraycopy(encoded, start, result, zeros, encoded.length - start);
|
||||
return result;
|
||||
}
|
||||
|
||||
public String encode(byte[] input) {
|
||||
if (input.length == 0) {
|
||||
return "";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.r35157.libs.pde;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/** Decoder restricted to a logical byte range, including each child object. */
|
||||
public interface PDEDecoder {
|
||||
int readInt();
|
||||
long readLong();
|
||||
@NotNull String readUtf8();
|
||||
@Nullable String readNullableUtf8();
|
||||
byte @NotNull [] readBytes();
|
||||
byte @Nullable [] readNullableBytes();
|
||||
@NotNull PDEDecoder readObject();
|
||||
void requireEnd();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.r35157.libs.pde;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Primitive PDE writer; domain conversions belong to codecs. */
|
||||
public interface PDEEncoder {
|
||||
void writeInteger(long value);
|
||||
void writeUtf8(@NotNull String value);
|
||||
void writeNullableUtf8(@Nullable String value);
|
||||
void writeBytes(byte @NotNull [] value);
|
||||
void writeNullableBytes(byte @Nullable [] value);
|
||||
void writeObject(@NotNull Consumer<PDEEncoder> writer);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.r35157.libs.pde;
|
||||
|
||||
/** Invalid PDE type, range, encoding or framing (not a domain schema error). */
|
||||
public final class PDEException extends IllegalArgumentException {
|
||||
public PDEException(String message) { super("PDE decoding/framing failure: " + message); }
|
||||
public PDEException(String message, Throwable cause) { super("PDE decoding/framing failure: " + message, cause); }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.r35157.libs.pde.impl.ref;
|
||||
|
||||
import com.r35157.libs.pde.PDEDecoder;
|
||||
import com.r35157.libs.pde.PDEException;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class PDEDecoderImpl implements PDEDecoder {
|
||||
private final byte[] bytes;
|
||||
private final int limit;
|
||||
private int position;
|
||||
|
||||
public PDEDecoderImpl(byte @NotNull [] bytes, int offset, int length) {
|
||||
Objects.checkFromIndexSize(offset, length, bytes.length);
|
||||
this.bytes = bytes; position = offset; limit = offset + length;
|
||||
}
|
||||
/** Absolute backing-array offset, for hashing original encoded ranges. */
|
||||
public int position() { return position; }
|
||||
public int limit() { return limit; }
|
||||
@Override public int readInt() {
|
||||
long value = readLong();
|
||||
if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) throw error("integer outside int range");
|
||||
return (int) value;
|
||||
}
|
||||
@Override public long readLong() {
|
||||
int type = next();
|
||||
if (type < 4 || type > 19) throw error("expected non-null integer");
|
||||
boolean negative = type >= 12;
|
||||
int width = type - (negative ? 11 : 3);
|
||||
long magnitude = unsigned(width);
|
||||
if (magnitude < 0) throw error("integer outside long range");
|
||||
return negative ? -1 - magnitude : magnitude;
|
||||
}
|
||||
@Override public @NotNull String readUtf8() {
|
||||
String result = readNullableUtf8();
|
||||
if (result == null) throw error("required UTF-8 is null");
|
||||
return result;
|
||||
}
|
||||
@Override public @Nullable String readNullableUtf8() {
|
||||
int length = fieldLength(48, 49, 65);
|
||||
if (length < 0) return null;
|
||||
require(length);
|
||||
try {
|
||||
String result = StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes, position, length)).toString();
|
||||
position += length;
|
||||
return result;
|
||||
} catch (CharacterCodingException failure) { throw new PDEException("invalid UTF-8", failure); }
|
||||
}
|
||||
@Override public byte @NotNull [] readBytes() {
|
||||
byte[] result = readNullableBytes();
|
||||
if (result == null) throw error("required bytes are null");
|
||||
return result;
|
||||
}
|
||||
@Override public byte @Nullable [] readNullableBytes() {
|
||||
int length = fieldLength(23, 24, 40);
|
||||
if (length < 0) return null;
|
||||
require(length);
|
||||
byte[] result = Arrays.copyOfRange(bytes, position, position + length);
|
||||
position += length;
|
||||
return result;
|
||||
}
|
||||
@Override public @NotNull PDEDecoderImpl readObject() {
|
||||
int width = objectLengthWidth(next());
|
||||
int length = length(width);
|
||||
require(length);
|
||||
PDEDecoderImpl child = new PDEDecoderImpl(bytes, position, length);
|
||||
position += length;
|
||||
return child;
|
||||
}
|
||||
@Override public void requireEnd() {
|
||||
if (position != limit) throw error("unexpected trailing values");
|
||||
}
|
||||
/** Number of following length bytes for a non-null OBJECT type. */
|
||||
public static int objectLengthWidth(int type) {
|
||||
if (type < 120 || type > 127) throw new PDEException("expected non-null OBJECT");
|
||||
return type - 119;
|
||||
}
|
||||
/** Inspect just an OBJECT header; the body need not be present yet. */
|
||||
public static long objectSize(byte @NotNull [] bytes, int offset, int headerLength) {
|
||||
PDEDecoderImpl header = new PDEDecoderImpl(bytes, offset, headerLength);
|
||||
int width = objectLengthWidth(header.next());
|
||||
long size = header.unsigned(width);
|
||||
header.requireEnd();
|
||||
if (size < 0 || size > Long.MAX_VALUE - headerLength) throw error("OBJECT length overflow");
|
||||
return size + headerLength;
|
||||
}
|
||||
private int fieldLength(int nullType, int shortBase, int lengthBase) {
|
||||
int type = next();
|
||||
if (type == nullType) return -1;
|
||||
if (type >= shortBase && type <= shortBase + 15) return type - shortBase;
|
||||
if (type >= lengthBase && type <= lengthBase + 7) return length(type - lengthBase + 1);
|
||||
throw error("unexpected primitive type " + type);
|
||||
}
|
||||
private int length(int width) {
|
||||
long value = unsigned(width);
|
||||
if (value < 0 || value > Integer.MAX_VALUE) throw error("encoded length outside supported range");
|
||||
return (int) value;
|
||||
}
|
||||
private long unsigned(int width) {
|
||||
require(width);
|
||||
long result = 0;
|
||||
for (int i = 0; i < width; i++) result |= (long) next() << (8 * i);
|
||||
return result;
|
||||
}
|
||||
private int next() { require(1); return bytes[position++] & 255; }
|
||||
private void require(int length) {
|
||||
if (length < 0 || length > limit - position) throw error("truncated value");
|
||||
}
|
||||
private static PDEException error(String message) { return new PDEException(message); }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.r35157.libs.pde.impl.ref;
|
||||
|
||||
import com.r35157.libs.pde.PDEEncoder;
|
||||
import com.r35157.libs.pde.PDEException;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.CharBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Minimal Jenkov PDE: little-endian magnitudes/lengths, typed nulls and objects. */
|
||||
public final class PDEEncoderImpl implements PDEEncoder {
|
||||
private final ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
|
||||
@Override public void writeInteger(long value) {
|
||||
long magnitude = value < 0 ? -(value + 1) : value;
|
||||
int width = width(magnitude);
|
||||
output.write((value < 0 ? 11 : 3) + width);
|
||||
littleEndian(magnitude, width);
|
||||
}
|
||||
@Override public void writeUtf8(@NotNull String value) {
|
||||
Objects.requireNonNull(value, "value");
|
||||
try {
|
||||
var encoded = StandardCharsets.UTF_8.newEncoder().encode(CharBuffer.wrap(value));
|
||||
byte[] bytes = new byte[encoded.remaining()];
|
||||
encoded.get(bytes);
|
||||
field(49, 65, bytes);
|
||||
} catch (CharacterCodingException failure) {
|
||||
throw new PDEException("invalid UTF-8 input", failure);
|
||||
}
|
||||
}
|
||||
@Override public void writeNullableUtf8(@Nullable String value) {
|
||||
if (value == null) output.write(48); else writeUtf8(value);
|
||||
}
|
||||
@Override public void writeBytes(byte @NotNull [] value) {
|
||||
field(24, 40, Objects.requireNonNull(value, "value"));
|
||||
}
|
||||
@Override public void writeNullableBytes(byte @Nullable [] value) {
|
||||
if (value == null) output.write(23); else writeBytes(value);
|
||||
}
|
||||
@Override public void writeObject(@NotNull Consumer<PDEEncoder> writer) {
|
||||
PDEEncoderImpl child = new PDEEncoderImpl();
|
||||
Objects.requireNonNull(writer, "writer").accept(child);
|
||||
writeEncodedObject(child.toByteArray());
|
||||
}
|
||||
/** Wrap already encoded PDE fields without semantic re-encoding. */
|
||||
public void writeEncodedObject(byte @NotNull [] fields) {
|
||||
int width = width(fields.length);
|
||||
output.write(119 + width);
|
||||
littleEndian(fields.length, width);
|
||||
output.writeBytes(fields);
|
||||
}
|
||||
public byte @NotNull [] toByteArray() { return output.toByteArray(); }
|
||||
private void field(int shortBase, int lengthBase, byte[] value) {
|
||||
if (value.length <= 15) output.write(shortBase + value.length);
|
||||
else {
|
||||
int width = width(value.length);
|
||||
output.write(lengthBase + width - 1);
|
||||
littleEndian(value.length, width);
|
||||
}
|
||||
output.writeBytes(value);
|
||||
}
|
||||
private static int width(long value) {
|
||||
return Math.max(1, (64 - Long.numberOfLeadingZeros(value) + 7) / 8);
|
||||
}
|
||||
private void littleEndian(long value, int width) {
|
||||
for (int i = 0; i < width; i++) { output.write((int) value & 255); value >>>= 8; }
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,28 @@ import java.math.BigDecimal;
|
||||
import java.util.Set;
|
||||
|
||||
public interface Raydium extends NenjimComponent {
|
||||
/** Reads validated CLMM mint identities/precision directly from chain. */
|
||||
@org.jetbrains.annotations.NotNull
|
||||
RaydiumConcentratedPoolInfo fetchConcentratedPoolInfoOnChain(
|
||||
@org.jetbrains.annotations.NotNull ΩRaydiumLiquidityPoolConcentratedIdΩ poolId
|
||||
) throws IOException, InterruptedException;
|
||||
|
||||
/**
|
||||
* Withdraws the snapshot's full liquidity and fees to its NFT owner, preserving
|
||||
* the NFT, position and token accounts. Minimums use quoteState and explicit
|
||||
* slippage in [0,10000); the current position must still match the snapshot.
|
||||
* Reuses validated owner token holdings, including non-associated accounts;
|
||||
* creates destination ATAs only when no owner holding exists.
|
||||
* Submits once and awaits CONFIRMED. Failures after submission may be unknown.
|
||||
*/
|
||||
@org.jetbrains.annotations.NotNull
|
||||
ΩSolanaTransactionSignatureΩ withdrawConcentratedPositionLiquidity(
|
||||
@org.jetbrains.annotations.NotNull com.r35157.cryptowallet.solana.SolanaWallet wallet,
|
||||
@org.jetbrains.annotations.NotNull RaydiumConcentratedPositionState position,
|
||||
@org.jetbrains.annotations.NotNull RaydiumConcentratedPoolState quoteState,
|
||||
int maximumSlippageBps
|
||||
) throws IOException, InterruptedException;
|
||||
|
||||
/**
|
||||
* Fetches the current price for a Raydium liquidity pool.
|
||||
*
|
||||
@@ -48,7 +70,9 @@ public interface Raydium extends NenjimComponent {
|
||||
*
|
||||
* <p>The returned set contains position NFT ids, not pool ids. Callers can use the returned
|
||||
* NFT ids to fetch concentrated position state and then determine which pools the positions
|
||||
* belong to.</p>
|
||||
* belong to. Individual candidate or token-program read failures are logged
|
||||
* and isolated, so a readable subset may be returned. Failure to read either
|
||||
* token program fails discovery.</p>
|
||||
*
|
||||
* @param ownerAddress the Solana owner address to inspect
|
||||
* @return the Raydium concentrated liquidity position NFT ids owned by the address
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.r35157.libs.raydium;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
/** Integer Q64.64 math matching Raydium's official tick_math.rs. */
|
||||
public final class RaydiumClmmMath {
|
||||
public static @NotNull BigInteger sqrtPriceAtTick(int tick) {
|
||||
if (tick < -443636 || tick > 443636) {
|
||||
throw new IllegalArgumentException("CLMM tick out of range: " + tick);
|
||||
}
|
||||
int absolute = Math.abs(tick);
|
||||
BigInteger ratio = Q64;
|
||||
for (int bit = 0; bit < FACTORS.length; bit++) {
|
||||
if ((absolute & (1 << bit)) != 0) {
|
||||
ratio = ratio.multiply(FACTORS[bit]).shiftRight(64);
|
||||
}
|
||||
}
|
||||
return tick > 0 ? MAX_U128.divide(ratio) : ratio;
|
||||
}
|
||||
|
||||
/** Decimal factors cancel between position and pool; no rounded UI prices. */
|
||||
public static boolean isAtOrBelowHarvestThreshold(
|
||||
@NotNull RaydiumConcentratedPositionState position,
|
||||
@NotNull RaydiumConcentratedPoolState pool,
|
||||
boolean baseIsMintA,
|
||||
@NotNull BigDecimal harvestDistancePercent) {
|
||||
validate(position, pool);
|
||||
if (harvestDistancePercent.signum() < 0
|
||||
|| harvestDistancePercent.compareTo(new BigDecimal("100")) >= 0) {
|
||||
throw new IllegalArgumentException("Harvest distance must be in [0,100)");
|
||||
}
|
||||
BigDecimal retained = BigDecimal.ONE.subtract(harvestDistancePercent.movePointLeft(2));
|
||||
BigInteger boundary = sqrtPriceAtTick(baseIsMintA
|
||||
? position.tickUpperIndex() : position.tickLowerIndex());
|
||||
BigDecimal boundarySquared = new BigDecimal(boundary.multiply(boundary));
|
||||
BigDecimal marketSquared = new BigDecimal(pool.sqrtPriceX64().pow(2));
|
||||
return baseIsMintA
|
||||
? boundarySquared.compareTo(marketSquared.multiply(retained)) <= 0
|
||||
: marketSquared.compareTo(boundarySquared.multiply(retained)) <= 0;
|
||||
}
|
||||
|
||||
/** Floor principal in raw units, as used by the on-chain decrease. */
|
||||
public static @NotNull BigInteger[] principalRawAmounts(
|
||||
@NotNull RaydiumConcentratedPositionState position,
|
||||
@NotNull RaydiumConcentratedPoolState pool) {
|
||||
validate(position, pool);
|
||||
BigInteger lower = sqrtPriceAtTick(position.tickLowerIndex());
|
||||
BigInteger upper = sqrtPriceAtTick(position.tickUpperIndex());
|
||||
BigInteger current = pool.sqrtPriceX64().max(lower).min(upper);
|
||||
BigInteger amountA = position.liquidity().multiply(Q64)
|
||||
.multiply(upper.subtract(current)).divide(upper.multiply(current));
|
||||
BigInteger amountB = position.liquidity().multiply(current.subtract(lower)).divide(Q64);
|
||||
return new BigInteger[] { amountA, amountB };
|
||||
}
|
||||
|
||||
private static void validate(RaydiumConcentratedPositionState position,
|
||||
RaydiumConcentratedPoolState pool) {
|
||||
if (!position.poolId().equals(pool.poolId()) || position.tickLowerIndex() >= position.tickUpperIndex()
|
||||
|| position.liquidity().signum() < 0 || position.liquidity().bitLength() > 128
|
||||
|| pool.sqrtPriceX64().compareTo(sqrtPriceAtTick(-443636)) < 0
|
||||
|| pool.sqrtPriceX64().compareTo(sqrtPriceAtTick(443636)) >= 0) {
|
||||
throw new IllegalArgumentException("Invalid or mismatched CLMM state");
|
||||
}
|
||||
}
|
||||
|
||||
private static final BigInteger Q64 = BigInteger.ONE.shiftLeft(64);
|
||||
private static final BigInteger MAX_U128 = BigInteger.ONE.shiftLeft(128).subtract(BigInteger.ONE);
|
||||
private static final BigInteger[] FACTORS = java.util.Arrays.stream(new String[] {
|
||||
"fffcb933bd6fb800", "fff97272373d4000", "fff2e50f5f657000", "ffe5caca7e10f000",
|
||||
"ffcb9843d60f7000", "ff973b41fa98e800", "ff2ea16466c9b000", "fe5dee046a9a3800",
|
||||
"fcbe86c7900bb000", "f987a7253ac65800", "f3392b0822bb6000", "e7159475a2caf000",
|
||||
"d097f3bdfd2f2000", "a9f746462d9f8000", "70d869a156f31c00", "31be135f97ed3200",
|
||||
"9aa508b5b85a500", "5d6af8dedc582c", "2216e584f5fa"
|
||||
}).map(hex -> new BigInteger(hex, 16)).toArray(BigInteger[]::new);
|
||||
private RaydiumClmmMath() { }
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package com.r35157.libs.raydium.impl.ref;
|
||||
|
||||
import com.r35157.cryptowallet.solana.SolanaWallet;
|
||||
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
|
||||
import com.r35157.libs.raydium.*;
|
||||
import com.r35157.libs.solana.*;
|
||||
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/** Narrow decrease_liquidity_v2 encoder; never closes positions or token accounts. */
|
||||
final class RaydiumClmmWithdrawal {
|
||||
static final ΩRaydiumProgramIdΩ PROGRAM = "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK";
|
||||
private static final ΩSolanaProgramIdΩ ATA_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
|
||||
private static final ΩSolanaProgramIdΩ SYSTEM = "11111111111111111111111111111111";
|
||||
private static final ΩSolanaProgramIdΩ MEMO = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
|
||||
private static final Base58CodecImpl BASE58 = new Base58CodecImpl();
|
||||
private final SolanaBlockChain chain;
|
||||
|
||||
RaydiumClmmWithdrawal(SolanaBlockChain chain) { this.chain = chain; }
|
||||
|
||||
RaydiumConcentratedPoolInfo poolInfo(ΩRaydiumLiquidityPoolConcentratedIdΩ poolId) throws IOException, InterruptedException {
|
||||
byte[] data = poolData(poolId);
|
||||
ΩSPLMintAddressΩ mintA = address(data, 73), mintB = address(data, 105);
|
||||
int decimalsA = Byte.toUnsignedInt(data[233]), decimalsB = Byte.toUnsignedInt(data[234]);
|
||||
validateMint(mintA, decimalsA);
|
||||
validateMint(mintB, decimalsB);
|
||||
BigDecimal price = new BigDecimal(unsigned(data, 253, 16).pow(2))
|
||||
.divide(new BigDecimal(BigInteger.ONE.shiftLeft(128)), java.math.MathContext.DECIMAL128)
|
||||
.scaleByPowerOfTen(decimalsA - decimalsB);
|
||||
return new RaydiumConcentratedPoolInfo(poolId, mintA, decimalsA, mintB, decimalsB, price);
|
||||
}
|
||||
|
||||
ΩSolanaTransactionSignatureΩ withdraw(SolanaWallet wallet, RaydiumConcentratedPositionState position,
|
||||
RaydiumConcentratedPoolState quoteState, int slippageBps)
|
||||
throws IOException, InterruptedException {
|
||||
if (slippageBps < 0 || slippageBps >= 10000 || position.liquidity().signum() <= 0) {
|
||||
throw new IllegalArgumentException("Withdrawal needs positive liquidity and slippage in [0,10000)");
|
||||
}
|
||||
BigInteger[] expected = RaydiumClmmMath.principalRawAmounts(position, quoteState);
|
||||
BigInteger[] minimum = Arrays.stream(expected)
|
||||
.map(amount -> amount.multiply(BigInteger.valueOf(10000 - slippageBps))
|
||||
.divide(BigInteger.valueOf(10000))).toArray(BigInteger[]::new);
|
||||
if (minimum[0].signum() == 0 && minimum[1].signum() == 0) {
|
||||
throw new IllegalArgumentException("Position too small for protected withdrawal");
|
||||
}
|
||||
byte[] pool = poolData(position.poolId());
|
||||
ΩRaydiumLiquidityPoolPositionIdΩ personal = pda("position", SolanaProgramAddressSeed.solanaAddress(position.nftId()));
|
||||
byte[] personalData = accountData(personal, PROGRAM, "PersonalPositionState", 97);
|
||||
if (!position.nftId().equals(address(personalData, 9))
|
||||
|| !position.poolId().equals(address(personalData, 41))
|
||||
|| position.tickLowerIndex() != integer(personalData, 73)
|
||||
|| position.tickUpperIndex() != integer(personalData, 77)
|
||||
|| !position.liquidity().equals(unsigned(personalData, 81, 16))) {
|
||||
throw new IOException("Position changed or mismatches requested full withdrawal: " + position.nftId());
|
||||
}
|
||||
SolanaSPLTokenProgram nftProgram = mintProgram(position.nftId());
|
||||
SPLTokenHolding nft = chain.getSPLTokenHoldings(wallet.getAddress(), nftProgram).get(position.nftId());
|
||||
if (nft == null || !BigInteger.ONE.equals(new BigInteger(nft.rawAmount())) || nft.decimals() != 0) {
|
||||
throw new IOException("Wallet does not own position NFT: " + position.nftId());
|
||||
}
|
||||
validateTokenAccount(nft.tokenAccount(), position.nftId(), wallet.getAddress(), nftProgram);
|
||||
ΩSPLMintAddressΩ mintA = address(pool, 73), mintB = address(pool, 105);
|
||||
SolanaSPLTokenProgram programA = validateMint(mintA, Byte.toUnsignedInt(pool[233]));
|
||||
SolanaSPLTokenProgram programB = validateMint(mintB, Byte.toUnsignedInt(pool[234]));
|
||||
int tickSpacing = Short.toUnsignedInt(ByteBuffer.wrap(pool, 235, 2).order(ByteOrder.LITTLE_ENDIAN).getShort());
|
||||
if (tickSpacing == 0 || position.tickLowerIndex() % tickSpacing != 0
|
||||
|| position.tickUpperIndex() % tickSpacing != 0 || (pool[389] & 2) != 0) {
|
||||
throw new IOException("Invalid tick spacing or disabled liquidity decrease");
|
||||
}
|
||||
int lowerStart = Math.floorDiv(position.tickLowerIndex(), 60 * tickSpacing) * 60 * tickSpacing;
|
||||
int upperStart = Math.floorDiv(position.tickUpperIndex(), 60 * tickSpacing) * 60 * tickSpacing;
|
||||
ΩSolanaAddressΩ lowerArray = tickArray(position.poolId(), lowerStart);
|
||||
ΩSolanaAddressΩ upperArray = tickArray(position.poolId(), upperStart);
|
||||
validateTickArray(lowerArray, position.poolId(), lowerStart);
|
||||
validateTickArray(upperArray, position.poolId(), upperStart);
|
||||
validateVault(address(pool, 137), mintA, position.poolId(), programA);
|
||||
validateVault(address(pool, 169), mintB, position.poolId(), programB);
|
||||
List<Instruction> instructions = new ArrayList<>();
|
||||
// Allow the decrease plus idempotent token-account creation in one transaction.
|
||||
instructions.add(new Instruction("ComputeBudget111111111111111111111111111111", List.of(),
|
||||
ByteBuffer.allocate(5).order(ByteOrder.LITTLE_ENDIAN).put((byte) 2).putInt(600_000).array()));
|
||||
ΩSPLTokenAccountΩ recipientA = destination(wallet.getAddress(), mintA, programA, instructions);
|
||||
ΩSPLTokenAccountΩ recipientB = destination(wallet.getAddress(), mintB, programB, instructions);
|
||||
ΩRaydiumLiquidityPoolPositionIdΩ protocol = pda("position", SolanaProgramAddressSeed.solanaAddress(position.poolId()),
|
||||
tickSeed(position.tickLowerIndex()), tickSeed(position.tickUpperIndex()));
|
||||
List<Meta> accounts = new ArrayList<>(List.of(
|
||||
new Meta(wallet.getAddress(), true, false), new Meta(nft.tokenAccount(), false, false),
|
||||
new Meta(personal, false, true), new Meta(position.poolId(), false, true),
|
||||
new Meta(protocol, false, false), new Meta(address(pool, 137), false, true),
|
||||
new Meta(address(pool, 169), false, true), new Meta(lowerArray, false, true),
|
||||
new Meta(upperArray, false, true), new Meta(recipientA, false, true),
|
||||
new Meta(recipientB, false, true),
|
||||
new Meta(SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM.getAddress(), false, false),
|
||||
new Meta(SolanaSPLTokenProgram.TOKEN_2022_PROGRAM.getAddress(), false, false),
|
||||
new Meta(MEMO, false, false), new Meta(mintA, false, false), new Meta(mintB, false, false)));
|
||||
// Bitmap extension is only required outside the pool's default 1024 arrays.
|
||||
if (lowerStart < -512 * 60 * tickSpacing || upperStart >= 512 * 60 * tickSpacing) {
|
||||
ΩSolanaAddressΩ bitmap = pda("pool_tick_array_bitmap_extension",
|
||||
SolanaProgramAddressSeed.solanaAddress(position.poolId()));
|
||||
byte[] bitmapData = accountData(bitmap, PROGRAM, "TickArrayBitmapExtension", 40);
|
||||
if (!position.poolId().equals(address(bitmapData, 8))) throw new IOException("Wrong bitmap pool");
|
||||
accounts.add(new Meta(bitmap, false, true));
|
||||
}
|
||||
// The official instruction requires reward triplets for all initialized rewards.
|
||||
// They are collected to this wallet only, never exchanged or paid out by Evelyn.
|
||||
if ((pool[389] & 8) == 0) {
|
||||
for (int reward = 0; reward < 3; reward++) {
|
||||
int offset = 397 + reward * 169;
|
||||
ΩSPLMintAddressΩ mint = address(pool, offset + 57);
|
||||
if (mint.equals(SYSTEM)) continue;
|
||||
ΩSPLTokenAccountΩ vault = address(pool, offset + 89);
|
||||
SolanaSPLTokenProgram program = mintProgram(mint);
|
||||
validateVault(vault, mint, position.poolId(), program);
|
||||
ΩSPLTokenAccountΩ recipient = destination(wallet.getAddress(), mint, program, instructions);
|
||||
accounts.add(new Meta(vault, false, true));
|
||||
accounts.add(new Meta(recipient, false, true));
|
||||
accounts.add(new Meta(mint, false, false));
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream data = new ByteArrayOutputStream();
|
||||
data.writeBytes(discriminator("global:decrease_liquidity_v2"));
|
||||
data.writeBytes(littleEndian(position.liquidity(), 16));
|
||||
data.writeBytes(littleEndian(minimum[0], 8));
|
||||
data.writeBytes(littleEndian(minimum[1], 8));
|
||||
instructions.add(new Instruction(PROGRAM, accounts, data.toByteArray()));
|
||||
checkInterrupted();
|
||||
ΩSolanaTransactionSignatureΩ signature = wallet.signAndSendTransaction(serialize(wallet.getAddress(), instructions));
|
||||
org.slf4j.LoggerFactory.getLogger(RaydiumClmmWithdrawal.class).info(
|
||||
"withdraw position={} liquidity={} minimumA={} minimumB={} signature={}",
|
||||
position.nftId(), position.liquidity(), minimum[0], minimum[1], signature);
|
||||
SolanaTransactionOutcome outcome = chain.awaitTransaction(signature, SolanaCommitment.CONFIRMED, Duration.ofSeconds(60));
|
||||
if (outcome.status() != SolanaTransactionOutcome.Status.SUCCEEDED) {
|
||||
throw new IOException("CLMM withdrawal signature=" + signature + " outcome=" + outcome);
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
|
||||
private byte[] poolData(ΩRaydiumLiquidityPoolConcentratedIdΩ poolId) throws IOException, InterruptedException {
|
||||
return accountData(poolId, PROGRAM, "PoolState", 904);
|
||||
}
|
||||
|
||||
private byte[] accountData(ΩSolanaAddressΩ address, ΩSolanaProgramIdΩ owner, String type, int minimumLength)
|
||||
throws IOException, InterruptedException {
|
||||
SolanaAccountInfo info = chain.getAccountInfo(address);
|
||||
if (info == null || !owner.equals(info.owner())) throw new IOException("Missing/wrong owner for " + type + " " + address);
|
||||
byte[] data = decode(info);
|
||||
if (data.length < minimumLength || !Arrays.equals(Arrays.copyOf(data, 8), discriminator("account:" + type))) {
|
||||
throw new IOException("Invalid " + type + " account layout: " + address);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private SolanaSPLTokenProgram mintProgram(ΩSPLMintAddressΩ mint) throws IOException, InterruptedException {
|
||||
SolanaAccountInfo info = chain.getAccountInfo(mint);
|
||||
if (info != null) for (SolanaSPLTokenProgram program : SolanaSPLTokenProgram.values()) {
|
||||
if (program.getAddress().equals(info.owner())) return program;
|
||||
}
|
||||
throw new IOException("Missing or unsupported mint: " + mint);
|
||||
}
|
||||
|
||||
private SolanaSPLTokenProgram validateMint(ΩSPLMintAddressΩ mint, ΩamountDecimalsΩ decimals) throws IOException, InterruptedException {
|
||||
SolanaSPLTokenProgram program = mintProgram(mint);
|
||||
byte[] data = decode(chain.getAccountInfo(mint));
|
||||
if (data.length < 82 || data[45] != 1 || Byte.toUnsignedInt(data[44]) != decimals) {
|
||||
throw new IOException("Mint precision/initialization mismatch: " + mint);
|
||||
}
|
||||
if (program == SolanaSPLTokenProgram.TOKEN_2022_PROGRAM && data.length > 82) {
|
||||
if (data.length < 166 || data[165] != 1) throw new IOException("Invalid Token-2022 mint layout");
|
||||
for (int offset = 166; offset + 4 <= data.length;) {
|
||||
int type = Short.toUnsignedInt(ByteBuffer.wrap(data, offset, 2).order(ByteOrder.LITTLE_ENDIAN).getShort());
|
||||
int size = Short.toUnsignedInt(ByteBuffer.wrap(data, offset + 2, 2).order(ByteOrder.LITTLE_ENDIAN).getShort());
|
||||
if (type == 0 && size == 0) break;
|
||||
if (offset + 4 + size > data.length || !Set.of(3, 18, 19, 20, 21, 22, 23).contains(type)) {
|
||||
throw new IOException("Unsupported transfer-affecting mint extension " + type + " on " + mint);
|
||||
}
|
||||
offset += 4 + size;
|
||||
}
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
private void validateTickArray(ΩSolanaAddressΩ account, ΩRaydiumLiquidityPoolConcentratedIdΩ pool, ΩraydiumLiquidityTickIndexΩ start) throws IOException, InterruptedException {
|
||||
byte[] data = accountData(account, PROGRAM, "TickArrayState", 44);
|
||||
if (!pool.equals(address(data, 8)) || integer(data, 40) != start) throw new IOException("Invalid tick array");
|
||||
}
|
||||
|
||||
private void validateVault(ΩSPLTokenAccountΩ account, ΩSPLMintAddressΩ mint, ΩSolanaAddressΩ owner, SolanaSPLTokenProgram program)
|
||||
throws IOException, InterruptedException { validateTokenAccount(account, mint, owner, program); }
|
||||
|
||||
private void validateTokenAccount(ΩSPLTokenAccountΩ account, ΩSPLMintAddressΩ mint, ΩSolanaAddressΩ owner, SolanaSPLTokenProgram program)
|
||||
throws IOException, InterruptedException {
|
||||
SolanaAccountInfo info = chain.getAccountInfo(account);
|
||||
if (info == null || !program.getAddress().equals(info.owner())) throw new IOException("Wrong token account program");
|
||||
byte[] data = decode(info);
|
||||
if (data.length < 165 || !mint.equals(address(data, 0)) || !owner.equals(address(data, 32)) || data[108] != 1) {
|
||||
throw new IOException("Invalid token account: " + account);
|
||||
}
|
||||
}
|
||||
|
||||
private ΩSPLTokenAccountΩ destination(ΩSolanaWalletIdΩ wallet, ΩSPLMintAddressΩ mint, SolanaSPLTokenProgram program, List<Instruction> instructions)
|
||||
throws IOException, InterruptedException {
|
||||
// Preserve the existing wallet's single-account-per-mint contract, even for non-ATAs.
|
||||
// Failed discovery must abort rather than risk creating a second owner account.
|
||||
SPLTokenHolding holding = chain.getSPLTokenHoldings(wallet, program).get(mint);
|
||||
if (holding != null) {
|
||||
validateTokenAccount(holding.tokenAccount(), mint, wallet, program);
|
||||
return holding.tokenAccount();
|
||||
}
|
||||
ΩSPLTokenAccountΩ ata = chain.findAssociatedTokenAccount(wallet, mint, program);
|
||||
if (chain.getAccountInfo(ata) == null) {
|
||||
instructions.add(new Instruction(ATA_PROGRAM, List.of(new Meta(wallet, true, true),
|
||||
new Meta(ata, false, true), new Meta(wallet, false, false), new Meta(mint, false, false),
|
||||
new Meta(SYSTEM, false, false), new Meta(program.getAddress(), false, false)), new byte[] {1}));
|
||||
} else {
|
||||
validateTokenAccount(ata, mint, wallet, program);
|
||||
}
|
||||
return ata;
|
||||
}
|
||||
|
||||
private ΩSolanaAddressΩ tickArray(ΩRaydiumLiquidityPoolConcentratedIdΩ pool, ΩraydiumLiquidityTickIndexΩ start) {
|
||||
return pda("tick_array", SolanaProgramAddressSeed.solanaAddress(pool), tickSeed(start));
|
||||
}
|
||||
private static SolanaProgramAddressSeed tickSeed(ΩraydiumLiquidityTickIndexΩ tick) {
|
||||
return SolanaProgramAddressSeed.hex(HexFormat.of().formatHex(ByteBuffer.allocate(4).putInt(tick).array()));
|
||||
}
|
||||
private ΩSolanaAddressΩ pda(String seed, SolanaProgramAddressSeed... rest) {
|
||||
List<SolanaProgramAddressSeed> seeds = new ArrayList<>();
|
||||
seeds.add(SolanaProgramAddressSeed.utf8(seed)); seeds.addAll(List.of(rest));
|
||||
return chain.findProgramAddress(PROGRAM, seeds).address();
|
||||
}
|
||||
|
||||
private SolanaUnsignedTransaction serialize(ΩSolanaWalletIdΩ payer, List<Instruction> instructions)
|
||||
throws IOException, InterruptedException {
|
||||
Map<String, Meta> catalogue = new LinkedHashMap<>();
|
||||
catalogue.put(payer, new Meta(payer, true, true));
|
||||
for (Instruction instruction : instructions) {
|
||||
for (Meta meta : instruction.accounts()) catalogue.merge(meta.address(), meta,
|
||||
(a,b) -> new Meta(a.address(), a.signer() || b.signer(), a.writable() || b.writable()));
|
||||
catalogue.putIfAbsent(instruction.program(), new Meta(instruction.program(), false, false));
|
||||
}
|
||||
List<Meta> keys = new ArrayList<>(catalogue.values());
|
||||
keys.sort(Comparator.comparingInt(meta -> meta.signer() ? (meta.writable() ? 0 : 1) : (meta.writable() ? 2 : 3)));
|
||||
if (keys.stream().filter(Meta::signer).count() != 1 || keys.size() > 256) throw new IOException("Unsupported transaction accounts");
|
||||
SolanaLatestBlockhash blockhash = chain.getLatestBlockhash();
|
||||
ByteArrayOutputStream message = new ByteArrayOutputStream();
|
||||
message.write(1); message.write(0);
|
||||
message.write((int) keys.stream().filter(meta -> !meta.signer() && !meta.writable()).count());
|
||||
compact(message, keys.size());
|
||||
for (Meta meta : keys) message.writeBytes(addressBytes(meta.address()));
|
||||
message.writeBytes(addressBytes(blockhash.blockhash()));
|
||||
compact(message, instructions.size());
|
||||
for (Instruction instruction : instructions) {
|
||||
message.write(index(keys, instruction.program()));
|
||||
compact(message, instruction.accounts().size());
|
||||
for (Meta meta : instruction.accounts()) message.write(index(keys, meta.address()));
|
||||
compact(message, instruction.data().length); message.writeBytes(instruction.data());
|
||||
}
|
||||
ByteArrayOutputStream transaction = new ByteArrayOutputStream();
|
||||
transaction.write(1); transaction.writeBytes(new byte[64]); transaction.writeBytes(message.toByteArray());
|
||||
if (transaction.size() > 1232) throw new IOException("CLMM transaction exceeds Solana packet limit");
|
||||
return new SolanaUnsignedTransaction(Base64.getEncoder().encodeToString(transaction.toByteArray()),
|
||||
blockhash.blockhash(), blockhash.lastValidBlockHeight());
|
||||
}
|
||||
private static int index(List<Meta> keys, ΩSolanaAddressΩ address) {
|
||||
for (int i = 0; i < keys.size(); i++) if (keys.get(i).address().equals(address)) return i;
|
||||
throw new IllegalArgumentException("Missing transaction account");
|
||||
}
|
||||
private static void compact(ByteArrayOutputStream stream, int value) {
|
||||
do { int next = value & 127; value >>>= 7; stream.write(next | (value > 0 ? 128 : 0)); } while (value > 0);
|
||||
}
|
||||
private static byte[] addressBytes(ΩSolanaAddressΩ address) {
|
||||
byte[] bytes = BASE58.decode(address);
|
||||
if (bytes.length != 32) throw new IllegalArgumentException("Solana address must decode to 32 bytes");
|
||||
return bytes;
|
||||
}
|
||||
private ΩSolanaAddressΩ address(byte[] data, int offset) { return chain.encodeSolanaAddress(Arrays.copyOfRange(data, offset, offset + 32)); }
|
||||
private static int integer(byte[] data, int offset) { return ByteBuffer.wrap(data, offset, 4).order(ByteOrder.LITTLE_ENDIAN).getInt(); }
|
||||
private static BigInteger unsigned(byte[] data, int offset, int length) {
|
||||
byte[] reversed = new byte[length];
|
||||
for (int i = 0; i < length; i++) reversed[length - 1 - i] = data[offset + i];
|
||||
return new BigInteger(1, reversed);
|
||||
}
|
||||
private static byte[] littleEndian(BigInteger value, int length) {
|
||||
if (value.signum() < 0 || value.bitLength() > length * 8) throw new IllegalArgumentException("Unsigned amount overflow");
|
||||
byte[] result = new byte[length];
|
||||
for (int i = 0; i < length; i++) result[i] = value.shiftRight(i * 8).byteValue();
|
||||
return result;
|
||||
}
|
||||
private static byte[] decode(SolanaAccountInfo info) throws IOException {
|
||||
try { return Base64.getDecoder().decode(info.data()); }
|
||||
catch (RuntimeException exception) { throw new IOException("Invalid account data", exception); }
|
||||
}
|
||||
private static byte[] discriminator(String name) {
|
||||
try { return Arrays.copyOf(MessageDigest.getInstance("SHA-256").digest(name.getBytes(StandardCharsets.UTF_8)), 8); }
|
||||
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
|
||||
}
|
||||
private static void checkInterrupted() throws InterruptedException {
|
||||
if (Thread.currentThread().isInterrupted()) throw new InterruptedException("CLMM withdrawal interrupted");
|
||||
}
|
||||
private record Meta(ΩSolanaAddressΩ address, boolean signer, boolean writable) { }
|
||||
private record Instruction(ΩSolanaProgramIdΩ program, List<Meta> accounts, byte[] data) { }
|
||||
}
|
||||
@@ -64,6 +64,20 @@ public class RaydiumImpl implements Raydium {
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RaydiumConcentratedPoolInfo fetchConcentratedPoolInfoOnChain(ΩRaydiumLiquidityPoolConcentratedIdΩ poolId)
|
||||
throws IOException, InterruptedException {
|
||||
return new RaydiumClmmWithdrawal(solanaBlockChain).poolInfo(poolId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ΩSolanaTransactionSignatureΩ withdrawConcentratedPositionLiquidity(
|
||||
com.r35157.cryptowallet.solana.SolanaWallet wallet,
|
||||
RaydiumConcentratedPositionState position, RaydiumConcentratedPoolState quoteState,
|
||||
int maximumSlippageBps) throws IOException, InterruptedException {
|
||||
return new RaydiumClmmWithdrawal(solanaBlockChain).withdraw(wallet, position, quoteState, maximumSlippageBps);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AssetPrice fetchPoolPrice(ΩRaydiumLiquidityPoolIdΩ raydiumLiquidityPoolId) throws IOException, InterruptedException {
|
||||
ΩRestEndpointΩ endpoint = createPoolInfoByIdEndpoint(raydiumLiquidityPoolId);
|
||||
@@ -102,19 +116,22 @@ public class RaydiumImpl implements Raydium {
|
||||
public Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> fetchConcentratedPositionNftIds(
|
||||
ΩSolanaAddressΩ ownerAddress
|
||||
) throws IOException, InterruptedException {
|
||||
Set<ΩSolanaNFTAddressΩ> candidates;
|
||||
Set<ΩSolanaNFTAddressΩ> allCandidates = new HashSet<>();
|
||||
boolean discoveredAnyProgram = false;
|
||||
for (var program : List.of(SPL_TOKEN_PROGRAM, TOKEN_2022_PROGRAM)) {
|
||||
try {
|
||||
allCandidates.addAll(solanaBlockChain.getSolanaNFTCandidateAddresses(ownerAddress, program));
|
||||
discoveredAnyProgram = true;
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
org.slf4j.LoggerFactory.getLogger(RaydiumImpl.class).warn(
|
||||
"Could not discover CLMM NFT candidates for owner={} program={}; continuing independent discovery",
|
||||
ownerAddress, program, exception);
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println("Searching for legacy NFTs...");
|
||||
candidates = solanaBlockChain.getSolanaNFTCandidateAddresses(ownerAddress, SPL_TOKEN_PROGRAM);
|
||||
System.out.println("Found " + candidates.size() + " legacy NFTs");
|
||||
allCandidates.addAll(candidates);
|
||||
|
||||
System.out.println("Searching for token_2022 NFTs...");
|
||||
candidates = solanaBlockChain.getSolanaNFTCandidateAddresses(ownerAddress, TOKEN_2022_PROGRAM);
|
||||
System.out.println("Found " + candidates.size() + " token_2022 NFTs");
|
||||
allCandidates.addAll(candidates);
|
||||
|
||||
if (!discoveredAnyProgram) {
|
||||
throw new IOException("Could not discover NFT holdings under either Solana token program for " + ownerAddress);
|
||||
}
|
||||
Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> ids = filterCandidatePositionNftIds(allCandidates);
|
||||
return ids;
|
||||
}
|
||||
@@ -636,18 +653,16 @@ public class RaydiumImpl implements Raydium {
|
||||
|
||||
System.out.println("Filtering " + candidatePositionNftIds.size() + " NFTs for Raydium Concentrated pools...");
|
||||
for (ΩSolanaNFTAddressΩ candidatePositionNftId : candidatePositionNftIds) {
|
||||
System.out.print(" " + candidatePositionNftId + " ");
|
||||
SolanaProgramDerivedAddress positionPda = findRaydiumPositionPda(candidatePositionNftId);
|
||||
SolanaAccountInfo accountInfo = solanaBlockChain.getAccountInfo(positionPda.address());
|
||||
|
||||
if (accountInfo == null) {
|
||||
System.out.println("NO");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RAYDIUM_CLMM_PROGRAM_ID.equals(accountInfo.owner())) {
|
||||
System.out.println("YES");
|
||||
positionNftIds.add(candidatePositionNftId);
|
||||
try {
|
||||
SolanaProgramDerivedAddress positionPda = findRaydiumPositionPda(candidatePositionNftId);
|
||||
SolanaAccountInfo accountInfo = solanaBlockChain.getAccountInfo(positionPda.address());
|
||||
if (accountInfo != null && RAYDIUM_CLMM_PROGRAM_ID.equals(accountInfo.owner())) {
|
||||
positionNftIds.add(candidatePositionNftId);
|
||||
}
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
org.slf4j.LoggerFactory.getLogger(RaydiumImpl.class).warn(
|
||||
"Could not inspect CLMM position candidate {}; continuing discovery",
|
||||
candidatePositionNftId, exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ public record SolanaProgramAddressSeed(
|
||||
SolanaProgramAddressSeedKind kind,
|
||||
String value
|
||||
) {
|
||||
/** Creates a binary seed; needed for CLMM big-endian signed tick indexes. */
|
||||
public static SolanaProgramAddressSeed hex(String value) {
|
||||
return new SolanaProgramAddressSeed(SolanaProgramAddressSeedKind.HEX, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a UTF-8 seed.
|
||||
*
|
||||
|
||||
@@ -13,6 +13,9 @@ public enum SolanaProgramAddressSeedKind {
|
||||
*/
|
||||
UTF8,
|
||||
|
||||
/** Exact binary seed represented by hexadecimal text. */
|
||||
HEX,
|
||||
|
||||
/**
|
||||
* A seed that represents a Solana address and should be decoded from its
|
||||
* textual Solana address representation to raw address bytes.
|
||||
|
||||
@@ -1741,6 +1741,8 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
|
||||
|
||||
private byte[] toSeedBytes(SolanaProgramAddressSeed seed) {
|
||||
return switch (seed.kind()) {
|
||||
case HEX -> java.util.HexFormat.of().parseHex(seed.value());
|
||||
|
||||
case UTF8 -> seed.value().getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
case SOLANA_ADDRESS -> {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package com.r35157.nenjim.hubd.impl.ref;
|
||||
|
||||
import com.fanitas.evelyn.core.Evelyn;
|
||||
import com.fanitas.evelyn.core.impl.ref.EvelynImpl;
|
||||
import com.r35157.assetaz.services.journal.impl.ref.AssetAZJournalServiceImpl;
|
||||
import com.r35157.assetaz.services.journal.codec.impl.ref.AssetOwnerRegisteredCodec;
|
||||
import com.r35157.assetaz.services.journal.codec.impl.ref.JournalFormatVersionChangedCodec;
|
||||
import com.r35157.evelyn.service.liquidity.impl.ref.EvelynLiquidityConfiguration;
|
||||
import com.r35157.evelyn.service.liquidity.impl.ref.EvelynLiquidityServiceImpl;
|
||||
import com.r35157.evelyn.service.status.EvelynStatusService;
|
||||
import com.r35157.evelyn.service.status.impl.ref.EvelynStatusServiceImpl;
|
||||
import com.fanitas.evelyn.service.burner.EvelynIOUBurnerService;
|
||||
import com.fanitas.evelyn.service.burner.impl.ref.EvelynIOUBurnerServiceImpl;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -152,6 +157,13 @@ public final class NenjimHubImpl implements NenjimHub {
|
||||
|
||||
private void initializeComponents(
|
||||
NenjimRegistryServiceManager registryServiceManager) {
|
||||
registryServiceManager.registerComponent(
|
||||
new NenjimComponentId("assetaz.journal.codec.asset-owner-registered.v1"),
|
||||
new AssetOwnerRegisteredCodec());
|
||||
registryServiceManager.registerComponent(
|
||||
new NenjimComponentId("assetaz.journal.codec.journal-format-version-changed.v1"),
|
||||
new JournalFormatVersionChangedCodec());
|
||||
|
||||
objectCache = new ObjectCacheImpl();
|
||||
registryServiceManager.registerComponent(OBJECT_CACHE_ID, objectCache);
|
||||
|
||||
@@ -264,29 +276,29 @@ public final class NenjimHubImpl implements NenjimHub {
|
||||
return process;
|
||||
}
|
||||
|
||||
private NenjimProcess constructEvelynProdProcess() {
|
||||
EvelynImpl process = new EvelynImpl(
|
||||
private NenjimProcess constructEvelynStatusProdProcess() {
|
||||
EvelynStatusServiceImpl process = new EvelynStatusServiceImpl(
|
||||
"Production",
|
||||
requireProcessDependency(
|
||||
tickerService,
|
||||
EVELYN_SERVICE_PROD_ID,
|
||||
EVELYN_STATUS_PROD_ID,
|
||||
TICKER_SERVICE_ID),
|
||||
eveUSDT,
|
||||
clockUTC);
|
||||
evelynServiceProd = process;
|
||||
evelynStatusServiceProd = process;
|
||||
return process;
|
||||
}
|
||||
|
||||
private NenjimProcess constructEvelynTestProcess() {
|
||||
EvelynImpl process = new EvelynImpl(
|
||||
private NenjimProcess constructEvelynStatusTestProcess() {
|
||||
EvelynStatusServiceImpl process = new EvelynStatusServiceImpl(
|
||||
"Test",
|
||||
requireProcessDependency(
|
||||
tickerService,
|
||||
EVELYN_SERVICE_TEST_ID,
|
||||
EVELYN_STATUS_TEST_ID,
|
||||
TICKER_SERVICE_ID),
|
||||
eveUSDT,
|
||||
clockUTC);
|
||||
evelynServiceTest = process;
|
||||
evelynStatusServiceTest = process;
|
||||
return process;
|
||||
}
|
||||
|
||||
@@ -316,6 +328,20 @@ public final class NenjimHubImpl implements NenjimHub {
|
||||
return process;
|
||||
}
|
||||
|
||||
private NenjimProcess constructEvelynLiquidityProdProcess() throws Exception {
|
||||
EvelynLiquidityConfiguration liquidityConfiguration = EvelynLiquidityConfiguration.parse(
|
||||
Path.of("conf/evelyn-liquidity.conf"));
|
||||
SolanaWallet liquidityWallet = new SolanaWalletImpl(liquidityConfiguration.walletAddress(),
|
||||
liquidityConfiguration.signerKeyName(), solanaBlockChain);
|
||||
JupiterSwapService liquiditySwap = new JupiterSwapServiceImpl(solanaBlockChain, liquidityWallet);
|
||||
Raydium liquidityRaydium = new RaydiumImpl(httpClient, objectMapper, solanaBlockChain, cis);
|
||||
registryServiceManager.registerComponent(new NenjimComponentId("solana.wallet.evelyn-liquidity-prod"), liquidityWallet);
|
||||
registryServiceManager.registerComponent(new NenjimComponentId("jupiter.swap.evelyn-liquidity-prod"), liquiditySwap);
|
||||
registryServiceManager.registerComponent(new NenjimComponentId("raydium.service.evelyn-liquidity-prod"), liquidityRaydium);
|
||||
return new EvelynLiquidityServiceImpl(liquidityWallet, liquiditySwap, liquidityRaydium,
|
||||
solanaBlockChain, cis, liquidityConfiguration);
|
||||
}
|
||||
|
||||
private NenjimProcess constructJavaFxRuntimeProcess() {
|
||||
JavaFxRuntimeServiceImpl process = new JavaFxRuntimeServiceImpl();
|
||||
javaFxRuntimeService = process;
|
||||
@@ -325,13 +351,13 @@ public final class NenjimHubImpl implements NenjimHub {
|
||||
private NenjimProcess constructMissionControlProcess() {
|
||||
EvelynMissionControlImpl process = new EvelynMissionControlImpl(
|
||||
requireProcessDependency(
|
||||
evelynServiceProd,
|
||||
evelynStatusServiceProd,
|
||||
EVELYN_MISSION_CONTROL_ID,
|
||||
EVELYN_SERVICE_PROD_ID),
|
||||
EVELYN_STATUS_PROD_ID),
|
||||
requireProcessDependency(
|
||||
evelynServiceTest,
|
||||
evelynStatusServiceTest,
|
||||
EVELYN_MISSION_CONTROL_ID,
|
||||
EVELYN_SERVICE_TEST_ID),
|
||||
EVELYN_STATUS_TEST_ID),
|
||||
requireProcessDependency(
|
||||
javaFxRuntimeService,
|
||||
EVELYN_MISSION_CONTROL_ID,
|
||||
@@ -371,10 +397,13 @@ public final class NenjimHubImpl implements NenjimHub {
|
||||
createProcessConstructorCatalogue() {
|
||||
return Map.ofEntries(
|
||||
Map.entry(JOURNAL_SERVICE_ID, this::constructJournalProcess),
|
||||
Map.entry(ASSETAZ_JOURNAL_SERVICE_ID, () -> new AssetAZJournalServiceImpl(
|
||||
registryService, Path.of("data/assetaz/journal/events.pde"))),
|
||||
Map.entry(TICKER_SERVICE_ID, this::constructTickerProcess),
|
||||
Map.entry(JUPITER_PERPS_ALARM_ID, this::constructJupiterPerpsAlarmProcess),
|
||||
Map.entry(EVELYN_SERVICE_PROD_ID, this::constructEvelynProdProcess),
|
||||
Map.entry(EVELYN_SERVICE_TEST_ID, this::constructEvelynTestProcess),
|
||||
Map.entry(EVELYN_STATUS_PROD_ID, this::constructEvelynStatusProdProcess),
|
||||
Map.entry(EVELYN_STATUS_TEST_ID, this::constructEvelynStatusTestProcess),
|
||||
Map.entry(EVELYN_LIQUIDITY_PROD_ID, this::constructEvelynLiquidityProdProcess),
|
||||
Map.entry(EVELYN_IOU_BURNER_PROD_ID, this::constructEvelynIOUBurnerProdProcess),
|
||||
Map.entry(JAVAFX_RUNTIME_SERVICE_ID, this::constructJavaFxRuntimeProcess),
|
||||
Map.entry(EVELYN_MISSION_CONTROL_ID, this::constructMissionControlProcess),
|
||||
@@ -446,6 +475,8 @@ public final class NenjimHubImpl implements NenjimHub {
|
||||
new NenjimComponentId("nenjim.registry.service");
|
||||
private static final NenjimComponentId JOURNAL_SERVICE_ID =
|
||||
new NenjimComponentId("nenjim.journal.service");
|
||||
private static final NenjimComponentId ASSETAZ_JOURNAL_SERVICE_ID =
|
||||
new NenjimComponentId("assetaz.journal.service");
|
||||
private static final Path JOURNAL_ROOT =
|
||||
Path.of("data/nenjim/journals");
|
||||
private static final NenjimComponentId OBJECT_CACHE_ID =
|
||||
@@ -477,16 +508,18 @@ public final class NenjimHubImpl implements NenjimHub {
|
||||
new NenjimComponentId("jupiter.perps.evelyn-test");
|
||||
private static final NenjimComponentId JUPITER_PERPS_ALARM_ID =
|
||||
new NenjimComponentId("jupiter-perps-alarm.default");
|
||||
private static final NenjimComponentId EVELYN_SERVICE_PROD_ID =
|
||||
new NenjimComponentId("evelyn.service.prod");
|
||||
private static final NenjimComponentId EVELYN_SERVICE_TEST_ID =
|
||||
new NenjimComponentId("evelyn.service.test");
|
||||
private static final NenjimComponentId EVELYN_STATUS_PROD_ID =
|
||||
new NenjimComponentId("evelyn.status.prod");
|
||||
private static final NenjimComponentId EVELYN_STATUS_TEST_ID =
|
||||
new NenjimComponentId("evelyn.status.test");
|
||||
private static final NenjimComponentId JUPITER_SWAP_EVELYN_BURNER_PROD_ID =
|
||||
new NenjimComponentId("jupiter.swap.evelyn-burner-prod");
|
||||
private static final NenjimComponentId DISCORD_ASSETAZ_TOKEN_NOTIFICATION_ID =
|
||||
new NenjimComponentId("notification.discord.assetaz-token");
|
||||
private static final NenjimComponentId EVELYN_IOU_BURNER_PROD_ID =
|
||||
new NenjimComponentId("evelyn.iou-burner.prod");
|
||||
private static final NenjimComponentId EVELYN_LIQUIDITY_PROD_ID =
|
||||
new NenjimComponentId("evelyn.liquidity.prod");
|
||||
private static final NenjimComponentId EVELYN_MISSION_CONTROL_ID =
|
||||
new NenjimComponentId("evelyn.mission-control.default");
|
||||
private static final NenjimComponentId JAVAFX_RUNTIME_SERVICE_ID =
|
||||
@@ -525,8 +558,8 @@ public final class NenjimHubImpl implements NenjimHub {
|
||||
// Services
|
||||
private TickerService tickerService;
|
||||
private JupiterPerpsAlarm jupiterPerpsAlarm;
|
||||
private Evelyn evelynServiceProd;
|
||||
private Evelyn evelynServiceTest;
|
||||
private EvelynStatusService evelynStatusServiceProd;
|
||||
private EvelynStatusService evelynStatusServiceTest;
|
||||
private JupiterSwapService jupiterSwapService;
|
||||
private BoundNotificationService discordAssetAZTokenNotificationService;
|
||||
private EvelynIOUBurnerService evelynIOUBurnerServiceProd;
|
||||
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
package com.r35157.evelyn.service.liquidity.impl.ref;
|
||||
|
||||
import com.r35157.assetaz.services.cis.impl.hc.HardcodedCurrencyIdentityService;
|
||||
import com.r35157.cryptowallet.solana.*;
|
||||
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
|
||||
import com.r35157.libs.jupiter.swap.*;
|
||||
import com.r35157.libs.raydium.*;
|
||||
import com.r35157.libs.solana.*;
|
||||
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.*;
|
||||
import java.math.*;
|
||||
import java.nio.file.*;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class EvelynLiquidityServiceImplTest {
|
||||
@Test void harvestsOwnedPoolOnlyThenSwapsAndPaysWithConfiguredReserves() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.positions.put("other-pool", new RaydiumConcentratedPositionState("other-pool", address(9), -100, 0, BigInteger.ONE));
|
||||
fixture.positions.put("empty", new RaydiumConcentratedPositionState("empty", POOL, -100, 0, BigInteger.ZERO));
|
||||
fixture.positions.put("above", new RaydiumConcentratedPositionState("above", POOL, -100, 1, BigInteger.ONE));
|
||||
fixture.positions.put("foreign-nft", new RaydiumConcentratedPositionState("unowned", POOL, -100, 0, BigInteger.ONE));
|
||||
fixture.balances.put(SYRUP, new BigDecimal("1.01"));
|
||||
fixture.balances.put(USDC, new BigDecimal("2.5"));
|
||||
try (var service = fixture.service("0.01", "0.5", "0")) {
|
||||
service.initialize(); service.runIteration();
|
||||
assertEquals(List.of("withdraw:owned", "swap:1.000000", "send:2.000000"), fixture.operations);
|
||||
assertEquals(RECIPIENT, fixture.recipient);
|
||||
assertEquals(MissingRecipientTokenAccountPolicy.CREATE_AT_SENDER_EXPENSE, fixture.creationPolicy);
|
||||
assertEquals(100, fixture.swapSlippage);
|
||||
assertEquals(100, fixture.withdrawSlippage);
|
||||
assertSame(fixture.wallet, fixture.withdrawWallet);
|
||||
assertEquals(BigInteger.valueOf(1_000_000), fixture.withdrawPosition.liquidity());
|
||||
assertEquals(List.of(SYRUP, USDC), fixture.balanceReads);
|
||||
}
|
||||
}
|
||||
@Test void eachStepReadsProceedsProducedByThePreviousStepAndKeepsReserves() throws Exception {
|
||||
Fixture fixture=new Fixture();fixture.creditProceeds=true;fixture.creditSwap=true;
|
||||
try(var service=fixture.service("0.04","0.5","0")) {
|
||||
service.initialize();service.runIteration();
|
||||
assertEquals(List.of("withdraw:owned","swap:3.000000","send:2.000000"),fixture.operations);
|
||||
assertEquals(0,new BigDecimal("0.04").compareTo(fixture.balances.get(SYRUP)));
|
||||
assertEquals(0,new BigDecimal("0.5").compareTo(fixture.balances.get(USDC)));
|
||||
assertEquals(List.of(SYRUP,USDC),fixture.balanceReads);
|
||||
}
|
||||
}
|
||||
@Test void eachFailureLeavesIndependentStepsUsableAndNextIterationRereadsState() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.positions.put("unreadable", fixture.positions.get("owned"));
|
||||
fixture.unreadable = "unreadable";
|
||||
fixture.withdrawFails = true; fixture.swapFails = true;
|
||||
fixture.balances.put(SYRUP, BigDecimal.ONE); fixture.balances.put(USDC, new BigDecimal("2"));
|
||||
try (var service = fixture.service("0", "0", "0")) {
|
||||
service.initialize(); service.runIteration();
|
||||
assertEquals(List.of("withdraw:owned", "swap:1.000000", "send:2.000000"), fixture.operations);
|
||||
fixture.withdrawFails = false; fixture.swapFails = false;
|
||||
service.runIteration();
|
||||
assertEquals(List.of("withdraw:owned", "swap:1.000000", "send:2.000000", "withdraw:owned", "swap:1.000000"), fixture.operations);
|
||||
}
|
||||
fixture.operations.clear();
|
||||
try (var restarted = fixture.service("0", "0", "0")) {
|
||||
restarted.initialize(); restarted.runIteration(); assertTrue(fixture.operations.isEmpty());
|
||||
}
|
||||
}
|
||||
@Test void unusablePriceStillSwapsAndPaysAndPayoutFailureCanRecover() throws Exception {
|
||||
Fixture fixture = new Fixture(); fixture.priceFails = true; fixture.sendFails = true;
|
||||
fixture.balances.put(SYRUP, BigDecimal.ONE); fixture.balances.put(USDC, BigDecimal.ONE);
|
||||
try (var service = fixture.service("0", "0", "0")) {
|
||||
service.initialize(); service.runIteration();
|
||||
assertEquals(List.of("swap:1.000000", "send:1.000000"), fixture.operations);
|
||||
fixture.sendFails = false; service.runIteration();
|
||||
assertEquals(List.of("swap:1.000000", "send:1.000000", "send:1.000000"), fixture.operations);
|
||||
}
|
||||
}
|
||||
@Test void missingZeroAndAtReserveBalancesAreSkippedAndAmountsNeverRoundUp() throws Exception {
|
||||
for (BigDecimal balance : Arrays.asList(null, BigDecimal.ZERO, new BigDecimal("0.5"))) {
|
||||
Fixture fixture = new Fixture(); fixture.positions.clear();
|
||||
if (balance != null) { fixture.balances.put(SYRUP, balance); fixture.balances.put(USDC, balance); }
|
||||
try (var service = fixture.service("0.5", "0.5", "0")) {
|
||||
service.initialize(); service.runIteration(); assertTrue(fixture.operations.isEmpty());
|
||||
}
|
||||
}
|
||||
Fixture fixture = new Fixture(); fixture.positions.clear();
|
||||
fixture.balances.put(SYRUP, new BigDecimal("1.0000009")); fixture.balances.put(USDC, new BigDecimal("2.0000009"));
|
||||
try (var service = fixture.service("0", "0", "0")) {
|
||||
service.initialize(); service.runIteration();
|
||||
assertEquals(List.of("swap:1.000000", "send:2.000000"), fixture.operations);
|
||||
}
|
||||
}
|
||||
@Test void reversedPoolAndMarketDeclineUseCorrectBoundaryWithoutRefilling() throws Exception {
|
||||
Fixture fixture = new Fixture(); fixture.reversed = true;
|
||||
fixture.sqrt = RaydiumClmmMath.sqrtPriceAtTick(-100);
|
||||
try (var service = fixture.service("0", "0", "0")) {
|
||||
service.initialize(); service.runIteration(); assertEquals(List.of("withdraw:owned"), fixture.operations);
|
||||
}
|
||||
fixture = new Fixture(); fixture.sqrt = RaydiumClmmMath.sqrtPriceAtTick(-50);
|
||||
try (var service = fixture.service("0", "0", "0")) {
|
||||
service.initialize(); service.runIteration(); assertTrue(fixture.operations.isEmpty());
|
||||
assertTrue(fixture.positions.get("owned").liquidity().signum() > 0);
|
||||
}
|
||||
}
|
||||
@Test void priceDeclineDuringDiscoveryPreservesLiquidityInBothOrientations() throws Exception {
|
||||
for (boolean reversed : List.of(false, true)) {
|
||||
Fixture fixture = new Fixture(); fixture.reversed = reversed;
|
||||
fixture.sqrt = RaydiumClmmMath.sqrtPriceAtTick(reversed ? -1200 : 1200);
|
||||
fixture.afterDiscovery = () -> fixture.sqrt = RaydiumClmmMath.sqrtPriceAtTick(reversed ? -600 : 600);
|
||||
fixture.balances.put(SYRUP, BigDecimal.ONE); fixture.balances.put(USDC, new BigDecimal("2"));
|
||||
try (var service = fixture.service("0", "0", "10")) {
|
||||
service.initialize(); service.runIteration();
|
||||
assertEquals(List.of("swap:1.000000", "send:2.000000"), fixture.operations);
|
||||
assertEquals(BigInteger.valueOf(1_000_000), fixture.positions.get("owned").liquidity());
|
||||
assertEquals(1, fixture.poolReads);
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test void priceDeclineDuringWithdrawalPreservesLaterPositionInBothOrientations() throws Exception {
|
||||
for (boolean reversed : List.of(false, true)) {
|
||||
Fixture fixture = new Fixture(); fixture.reversed = reversed;
|
||||
fixture.positions.put("later", new RaydiumConcentratedPositionState("later", POOL, -100, 0, BigInteger.valueOf(1_000_000)));
|
||||
fixture.sqrt = RaydiumClmmMath.sqrtPriceAtTick(reversed ? -1200 : 1200);
|
||||
fixture.afterWithdrawal = () -> fixture.sqrt = RaydiumClmmMath.sqrtPriceAtTick(reversed ? -600 : 600);
|
||||
try (var service = fixture.service("0", "0", "10")) {
|
||||
service.initialize(); service.runIteration();
|
||||
assertEquals(List.of("withdraw:owned"), fixture.operations);
|
||||
assertEquals(BigInteger.ZERO, fixture.positions.get("owned").liquidity());
|
||||
assertEquals(BigInteger.valueOf(1_000_000), fixture.positions.get("later").liquidity());
|
||||
assertEquals(RaydiumClmmMath.sqrtPriceAtTick(reversed ? -1200 : 1200), fixture.withdrawQuote.sqrtPriceX64());
|
||||
assertEquals(2, fixture.poolReads);
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test void priceRiseUsesTheNewStateForEligibilityAndWithdrawalQuote() throws Exception {
|
||||
for (boolean reversed : List.of(false, true)) {
|
||||
Fixture fixture = new Fixture(); fixture.reversed = reversed;
|
||||
fixture.positions.put("later", new RaydiumConcentratedPositionState("later", POOL,
|
||||
reversed ? -300 : 100, reversed ? -200 : 200, BigInteger.valueOf(1_000_000)));
|
||||
fixture.sqrt = RaydiumClmmMath.sqrtPriceAtTick(reversed ? -1200 : 1200);
|
||||
fixture.afterWithdrawal = () -> fixture.sqrt = RaydiumClmmMath.sqrtPriceAtTick(reversed ? -1800 : 1800);
|
||||
try (var service = fixture.service("0", "0", "10")) {
|
||||
service.initialize(); service.runIteration();
|
||||
assertEquals(List.of("withdraw:owned", "withdraw:later"), fixture.operations);
|
||||
assertEquals(RaydiumClmmMath.sqrtPriceAtTick(reversed ? -1800 : 1800), fixture.withdrawQuote.sqrtPriceX64());
|
||||
assertEquals(2, fixture.poolReads);
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test void unusablePriceBetweenPositionsStopsWithdrawalsAndKeepsLaterStepsIndependent() throws Exception {
|
||||
for (boolean readFails : List.of(false, true)) {
|
||||
Fixture fixture = new Fixture(); fixture.sqrt = RaydiumClmmMath.sqrtPriceAtTick(1200);
|
||||
for (String nft : List.of("later", "last")) {
|
||||
fixture.positions.put(nft, new RaydiumConcentratedPositionState(nft, POOL, -100, 0, BigInteger.valueOf(1_000_000)));
|
||||
}
|
||||
fixture.afterWithdrawal = () -> { fixture.priceFails = readFails; fixture.sqrt = BigInteger.ZERO; };
|
||||
fixture.balances.put(SYRUP, BigDecimal.ONE); fixture.balances.put(USDC, new BigDecimal("2"));
|
||||
try (var service = fixture.service("0", "0", "10")) {
|
||||
service.initialize(); service.runIteration();
|
||||
assertEquals(List.of("withdraw:owned", "swap:1.000000", "send:2.000000"), fixture.operations);
|
||||
assertTrue(fixture.positions.get("later").liquidity().signum() > 0);
|
||||
assertTrue(fixture.positions.get("last").liquidity().signum() > 0);
|
||||
assertEquals(2, fixture.poolReads);
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test void invalidIdentityPrecisionAndBindingFailBeforeWorker() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
try (var service = fixture.service("0.0000001", "0", "10")) {
|
||||
assertThrows(IllegalArgumentException.class, service::start); assertTrue(fixture.operations.isEmpty());
|
||||
}
|
||||
fixture.wrongPool = true;
|
||||
try (var service = fixture.service("0", "0", "10")) {
|
||||
assertThrows(IllegalArgumentException.class, service::start); assertTrue(fixture.operations.isEmpty());
|
||||
}
|
||||
Fixture mismatch = new Fixture(); mismatch.walletAddress = RECIPIENT;
|
||||
assertThrows(IllegalArgumentException.class, () -> mismatch.service("0", "0", "10"));
|
||||
}
|
||||
@Test void registeredServiceExposesDomainAndProcessViewsWithoutStarting() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
try (var service=fixture.service("0","0","0")) {
|
||||
var assembly=com.r35157.nenjim.service.registry.impl.ref.NenjimRegistryAssembly.create();
|
||||
assembly.registryProcess().start();
|
||||
var id=new com.r35157.nenjim.service.registry.valuetypes.NenjimComponentId("evelyn.liquidity.prod");
|
||||
assembly.registryServiceManager().registerComponent(id,service);
|
||||
assertSame(service,assembly.registryService().getComponent(id,com.r35157.evelyn.service.liquidity.EvelynLiquidityService.class));
|
||||
assertSame(service,assembly.registryService().getComponent(id,com.r35157.nenjim.process.NenjimProcess.class));
|
||||
assertEquals(0,com.r35157.evelyn.service.liquidity.EvelynLiquidityService.class.getDeclaredMethods().length);
|
||||
assertTrue(fixture.operations.isEmpty());
|
||||
}
|
||||
}
|
||||
@Test void interruptionStopsFollowingSteps() throws Exception {
|
||||
Fixture fixture = new Fixture(); fixture.interruptWithdraw = true;
|
||||
fixture.balances.put(SYRUP, BigDecimal.ONE); fixture.balances.put(USDC, BigDecimal.ONE);
|
||||
try (var service = fixture.service("0", "0", "0")) {
|
||||
service.initialize(); assertThrows(InterruptedException.class, service::runIteration);
|
||||
assertEquals(List.of("withdraw:owned"), fixture.operations);
|
||||
}
|
||||
}
|
||||
@Test void workerDoesNotOverlapAndShutdownInterruptsBlockingIteration() throws Exception {
|
||||
Fixture fixture = new Fixture(); fixture.blockWithdraw = true;
|
||||
var service = fixture.service("0", "0", "0", Duration.ofMillis(1));
|
||||
try {
|
||||
service.start(); assertTrue(fixture.entered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(fixture.secondEntered.await(100, TimeUnit.MILLISECONDS));
|
||||
assertThrows(IllegalStateException.class, service::start);
|
||||
service.close(); assertTrue(fixture.interrupted.await(5, TimeUnit.SECONDS));
|
||||
assertEquals(1, fixture.withdrawCount.get());
|
||||
} finally { service.close(); }
|
||||
}
|
||||
@Test void strictVersionedParserAndZeroReserveExample(@TempDir Path directory) throws Exception {
|
||||
Path example = Path.of("conf/evelyn-liquidity.conf.example");
|
||||
String text = Files.readString(example);
|
||||
assertTrue(text.startsWith("FORMAT_VERSION=1\n"));
|
||||
assertTrue(text.contains("MINIMUM_SYRUPUSDC_RESERVE=0\n"));
|
||||
assertTrue(text.contains("MINIMUM_USDC_RESERVE=0\n"));
|
||||
Path path = directory.resolve("evelyn-liquidity.conf");
|
||||
assertThrows(IOException.class, () -> EvelynLiquidityConfiguration.parse(path));
|
||||
String valid = text.replace("REPLACE_WITH_DEDICATED_SOLANA_WALLET", OWNER)
|
||||
.replace("REPLACE_WITH_EXTERNAL_JUP_SIGNER", "liquidity")
|
||||
.replace("REPLACE_WITH_EXISTING_EVE_SYRUPUSDC_CLMM_POOL", POOL)
|
||||
.replace("REPLACE_WITH_RECIPIENT_WALLET_OWNER", RECIPIENT);
|
||||
Files.writeString(path, valid);
|
||||
assertEquals(BigDecimal.ZERO, EvelynLiquidityConfiguration.parse(path).minimumSyrupUSDCReserve());
|
||||
for (String invalid : List.of(valid.replace("FORMAT_VERSION=1", "FORMAT_VERSION=2"), valid + "UNKNOWN=1\n",
|
||||
valid + "MINIMUM_USDC_RESERVE=0\n", valid.replace("HARVEST_DISTANCE_PERCENT=10", "HARVEST_DISTANCE_PERCENT=100"),
|
||||
valid.replace("ITERATION_INTERVAL_SECONDS=300", "ITERATION_INTERVAL_SECONDS=0"),
|
||||
valid.replace("MINIMUM_USDC_RESERVE=0", "MINIMUM_USDC_RESERVE=-1"),
|
||||
valid.replace("MAXIMUM_WITHDRAWAL_SLIPPAGE_BPS=100", "MAXIMUM_WITHDRAWAL_SLIPPAGE_BPS=10000"),
|
||||
valid.replace("MAXIMUM_SWAP_SLIPPAGE_BPS=100", "MAXIMUM_SWAP_SLIPPAGE_BPS=10001"),
|
||||
valid.replace(RECIPIENT, "invalid-address"))) {
|
||||
Files.writeString(path, invalid); assertThrows(IllegalArgumentException.class, () -> EvelynLiquidityConfiguration.parse(path));
|
||||
}
|
||||
}
|
||||
private static final class Fixture {
|
||||
final Map<String, RaydiumConcentratedPositionState> positions = new LinkedHashMap<>();
|
||||
final Map<String, BigDecimal> balances = new HashMap<>();
|
||||
final List<String> operations = new CopyOnWriteArrayList<>(), balanceReads = new ArrayList<>();
|
||||
final CountDownLatch entered = new CountDownLatch(1), secondEntered = new CountDownLatch(1), interrupted = new CountDownLatch(1);
|
||||
final AtomicInteger withdrawCount = new AtomicInteger();
|
||||
BigInteger sqrt = BigInteger.ONE.shiftLeft(64);
|
||||
boolean reversed, wrongPool, priceFails, withdrawFails, swapFails, sendFails, interruptWithdraw, blockWithdraw, creditProceeds, creditSwap;
|
||||
String unreadable, recipient, walletAddress = OWNER;
|
||||
int swapSlippage, withdrawSlippage;
|
||||
Object creationPolicy;
|
||||
SolanaWallet withdrawWallet;
|
||||
RaydiumConcentratedPositionState withdrawPosition;
|
||||
RaydiumConcentratedPoolState withdrawQuote;
|
||||
int poolReads;
|
||||
Runnable afterDiscovery = () -> { }, afterWithdrawal = () -> { };
|
||||
final SolanaWallet wallet = proxy(SolanaWallet.class, (method, args) -> switch (method) {
|
||||
case "getAddress" -> walletAddress;
|
||||
case "getSPLTokenBalance" -> { balanceReads.add((String) args[0]); yield balances.get(args[0]); }
|
||||
case "sendSPLToken" -> {
|
||||
operations.add("send:" + args[2]); recipient = (String) args[1]; creationPolicy = args[3];
|
||||
if (sendFails) throw new IOException("fixture payout failure");
|
||||
balances.put(USDC, balances.get(USDC).subtract((BigDecimal) args[2])); yield "payout-signature";
|
||||
}
|
||||
default -> throw new AssertionError("Unexpected wallet operation: " + method);
|
||||
});
|
||||
final JupiterSwapService swap = proxy(JupiterSwapService.class, (method, args) -> {
|
||||
assertEquals("swap", method); assertEquals(SYRUP, args[0]); assertEquals(USDC, args[2]);
|
||||
operations.add("swap:" + args[1]); swapSlippage = (int) args[3];
|
||||
if (swapFails) throw new IOException("fixture swap failure");
|
||||
balances.put(SYRUP, balances.get(SYRUP).subtract((BigDecimal) args[1]));
|
||||
if(creditSwap) balances.merge(USDC,new BigDecimal("2.5"),BigDecimal::add);
|
||||
return new JupiterSwapResult("swap-signature", (BigDecimal) args[1], creditSwap ? new BigDecimal("2.5") : BigDecimal.ONE);
|
||||
});
|
||||
final SolanaBlockChain chain = proxy(SolanaBlockChain.class, (method, args) -> switch (method) {
|
||||
case "getAccountInfo" -> {
|
||||
if (RECIPIENT.equals(args[0])) yield null;
|
||||
byte[] data = new byte[82]; data[44] = (byte) (EVE.equals(args[0]) ? 8 : 6); data[45] = 1;
|
||||
yield new SolanaAccountInfo((String) args[0], SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM.getAddress(), Base64.getEncoder().encodeToString(data));
|
||||
}
|
||||
case "awaitTransaction" -> new SolanaTransactionOutcome(SolanaTransactionOutcome.Status.SUCCEEDED, 1L, null);
|
||||
default -> throw new AssertionError("Unexpected chain operation: " + method);
|
||||
});
|
||||
final Raydium raydium = proxy(Raydium.class, (method, args) -> switch (method) {
|
||||
case "fetchConcentratedPoolInfoOnChain" -> new RaydiumConcentratedPoolInfo(POOL, wrongPool ? USDC : reversed ? SYRUP : EVE, reversed ? 6 : 8, reversed ? EVE : SYRUP, reversed ? 8 : 6, BigDecimal.ONE);
|
||||
case "fetchConcentratedPoolState" -> {
|
||||
poolReads++;
|
||||
if (priceFails) throw new IOException("fixture pool price failure");
|
||||
yield new RaydiumConcentratedPoolState(POOL, BigInteger.ONE, sqrt, 0);
|
||||
}
|
||||
case "fetchConcentratedPositionNftIds" -> { assertEquals(OWNER, args[0]); afterDiscovery.run(); yield new LinkedHashSet<>(positions.keySet()); }
|
||||
case "fetchConcentratedPositionState" -> {
|
||||
if (args[0].equals(unreadable)) throw new IOException("fixture unreadable position"); yield positions.get(args[0]);
|
||||
}
|
||||
case "withdrawConcentratedPositionLiquidity" -> {
|
||||
withdrawWallet = (SolanaWallet) args[0]; withdrawPosition = (RaydiumConcentratedPositionState) args[1]; withdrawSlippage = (int) args[3];
|
||||
withdrawQuote = (RaydiumConcentratedPoolState) args[2];
|
||||
operations.add("withdraw:" + withdrawPosition.nftId());
|
||||
if (interruptWithdraw) throw new InterruptedException("fixture interrupted");
|
||||
if (blockWithdraw) {
|
||||
if (withdrawCount.incrementAndGet() > 1) secondEntered.countDown(); entered.countDown();
|
||||
try { new CountDownLatch(1).await(); }
|
||||
catch (InterruptedException exception) { interrupted.countDown(); throw exception; }
|
||||
}
|
||||
if (withdrawFails) throw new IOException("fixture withdrawal failure");
|
||||
if(creditProceeds) balances.merge(SYRUP,new BigDecimal("3.04"),BigDecimal::add);
|
||||
positions.put(withdrawPosition.nftId(), new RaydiumConcentratedPositionState(withdrawPosition.nftId(), POOL, -100, 0, BigInteger.ZERO));
|
||||
afterWithdrawal.run();
|
||||
yield "withdrawal-signature";
|
||||
}
|
||||
default -> throw new AssertionError("Unexpected Raydium operation: " + method);
|
||||
});
|
||||
Fixture() { positions.put("owned", new RaydiumConcentratedPositionState("owned", POOL, -100, 0, BigInteger.valueOf(1_000_000))); }
|
||||
EvelynLiquidityServiceImpl service(String syrupReserve, String usdcReserve, String distance) {
|
||||
return service(syrupReserve, usdcReserve, distance, Duration.ofSeconds(300));
|
||||
}
|
||||
EvelynLiquidityServiceImpl service(String syrupReserve, String usdcReserve, String distance, Duration interval) {
|
||||
return new EvelynLiquidityServiceImpl(wallet, swap, raydium, chain, CIS, new EvelynLiquidityConfiguration(OWNER, "liquidity", POOL,
|
||||
new BigDecimal(distance), interval, RECIPIENT, new BigDecimal(syrupReserve), new BigDecimal(usdcReserve), 100, 100));
|
||||
}
|
||||
}
|
||||
@FunctionalInterface private interface Handler { Object invoke(String method, Object[] args) throws Throwable; }
|
||||
@SuppressWarnings("unchecked") private static <T> T proxy(Class<T> type, Handler handler) {
|
||||
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] {type}, (object, method, args) -> handler.invoke(method.getName(), args));
|
||||
}
|
||||
private static String address(int number) { byte[] data = new byte[32]; data[31] = (byte) number; return new Base58CodecImpl().encode(data); }
|
||||
private static final HardcodedCurrencyIdentityService CIS = new HardcodedCurrencyIdentityService();
|
||||
private static String mint(UUID id) { return CIS.findExternalReferences(id).iterator().next().externalId(); }
|
||||
private static final String EVE = mint(EVE_ID), SYRUP = mint(SYRUPUSDC_ID), USDC = mint(USDC_ID);
|
||||
private static final String OWNER = address(1), POOL = address(2), RECIPIENT = address(3);
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
package com.r35157.evelyn.service.status.impl.ref;
|
||||
|
||||
import com.fanitas.evelyn.core.EvelynStatusIndexPoint;
|
||||
import com.r35157.assetaz.services.cis.impl.hc.HardcodedCurrencyIdentityService;
|
||||
import com.r35157.assetaz.services.ticker.PriceObservation;
|
||||
import com.r35157.assetaz.services.ticker.TickerService;
|
||||
import com.r35157.evelyn.service.status.EvelynStatusService;
|
||||
import com.r35157.libs.valuetypes.basic.AssetPrice;
|
||||
import com.r35157.libs.valuetypes.basic.TradingPair;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.EVE_ID;
|
||||
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.USDT_ID;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class EvelynStatusServiceImplTest {
|
||||
@Test
|
||||
void publicApiCalculatesRequestedCurveWithCanonicalTradingPair(@TempDir Path root) {
|
||||
try (EvelynStatusService service = service("Curve", UNUSED_TICKER, UNUSED_CLOCK, root)) {
|
||||
assertPrice("15", service.getReferencePrice(START));
|
||||
assertPrice("18", service.getReferencePrice(START.plusSeconds(31_536_000)));
|
||||
|
||||
AssetPrice fractional = service.getReferencePrice(START.plusSeconds(1_566_494));
|
||||
assertTrue(fractional.price().subtract(new BigDecimal("15.136464436276"))
|
||||
.abs().compareTo(new BigDecimal("0.000000000001")) < 0);
|
||||
assertSame(EVE_USDT, fractional.tradingPair());
|
||||
assertTrue(service.getStatusIndexHistory().isEmpty());
|
||||
assertTrue(Files.notExists(root.resolve("Curve")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicApiRejectsNullAndPreStartInstantsWithoutSideEffects(@TempDir Path root) {
|
||||
try (EvelynStatusService service = service("Invalid", UNUSED_TICKER, UNUSED_CLOCK, root)) {
|
||||
assertThrows(NullPointerException.class, () -> service.getReferencePrice(null));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.getReferencePrice(START.minusNanos(1)));
|
||||
assertTrue(service.getStatusIndexHistory().isEmpty());
|
||||
assertTrue(Files.notExists(root.resolve("Invalid")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void referencePriceWorksWhileStoppedWithoutProvisionedStorage(@TempDir Path root) {
|
||||
try (EvelynStatusService service = service("Stopped", UNUSED_TICKER, UNUSED_CLOCK, root)) {
|
||||
service.stop();
|
||||
assertPrice("18", service.getReferencePrice(START.plusSeconds(31_536_000)));
|
||||
assertTrue(service.getStatusIndexHistory().isEmpty());
|
||||
assertTrue(Files.notExists(root.resolve("Stopped")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void samplingPersistsSharedIndexAndRestoresHistoryAcrossRestart(@TempDir Path root)
|
||||
throws Exception {
|
||||
Path history = provision(root, "History", "2");
|
||||
String original = Files.readString(history);
|
||||
try (EvelynStatusService service = service("History", ticker("16.5"), FIXED_CLOCK, root)) {
|
||||
service.start();
|
||||
List<EvelynStatusIndexPoint> snapshot = awaitHistory(service, 2);
|
||||
assertDecimal("1", snapshot.get(1).evelynPriceIndex());
|
||||
assertEquals(START, snapshot.get(1).timestamp());
|
||||
assertZeroPlaceholders(snapshot.get(1));
|
||||
assertThrows(UnsupportedOperationException.class, () -> snapshot.add(snapshot.getFirst()));
|
||||
|
||||
// Observing a published point must also observe its durable record.
|
||||
String persisted = Files.readString(history);
|
||||
assertTrue(persisted.startsWith(original + "\n"));
|
||||
assertEquals(3, persisted.lines().count());
|
||||
byte[] beforeRequest = Files.readAllBytes(history);
|
||||
assertPrice("18", service.getReferencePrice(START.plusSeconds(31_536_000)));
|
||||
assertEquals(snapshot, service.getStatusIndexHistory());
|
||||
assertArrayEquals(beforeRequest, Files.readAllBytes(history));
|
||||
|
||||
service.stop();
|
||||
assertTrue(service.getStatusIndexHistory().isEmpty());
|
||||
assertEquals(2, snapshot.size());
|
||||
assertArrayEquals(beforeRequest, Files.readAllBytes(history));
|
||||
|
||||
Files.delete(history);
|
||||
assertPrice("15", service.getReferencePrice(START));
|
||||
assertTrue(Files.notExists(history));
|
||||
Files.write(history, beforeRequest);
|
||||
|
||||
service.start();
|
||||
List<EvelynStatusIndexPoint> restored = awaitHistory(service, 3);
|
||||
assertEquals(snapshot, restored.subList(0, 2));
|
||||
assertDecimal("1", restored.get(2).evelynPriceIndex());
|
||||
service.stop();
|
||||
byte[] beforeClose = Files.readAllBytes(history);
|
||||
service.close();
|
||||
assertArrayEquals(beforeClose, Files.readAllBytes(history));
|
||||
assertThrows(IllegalStateException.class, service::start);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void productionAndTestKeepIndependentNamedHistories(@TempDir Path root) throws Exception {
|
||||
Path productionFile = provision(root, "Production", "2");
|
||||
Path testFile = provision(root, "Test", "-2");
|
||||
try (EvelynStatusService production = service("Production", ticker("16.5"), FIXED_CLOCK, root);
|
||||
EvelynStatusService test = service("Test", ticker("13.5"), FIXED_CLOCK, root)) {
|
||||
production.start();
|
||||
test.start();
|
||||
List<EvelynStatusIndexPoint> productionHistory = awaitHistory(production, 2);
|
||||
List<EvelynStatusIndexPoint> testHistory = awaitHistory(test, 2);
|
||||
assertDecimal("2", productionHistory.getFirst().evelynPriceIndex());
|
||||
assertDecimal("-2", testHistory.getFirst().evelynPriceIndex());
|
||||
assertDecimal("1", productionHistory.get(1).evelynPriceIndex());
|
||||
assertDecimal("-1", testHistory.get(1).evelynPriceIndex());
|
||||
assertPrice("15", production.getReferencePrice(START));
|
||||
assertPrice("15", test.getReferencePrice(START));
|
||||
byte[] testBytes = Files.readAllBytes(testFile);
|
||||
production.stop();
|
||||
assertTrue(production.getStatusIndexHistory().isEmpty());
|
||||
assertEquals(testHistory, test.getStatusIndexHistory());
|
||||
assertArrayEquals(testBytes, Files.readAllBytes(testFile));
|
||||
assertEquals(3, Files.readString(productionFile).lines().count());
|
||||
}
|
||||
}
|
||||
|
||||
private static EvelynStatusService service(String name, TickerService ticker, Clock clock, Path root) {
|
||||
return new EvelynStatusServiceImpl(name, ticker, EVE_USDT, clock, 1, TimeUnit.DAYS, root);
|
||||
}
|
||||
|
||||
private static TickerService ticker(String actualPrice) {
|
||||
return pair -> {
|
||||
assertSame(EVE_USDT, pair);
|
||||
return new PriceObservation(new AssetPrice(new BigDecimal(actualPrice), pair), START, "offline");
|
||||
};
|
||||
}
|
||||
|
||||
private static Path provision(Path root, String name, String existingIndex) throws Exception {
|
||||
Path history = Files.createDirectory(root.resolve(name)).resolve("status.log");
|
||||
Files.writeString(history, "FORMAT_VERSION=1\n20260731215959000Z:1:" + existingIndex);
|
||||
return history;
|
||||
}
|
||||
|
||||
private static List<EvelynStatusIndexPoint> awaitHistory(EvelynStatusService service, int size)
|
||||
throws InterruptedException {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
List<EvelynStatusIndexPoint> history;
|
||||
do {
|
||||
history = service.getStatusIndexHistory();
|
||||
if (history.size() == size) {
|
||||
return history;
|
||||
}
|
||||
Thread.sleep(10);
|
||||
} while (System.nanoTime() < deadline);
|
||||
assertEquals(size, history.size(), "Expected persisted sample was not published");
|
||||
return history;
|
||||
}
|
||||
|
||||
private static void assertDecimal(String expected, BigDecimal actual) {
|
||||
assertEquals(0, new BigDecimal(expected).compareTo(actual));
|
||||
}
|
||||
|
||||
private static void assertPrice(String expected, AssetPrice actual) {
|
||||
assertDecimal(expected, actual.price());
|
||||
assertSame(EVE_USDT, actual.tradingPair());
|
||||
assertEquals(EVE_ID, actual.tradingPair().base().id());
|
||||
assertEquals(USDT_ID, actual.tradingPair().quote().id());
|
||||
}
|
||||
|
||||
private static void assertZeroPlaceholders(EvelynStatusIndexPoint point) {
|
||||
assertEquals(BigDecimal.ZERO, point.eveSyrupPoolDepthIndex());
|
||||
assertEquals(BigDecimal.ZERO, point.eveSyrupPoolBalanceIndex());
|
||||
assertEquals(BigDecimal.ZERO, point.aazdkkUsdtPoolBalanceIndex());
|
||||
assertEquals(BigDecimal.ZERO, point.aazdkkUsdtPoolPriceIndex());
|
||||
assertEquals(BigDecimal.ZERO, point.aazdkkUsdtPoolDepthIndex());
|
||||
}
|
||||
|
||||
private static final Instant START = Instant.parse("2026-07-31T22:00:00Z");
|
||||
private static final Clock FIXED_CLOCK = Clock.fixed(START, ZoneOffset.UTC);
|
||||
private static final TradingPair EVE_USDT;
|
||||
static {
|
||||
HardcodedCurrencyIdentityService currencies = new HardcodedCurrencyIdentityService();
|
||||
EVE_USDT = new TradingPair(currencies.resolve(EVE_ID), currencies.resolve(USDT_ID));
|
||||
}
|
||||
private static final TickerService UNUSED_TICKER = pair -> {
|
||||
throw new AssertionError("Reference-price requests must not fetch a market price");
|
||||
};
|
||||
private static final Clock UNUSED_CLOCK = new Clock() {
|
||||
@Override
|
||||
public ZoneId getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
throw new AssertionError("Reference-price requests must use the supplied instant");
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.r35157.libs.raydium;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RaydiumClmmMathTest {
|
||||
@Test void matchesOfficialFixedPointExtremaAndTickZero() {
|
||||
assertEquals(new BigInteger("4295048016"), RaydiumClmmMath.sqrtPriceAtTick(-443636));
|
||||
assertEquals(new BigInteger("79226673521066979257578248091"), RaydiumClmmMath.sqrtPriceAtTick(443636));
|
||||
assertEquals(BigInteger.ONE.shiftLeft(64), RaydiumClmmMath.sqrtPriceAtTick(0));
|
||||
assertThrows(IllegalArgumentException.class, () -> RaydiumClmmMath.sqrtPriceAtTick(443637));
|
||||
}
|
||||
@Test void exactEqualityAndOneUnitAboveDoNotDependOnDisplayRounding() {
|
||||
var position = position(-100, 100);
|
||||
var boundary = RaydiumClmmMath.sqrtPriceAtTick(100);
|
||||
assertTrue(eligible(position, boundary, true, "0"));
|
||||
assertFalse(eligible(position, boundary.subtract(BigInteger.ONE), true, "0"));
|
||||
// Lower boundary qualifies but upper boundary does not.
|
||||
assertFalse(eligible(position, RaydiumClmmMath.sqrtPriceAtTick(0), true, "0"));
|
||||
assertFalse(eligible(position, boundary, true, "10"));
|
||||
assertTrue(eligible(position, RaydiumClmmMath.sqrtPriceAtTick(1200), true, "10"));
|
||||
// Exactly representable equality with a nonzero configured harvest distance.
|
||||
BigInteger q64=BigInteger.ONE.shiftLeft(64);
|
||||
assertTrue(eligible(position(-100,0),q64.multiply(BigInteger.TWO),true,"75"));
|
||||
assertFalse(eligible(position(-100,0),q64.multiply(BigInteger.TWO).subtract(BigInteger.ONE),true,"75"));
|
||||
assertTrue(eligible(position(0,100),q64.divide(BigInteger.TWO),false,"75"));
|
||||
assertFalse(eligible(position(0,100),q64.divide(BigInteger.TWO).add(BigInteger.ONE),false,"75"));
|
||||
}
|
||||
@Test void inversionUsesRawLowerBoundaryAsNormalizedUpperBoundary() {
|
||||
var position = position(-100, 100);
|
||||
var boundary = RaydiumClmmMath.sqrtPriceAtTick(-100);
|
||||
assertTrue(eligible(position, boundary, false, "0"));
|
||||
assertFalse(eligible(position, boundary.add(BigInteger.ONE), false, "0"));
|
||||
assertFalse(eligible(position, RaydiumClmmMath.sqrtPriceAtTick(0), false, "0"));
|
||||
assertTrue(eligible(position, RaydiumClmmMath.sqrtPriceAtTick(-1200), false, "10"));
|
||||
}
|
||||
@Test void principalIsFlooredAndOneSidedOutsideTheRange() {
|
||||
var position = position(-100, 100);
|
||||
var above = RaydiumClmmMath.principalRawAmounts(position, pool(RaydiumClmmMath.sqrtPriceAtTick(200)));
|
||||
var below = RaydiumClmmMath.principalRawAmounts(position, pool(RaydiumClmmMath.sqrtPriceAtTick(-200)));
|
||||
assertEquals(BigInteger.ZERO, above[0]);
|
||||
assertEquals(new BigInteger("9999"), above[1]);
|
||||
assertEquals(BigInteger.ZERO, below[1]);
|
||||
assertTrue(below[0].signum() > 0);
|
||||
assertThrows(IllegalArgumentException.class, () -> eligible(position, BigInteger.ZERO, true, "0"));
|
||||
}
|
||||
private static boolean eligible(RaydiumConcentratedPositionState position, BigInteger price, boolean direct, String distance) {
|
||||
return RaydiumClmmMath.isAtOrBelowHarvestThreshold(position, pool(price), direct, new BigDecimal(distance));
|
||||
}
|
||||
private static RaydiumConcentratedPositionState position(int lower, int upper) {
|
||||
return new RaydiumConcentratedPositionState("nft", "pool", lower, upper, BigInteger.valueOf(1_000_000));
|
||||
}
|
||||
private static RaydiumConcentratedPoolState pool(BigInteger sqrt) {
|
||||
return new RaydiumConcentratedPoolState("pool", BigInteger.ONE, sqrt, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package com.r35157.libs.raydium.impl.ref;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.r35157.assetaz.services.cis.impl.hc.HardcodedCurrencyIdentityService;
|
||||
import com.r35157.cryptowallet.solana.SolanaWallet;
|
||||
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
|
||||
import com.r35157.libs.raydium.*;
|
||||
import com.r35157.libs.solana.*;
|
||||
import com.r35157.libs.solana.impl.ref.SolanaBlockChainImpl;
|
||||
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.io.*;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.math.*;
|
||||
import java.net.http.HttpClient;
|
||||
import java.nio.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RaydiumClmmWithdrawalTest {
|
||||
@Test void fullDecreaseMatchesOfficialAccountsAndKeepsNftReadOnly() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
assertEquals("fixture-signature", fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 100));
|
||||
Parsed transaction = parse(fixture.transaction);
|
||||
assertEquals(4, transaction.instructions.size()); // Compute budget, two ATAs and decrease; no closes.
|
||||
assertEquals("ComputeBudget111111111111111111111111111111", transaction.instructions.getFirst().program);
|
||||
for (int i = 1; i < 3; i++) {
|
||||
assertEquals(ATA, transaction.instructions.get(i).program);
|
||||
assertArrayEquals(new byte[] {1}, transaction.instructions.get(i).data);
|
||||
}
|
||||
ParsedInstruction instruction = transaction.instructions.get(3);
|
||||
assertEquals(RaydiumClmmWithdrawal.PROGRAM, instruction.program);
|
||||
assertEquals(List.of(OWNER, NFT_ACCOUNT, fixture.personal, POOL, fixture.protocol, VAULT_A, VAULT_B,
|
||||
fixture.lowerArray, fixture.upperArray, fixture.ataA, fixture.ataB, LEGACY, TOKEN2022, MEMO, EVE, SYRUP), instruction.accounts);
|
||||
assertEquals(40, instruction.data.length);
|
||||
assertArrayEquals(new byte[] {58,127,(byte)188,62,79,82,(byte)196,96}, Arrays.copyOf(instruction.data, 8));
|
||||
assertEquals(fixture.position.liquidity(), unsigned(instruction.data, 8, 16));
|
||||
assertEquals(BigInteger.ZERO, unsigned(instruction.data, 24, 8));
|
||||
// Independent decimal calculation for ticks [-120,0], L=1e9.
|
||||
// Principal token1 = floor(L * (1 - sqrtPrice(-120)/Q64)).
|
||||
BigInteger expected = BigInteger.valueOf(1_000_000_000L)
|
||||
.multiply(BigInteger.ONE.shiftLeft(64).subtract(RaydiumClmmMath.sqrtPriceAtTick(-120)))
|
||||
.divide(BigInteger.ONE.shiftLeft(64)).multiply(BigInteger.valueOf(9900)).divide(BigInteger.valueOf(10000));
|
||||
assertEquals(expected, unsigned(instruction.data, 32, 8));
|
||||
assertTrue(expected.signum() > 0);
|
||||
assertFalse(transaction.writable.contains(NFT_ACCOUNT));
|
||||
assertFalse(transaction.keys.contains(NFT));
|
||||
assertTrue(transaction.writable.contains(fixture.personal));
|
||||
assertEquals(1, fixture.submissions); assertEquals(1, fixture.confirmations);
|
||||
assertTrue(fixture.accounts.containsKey(fixture.personal));
|
||||
}
|
||||
@Test void directMetadataSupportsActualEveMintExtensionsAndPrecision() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.range(-120,0,0);
|
||||
var info = fixture.operation().poolInfo(POOL);
|
||||
assertEquals(EVE, info.mintA()); assertEquals(8, info.mintADecimals());
|
||||
assertEquals(SYRUP, info.mintB()); assertEquals(6, info.mintBDecimals());
|
||||
assertEquals(0, new BigDecimal("100").compareTo(info.priceEstimate()));
|
||||
assertEquals(0, fixture.submissions);
|
||||
}
|
||||
@Test void reversedTokenOrderingProtectsSyrupOnTheOtherSide() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.reversed();
|
||||
fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 50);
|
||||
ParsedInstruction instruction = parse(fixture.transaction).instructions.get(3);
|
||||
assertEquals(SYRUP, instruction.accounts.get(14)); assertEquals(EVE, instruction.accounts.get(15));
|
||||
assertTrue(unsigned(instruction.data, 24, 8).signum() > 0);
|
||||
assertEquals(BigInteger.ZERO, unsigned(instruction.data, 32, 8));
|
||||
}
|
||||
@Test void invalidOwnerChangedPositionAndUnprotectedWithdrawalFailBeforeSigning() throws Exception {
|
||||
for (int scenario = 0; scenario < 7; scenario++) {
|
||||
Fixture fixture = new Fixture();
|
||||
switch (scenario) {
|
||||
case 0 -> fixture.ownsNft = false;
|
||||
case 1 -> { byte[] personal = fixture.data(fixture.personal); personal[81]++; fixture.put(fixture.personal, RaydiumClmmWithdrawal.PROGRAM, personal); }
|
||||
case 2 -> { byte[] data = fixture.data(NFT_ACCOUNT); data[32]++; fixture.put(NFT_ACCOUNT, LEGACY, data); }
|
||||
case 3 -> { byte[] data = fixture.data(POOL); data[0]++; fixture.put(POOL, RaydiumClmmWithdrawal.PROGRAM, data); }
|
||||
case 4 -> { byte[] mint = fixture.data(EVE); mint[166] = 1; fixture.put(EVE, TOKEN2022, mint); }
|
||||
case 5 -> fixture.put(VAULT_B, LEGACY, new byte[165]);
|
||||
case 6 -> { byte[] data = fixture.data(fixture.upperArray); data[40]++; fixture.put(fixture.upperArray, RaydiumClmmWithdrawal.PROGRAM, data); }
|
||||
}
|
||||
assertThrows(Exception.class, () -> fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 100));
|
||||
assertEquals(0, fixture.submissions); assertNull(fixture.transaction);
|
||||
}
|
||||
Fixture fixture = new Fixture();
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 10000));
|
||||
var tiny = new RaydiumConcentratedPositionState(NFT, POOL, -120, 0, BigInteger.ONE);
|
||||
assertThrows(IllegalArgumentException.class, () -> fixture.operation().withdraw(fixture.wallet, tiny, fixture.quote, 100));
|
||||
assertNull(fixture.transaction);
|
||||
}
|
||||
@Test void bitmapExtensionAndInitializedRewardUseCorrectAccounts() throws Exception {
|
||||
Fixture fixture = new Fixture(); fixture.tickSpacing=1;
|
||||
byte[] spacingPool=fixture.data(POOL);ByteBuffer.wrap(spacingPool,235,2).order(ByteOrder.LITTLE_ENDIAN).putShort((short)1);
|
||||
fixture.put(POOL,RaydiumClmmWithdrawal.PROGRAM,spacingPool);
|
||||
fixture.range(-31000, -30900, -30800);
|
||||
String bitmap = fixture.pda("pool_tick_array_bitmap_extension", SolanaProgramAddressSeed.solanaAddress(POOL));
|
||||
byte[] bitmapData = account("TickArrayBitmapExtension", 40); addressInto(bitmapData, 8, POOL);
|
||||
fixture.put(bitmap, RaydiumClmmWithdrawal.PROGRAM, bitmapData);
|
||||
byte[] pool = fixture.data(POOL); String reward = address(20), rewardVault = address(21);
|
||||
addressInto(pool, 397 + 57, reward); addressInto(pool, 397 + 89, rewardVault);
|
||||
fixture.put(POOL, RaydiumClmmWithdrawal.PROGRAM, pool);
|
||||
fixture.put(reward, LEGACY, mint(6, false)); fixture.put(rewardVault, LEGACY, token(reward, POOL));
|
||||
fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 100);
|
||||
Parsed transaction = parse(fixture.transaction);
|
||||
ParsedInstruction decrease = transaction.instructions.get(transaction.instructions.size() - 1);
|
||||
assertEquals(bitmap, decrease.accounts.get(16));
|
||||
assertEquals(rewardVault, decrease.accounts.get(17));
|
||||
assertEquals(fixture.math.findAssociatedTokenAccount(OWNER, reward, SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM), decrease.accounts.get(18));
|
||||
assertEquals(reward, decrease.accounts.get(19));
|
||||
}
|
||||
@Test void token2022PositionNftIsPreservedAndUsesItsOwnerAccount() throws Exception {
|
||||
Fixture fixture = new Fixture(); fixture.nftProgram=TOKEN2022;
|
||||
fixture.put(NFT,TOKEN2022,mint(0,true)); fixture.put(NFT_ACCOUNT,TOKEN2022,token(NFT,OWNER));
|
||||
fixture.operation().withdraw(fixture.wallet,fixture.position,fixture.quote,100);
|
||||
Parsed transaction=parse(fixture.transaction);
|
||||
assertEquals(NFT_ACCOUNT,transaction.instructions.getLast().accounts.get(1));
|
||||
assertFalse(transaction.writable.contains(NFT_ACCOUNT));
|
||||
}
|
||||
@Test void existingOwnerAccountsUnderBothProgramsAreReusedEvenWhenEmpty() throws Exception {
|
||||
for (boolean associated : List.of(false, true)) {
|
||||
Fixture fixture = new Fixture();
|
||||
String eveAccount = associated ? fixture.ataA : address(40);
|
||||
String syrupAccount = associated ? fixture.ataB : address(41);
|
||||
fixture.holding(eveAccount, EVE, SolanaSPLTokenProgram.TOKEN_2022_PROGRAM, 8, BigDecimal.ZERO);
|
||||
fixture.holding(syrupAccount, SYRUP, SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM, 6, BigDecimal.ONE);
|
||||
fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 100);
|
||||
Parsed transaction = parse(fixture.transaction);
|
||||
assertEquals(2, transaction.instructions.size()); // Compute budget and decrease, no new accounts.
|
||||
ParsedInstruction decrease = transaction.instructions.getLast();
|
||||
assertEquals(eveAccount, decrease.accounts.get(9));
|
||||
assertEquals(syrupAccount, decrease.accounts.get(10));
|
||||
assertEquals(syrupAccount, fixture.chain.getSPLTokenHoldings(OWNER, SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM).get(SYRUP).tokenAccount());
|
||||
if (!associated) {
|
||||
assertFalse(transaction.keys.contains(fixture.ataA));
|
||||
assertFalse(transaction.keys.contains(fixture.ataB));
|
||||
}
|
||||
assertEquals(1, fixture.submissions);
|
||||
}
|
||||
}
|
||||
@Test void existingRewardAccountIsReusedUnderEitherTokenProgram() throws Exception {
|
||||
for (SolanaSPLTokenProgram program : SolanaSPLTokenProgram.values()) {
|
||||
Fixture fixture = new Fixture();
|
||||
String reward = address(42), vault = address(43), destination = address(44);
|
||||
byte[] pool = fixture.data(POOL);
|
||||
addressInto(pool, 397 + 57, reward); addressInto(pool, 397 + 89, vault);
|
||||
fixture.put(POOL, RaydiumClmmWithdrawal.PROGRAM, pool);
|
||||
fixture.put(reward, program.getAddress(), mint(6, program == SolanaSPLTokenProgram.TOKEN_2022_PROGRAM));
|
||||
fixture.put(vault, program.getAddress(), token(reward, POOL));
|
||||
fixture.holding(destination, reward, program, 6, BigDecimal.ZERO);
|
||||
fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 100);
|
||||
Parsed transaction = parse(fixture.transaction);
|
||||
ParsedInstruction decrease = transaction.instructions.getLast();
|
||||
assertEquals(vault, decrease.accounts.get(16));
|
||||
assertEquals(destination, decrease.accounts.get(17));
|
||||
assertEquals(reward, decrease.accounts.get(18));
|
||||
assertFalse(transaction.keys.contains(fixture.math.findAssociatedTokenAccount(OWNER, reward, program)));
|
||||
assertEquals(4, transaction.instructions.size()); // Only the two absent pool-token ATAs.
|
||||
}
|
||||
}
|
||||
@Test void invalidExistingOwnerAccountFailsBeforeSigningWithoutReplacement() throws Exception {
|
||||
for (int scenario = 0; scenario < 5; scenario++) {
|
||||
Fixture fixture = new Fixture(); String existing = address(45);
|
||||
fixture.holding(existing, SYRUP, SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM, 6, BigDecimal.ONE);
|
||||
byte[] data = fixture.data(existing); String program = LEGACY;
|
||||
switch (scenario) {
|
||||
case 0 -> addressInto(data, 32, address(46));
|
||||
case 1 -> addressInto(data, 0, EVE);
|
||||
case 2 -> program = TOKEN2022;
|
||||
case 3 -> data[108] = 0;
|
||||
case 4 -> data[108] = 2;
|
||||
}
|
||||
fixture.put(existing, program, data);
|
||||
assertThrows(IOException.class, () -> fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 100));
|
||||
assertEquals(0, fixture.submissions); assertNull(fixture.transaction);
|
||||
}
|
||||
}
|
||||
@Test void failedDestinationHoldingsReadDoesNotFallBackToCreatingAnAta() throws Exception {
|
||||
Fixture fixture = new Fixture(); fixture.unreadableHoldingsProgram = TOKEN2022;
|
||||
assertThrows(IOException.class, () -> fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 100));
|
||||
assertEquals(0, fixture.submissions); assertNull(fixture.transaction);
|
||||
}
|
||||
@Test void discoveryIsolatesCandidateAndTokenProgramReadFailures() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
String badNft=address(31);fixture.unreadablePosition=fixture.pda("position",SolanaProgramAddressSeed.solanaAddress(badNft));
|
||||
fixture.candidateNfts=Set.of(NFT,badNft);
|
||||
RaydiumImpl raydium=new RaydiumImpl(HttpClient.newHttpClient(),new ObjectMapper(),fixture.chain,new HardcodedCurrencyIdentityService());
|
||||
assertEquals(Set.of(NFT),raydium.fetchConcentratedPositionNftIds(OWNER));
|
||||
fixture.legacyDiscoveryFails=true;fixture.nftProgram=TOKEN2022;
|
||||
fixture.put(NFT,TOKEN2022,mint(0,true));fixture.put(NFT_ACCOUNT,TOKEN2022,token(NFT,OWNER));
|
||||
assertEquals(Set.of(NFT),raydium.fetchConcentratedPositionNftIds(OWNER));
|
||||
assertEquals(0,fixture.submissions);
|
||||
}
|
||||
@Test void unknownOutcomeIsReportedWithSignatureWithoutRetry() throws Exception {
|
||||
Fixture fixture = new Fixture(); fixture.timeout = true;
|
||||
IOException failure = assertThrows(IOException.class, () -> fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 100));
|
||||
assertTrue(failure.getMessage().contains("fixture-signature")); assertEquals(1, fixture.submissions); assertEquals(1, fixture.confirmations);
|
||||
}
|
||||
private static final class Fixture {
|
||||
final SolanaBlockChainImpl math = new SolanaBlockChainImpl(HttpClient.newHttpClient(), new ObjectMapper(), new HardcodedCurrencyIdentityService());
|
||||
final Map<String, SolanaAccountInfo> accounts = new HashMap<>();
|
||||
final Map<String, SPLTokenHolding> ownerHoldings = new HashMap<>();
|
||||
String personal, protocol, lowerArray, upperArray, ataA, ataB;
|
||||
RaydiumConcentratedPositionState position;
|
||||
RaydiumConcentratedPoolState quote;
|
||||
SolanaUnsignedTransaction transaction;
|
||||
boolean ownsNft = true, timeout, legacyDiscoveryFails;
|
||||
String nftProgram=LEGACY,unreadablePosition,unreadableHoldingsProgram;
|
||||
Set<String> candidateNfts=Set.of(NFT);
|
||||
int submissions, confirmations, tickSpacing=60;
|
||||
final SolanaWallet wallet = proxy(SolanaWallet.class, (method, args) -> switch (method) {
|
||||
case "getAddress" -> OWNER;
|
||||
case "signAndSendTransaction" -> { transaction = (SolanaUnsignedTransaction) args[0]; submissions++; yield "fixture-signature"; }
|
||||
default -> throw new AssertionError("Unexpected wallet operation " + method);
|
||||
});
|
||||
final SolanaBlockChain chain = proxy(SolanaBlockChain.class, (method, args) -> switch (method) {
|
||||
case "getAccountInfo" -> {
|
||||
if(args[0].equals(unreadablePosition)) throw new IOException("fixture unreadable candidate");
|
||||
yield accounts.get(args[0]);
|
||||
}
|
||||
case "getSolanaNFTCandidateAddresses" -> {
|
||||
assertEquals(OWNER,args[0]);
|
||||
if(legacyDiscoveryFails && args[1]==SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM) throw new IOException("fixture legacy discovery failure");
|
||||
yield ((SolanaSPLTokenProgram)args[1]).getAddress().equals(nftProgram) ? candidateNfts : Set.of();
|
||||
}
|
||||
case "findProgramAddress" -> math.findProgramAddress((String) args[0], seeds(args[1]));
|
||||
case "findAssociatedTokenAccount" -> math.findAssociatedTokenAccount((String) args[0], (String) args[1], (SolanaSPLTokenProgram) args[2]);
|
||||
case "encodeSolanaAddress" -> math.encodeSolanaAddress((byte[]) args[0]);
|
||||
case "getLatestBlockhash" -> new SolanaLatestBlockhash(address(30), 100);
|
||||
case "getSPLTokenHoldings" -> {
|
||||
assertEquals(OWNER, args[0]); String program = ((SolanaSPLTokenProgram) args[1]).getAddress();
|
||||
if (program.equals(unreadableHoldingsProgram)) throw new IOException("fixture unreadable owner holdings");
|
||||
Map<String, SPLTokenHolding> holdings = new HashMap<>();
|
||||
for (SPLTokenHolding holding : ownerHoldings.values()) {
|
||||
if (program.equals(holding.programId())) holdings.put(holding.mintAddress(), holding);
|
||||
}
|
||||
if (ownsNft && program.equals(nftProgram)) holdings.put(NFT, new SPLTokenHolding(NFT_ACCOUNT, NFT, BigDecimal.ONE, "1", 0, nftProgram));
|
||||
yield holdings;
|
||||
}
|
||||
case "awaitTransaction" -> {
|
||||
assertEquals("fixture-signature", args[0]); assertEquals(SolanaCommitment.CONFIRMED, args[1]); confirmations++;
|
||||
yield timeout ? new SolanaTransactionOutcome(SolanaTransactionOutcome.Status.TIMED_OUT, null, null)
|
||||
: new SolanaTransactionOutcome(SolanaTransactionOutcome.Status.SUCCEEDED, 1L, null);
|
||||
}
|
||||
default -> throw new AssertionError("Unexpected chain operation " + method);
|
||||
});
|
||||
Fixture() throws Exception {
|
||||
byte[] pool = account("PoolState", 1544);
|
||||
addressInto(pool, 73, EVE); addressInto(pool, 105, SYRUP);
|
||||
addressInto(pool, 137, VAULT_A); addressInto(pool, 169, VAULT_B);
|
||||
pool[233] = 8; pool[234] = 6; ByteBuffer.wrap(pool, 235, 2).order(ByteOrder.LITTLE_ENDIAN).putShort((short)60);
|
||||
put(POOL, RaydiumClmmWithdrawal.PROGRAM, pool);
|
||||
put(EVE, TOKEN2022, mint(8, true)); put(SYRUP, LEGACY, mint(6, false)); put(NFT, LEGACY, mint(0, false));
|
||||
put(VAULT_A, TOKEN2022, token(EVE, POOL)); put(VAULT_B, LEGACY, token(SYRUP, POOL));
|
||||
put(NFT_ACCOUNT, LEGACY, token(NFT, OWNER));
|
||||
range(-120, 0, 120);
|
||||
ataA = math.findAssociatedTokenAccount(OWNER, EVE, SolanaSPLTokenProgram.TOKEN_2022_PROGRAM);
|
||||
ataB = math.findAssociatedTokenAccount(OWNER, SYRUP, SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM);
|
||||
}
|
||||
void reversed() throws Exception {
|
||||
range(0,120,-120);
|
||||
byte[] pool = data(POOL); addressInto(pool, 73, SYRUP); addressInto(pool,105,EVE); pool[233] = 6; pool[234] = 8;
|
||||
put(POOL,RaydiumClmmWithdrawal.PROGRAM,pool);
|
||||
put(VAULT_A,LEGACY,token(SYRUP,POOL)); put(VAULT_B,TOKEN2022,token(EVE,POOL));
|
||||
String temp=ataA;ataA=ataB;ataB=temp;
|
||||
}
|
||||
void range(int lower, int upper, int current) throws Exception {
|
||||
position = new RaydiumConcentratedPositionState(NFT,POOL,lower,upper,BigInteger.valueOf(1_000_000_000L));
|
||||
quote = new RaydiumConcentratedPoolState(POOL,BigInteger.ONE,RaydiumClmmMath.sqrtPriceAtTick(current),current);
|
||||
personal = pda("position",SolanaProgramAddressSeed.solanaAddress(NFT));
|
||||
protocol = pda("position",SolanaProgramAddressSeed.solanaAddress(POOL),tickSeed(lower),tickSeed(upper));
|
||||
byte[] data = account("PersonalPositionState",281);addressInto(data,9,NFT);addressInto(data,41,POOL);
|
||||
ByteBuffer.wrap(data,73,8).order(ByteOrder.LITTLE_ENDIAN).putInt(lower).putInt(upper);
|
||||
byte[] amount = position.liquidity().toByteArray(); for(int i=0;i<amount.length;i++)data[81+i]=amount[amount.length-i-1];
|
||||
put(personal,RaydiumClmmWithdrawal.PROGRAM,data);
|
||||
int span=60*tickSpacing;int lowerStart=Math.floorDiv(lower,span)*span,upperStart=Math.floorDiv(upper,span)*span;
|
||||
lowerArray=pda("tick_array",SolanaProgramAddressSeed.solanaAddress(POOL),tickSeed(lowerStart));
|
||||
upperArray=pda("tick_array",SolanaProgramAddressSeed.solanaAddress(POOL),tickSeed(upperStart));
|
||||
Map<String,Integer> arrays=new LinkedHashMap<>(); arrays.put(lowerArray,lowerStart);arrays.put(upperArray,upperStart);
|
||||
for(var entry:arrays.entrySet()) {
|
||||
byte[] tick=account("TickArrayState",44);addressInto(tick,8,POOL);ByteBuffer.wrap(tick,40,4).order(ByteOrder.LITTLE_ENDIAN).putInt(entry.getValue());
|
||||
put(entry.getKey(),RaydiumClmmWithdrawal.PROGRAM,tick);
|
||||
}
|
||||
byte[] pool=data(POOL);BigInteger sqrt=quote.sqrtPriceX64();for(int i=0;i<16;i++)pool[253+i]=sqrt.shiftRight(8*i).byteValue();
|
||||
put(POOL,RaydiumClmmWithdrawal.PROGRAM,pool);
|
||||
}
|
||||
String pda(String seed, SolanaProgramAddressSeed... rest) {
|
||||
List<SolanaProgramAddressSeed> seeds=new ArrayList<>();seeds.add(SolanaProgramAddressSeed.utf8(seed));seeds.addAll(List.of(rest));
|
||||
return math.findProgramAddress(RaydiumClmmWithdrawal.PROGRAM,seeds).address();
|
||||
}
|
||||
void put(String account,String owner,byte[] data) {accounts.put(account,new SolanaAccountInfo(account,owner,Base64.getEncoder().encodeToString(data)));}
|
||||
void holding(String account, String mint, SolanaSPLTokenProgram program, int decimals, BigDecimal amount) {
|
||||
BigInteger raw = amount.movePointRight(decimals).toBigIntegerExact();
|
||||
byte[] data = token(mint, OWNER);
|
||||
for (int i = 0; i < 8; i++) data[64 + i] = raw.shiftRight(8 * i).byteValue();
|
||||
put(account, program.getAddress(), data);
|
||||
ownerHoldings.put(mint, new SPLTokenHolding(account, mint, amount, raw.toString(), decimals, program.getAddress()));
|
||||
}
|
||||
byte[] data(String account) {return Base64.getDecoder().decode(accounts.get(account).data());}
|
||||
RaydiumClmmWithdrawal operation() {return new RaydiumClmmWithdrawal(chain);}
|
||||
}
|
||||
private record ParsedInstruction(String program,List<String> accounts,byte[] data) { }
|
||||
private record Parsed(List<String> keys,Set<String> writable,List<ParsedInstruction> instructions) { }
|
||||
private static Parsed parse(SolanaUnsignedTransaction unsigned) throws Exception {
|
||||
var stream = new ByteArrayInputStream(Base64.getDecoder().decode(unsigned.serializedTransaction()));
|
||||
assertEquals(1,stream.read());stream.skipNBytes(64);int signatures=stream.read(),readonlySigners=stream.read(),readonly=stream.read();
|
||||
assertEquals(1,signatures);assertEquals(0,readonlySigners);
|
||||
int count=compact(stream);List<String> keys=new ArrayList<>();for(int i=0;i<count;i++)keys.add(CODEC.encode(stream.readNBytes(32)));
|
||||
assertEquals(OWNER,keys.getFirst());Set<String> writable=new HashSet<>(keys.subList(0,count-readonly));stream.skipNBytes(32);
|
||||
List<ParsedInstruction> instructions=new ArrayList<>();count=compact(stream);
|
||||
for(int i=0;i<count;i++) {
|
||||
String program=keys.get(stream.read());int accountCount=compact(stream);List<String> accounts=new ArrayList<>();
|
||||
for(int j=0;j<accountCount;j++)accounts.add(keys.get(stream.read()));byte[] data=stream.readNBytes(compact(stream));
|
||||
instructions.add(new ParsedInstruction(program,accounts,data));
|
||||
}
|
||||
assertEquals(0,stream.available());return new Parsed(keys,writable,instructions);
|
||||
}
|
||||
private static int compact(InputStream stream) throws IOException {int value=0,shift=0,next;do {next=stream.read();value|=(next&127)<<shift;shift+=7;}while((next&128)!=0);return value;}
|
||||
private static byte[] account(String type,int size) throws Exception {
|
||||
byte[] data=new byte[size];System.arraycopy(MessageDigest.getInstance("SHA-256").digest(("account:"+type).getBytes(StandardCharsets.UTF_8)),0,data,0,8);return data;
|
||||
}
|
||||
private static byte[] mint(int decimals,boolean extensions) {byte[] data=new byte[extensions?174:82];data[44]=(byte)decimals;data[45]=1;if(extensions){data[165]=1;data[166]=18;data[170]=19;}return data;}
|
||||
private static byte[] token(String mint,String owner) {byte[] data=new byte[165];addressInto(data,0,mint);addressInto(data,32,owner);data[64]=1;data[108]=1;return data;}
|
||||
private static void addressInto(byte[] data,int offset,String address) {System.arraycopy(CODEC.decode(address),0,data,offset,32);}
|
||||
private static SolanaProgramAddressSeed tickSeed(int tick) {return SolanaProgramAddressSeed.hex(HexFormat.of().formatHex(ByteBuffer.allocate(4).putInt(tick).array()));}
|
||||
private static BigInteger unsigned(byte[] data,int offset,int length) {byte[] copy=new byte[length];for(int i=0;i<length;i++)copy[length-i-1]=data[offset+i];return new BigInteger(1,copy);}
|
||||
@SuppressWarnings("unchecked") private static List<SolanaProgramAddressSeed> seeds(Object value) {return (List<SolanaProgramAddressSeed>)value;}
|
||||
@FunctionalInterface private interface Handler {Object invoke(String method,Object[] args) throws Throwable;}
|
||||
@SuppressWarnings("unchecked") private static <T> T proxy(Class<T> type,Handler handler) {return (T)Proxy.newProxyInstance(type.getClassLoader(),new Class<?>[]{type},(object,method,args)->handler.invoke(method.getName(),args));}
|
||||
private static final Base58CodecImpl CODEC=new Base58CodecImpl();
|
||||
private static String address(int value) {byte[] bytes=new byte[32];bytes[31]=(byte)value;return CODEC.encode(bytes);}
|
||||
private static final String OWNER=address(1),POOL=address(2),NFT=address(3),NFT_ACCOUNT=address(4),VAULT_A=address(5),VAULT_B=address(6);
|
||||
private static final String EVE="meveYG2iXYSkgSUn1T1uxcthH1EGMZdRHGgCntXZA3Y",SYRUP="AvZZF1YaZDziPY2RCK4oJrRVrbN3mTD9NL24hPeaZeUj";
|
||||
private static final String LEGACY=SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM.getAddress(),TOKEN2022=SolanaSPLTokenProgram.TOKEN_2022_PROGRAM.getAddress();
|
||||
private static final String ATA="ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",MEMO="MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.r35157.nenjim.hubd.impl.ref;
|
||||
|
||||
import com.r35157.nenjim.service.registry.valuetypes.NenjimComponentId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class NenjimHubLiquidityConfigurationTest {
|
||||
@Test void catalogueConstructionIsPassiveAndRegistersOptionalLiquidityChoice(@TempDir Path directory) throws Exception {
|
||||
NenjimHubImpl hub = new NenjimHubImpl();
|
||||
Method method = NenjimHubImpl.class.getDeclaredMethod("createProcessConstructorCatalogue"); method.setAccessible(true);
|
||||
Map<?, ?> catalogue = (Map<?, ?>) method.invoke(hub);
|
||||
Set<NenjimComponentId> ids = new HashSet<>();
|
||||
catalogue.keySet().forEach(key -> ids.add((NenjimComponentId)key)); ids.add(REGISTRY);
|
||||
assertTrue(ids.contains(LIQUIDITY));
|
||||
assertTrue(ids.contains(new NenjimComponentId("evelyn.iou-burner.prod")));
|
||||
assertTrue(ids.contains(new NenjimComponentId("evelyn.status.prod")));
|
||||
assertTrue(hub.getStartedProcessIds().isEmpty());
|
||||
Path path = directory.resolve("NenjimHub.conf");
|
||||
Files.writeString(path, "FORMAT_VERSION=1\n[AUTO_START]\nnenjim.registry.service\nnenjim.journal.service\nevelyn.liquidity.prod\n");
|
||||
var configuration = NenjimHubConfigurationParser.parse(path, ids, REGISTRY, JOURNAL);
|
||||
assertEquals(List.of(REGISTRY, JOURNAL, LIQUIDITY), configuration.autoStartProcessIds());
|
||||
// Parsing a selection constructs no process or signer and adds no inferred dependencies.
|
||||
assertTrue(hub.getStartedProcessIds().isEmpty());
|
||||
var example = NenjimHubConfigurationParser.parse(Path.of("conf/NenjimHub.conf.example"), ids, REGISTRY, JOURNAL);
|
||||
assertEquals(List.of(REGISTRY, JOURNAL), example.autoStartProcessIds());
|
||||
}
|
||||
@Test void optionalAdditionPreservesExistingExplicitOrdering(@TempDir Path directory) throws Exception {
|
||||
Path path = directory.resolve("NenjimHub.conf");
|
||||
NenjimComponentId ticker = new NenjimComponentId("assetaz.ticker.default"), status = new NenjimComponentId("evelyn.status.prod");
|
||||
List<NenjimComponentId> ordered = List.of(REGISTRY, JOURNAL, ticker, LIQUIDITY, status);
|
||||
Files.writeString(path,"FORMAT_VERSION=1\n[AUTO_START]\n"+String.join("\n",ordered.stream().map(NenjimComponentId::value).toList())+"\n");
|
||||
assertEquals(ordered,NenjimHubConfigurationParser.parse(path,Set.copyOf(ordered),REGISTRY,JOURNAL).autoStartProcessIds());
|
||||
}
|
||||
private static final NenjimComponentId REGISTRY = new NenjimComponentId("nenjim.registry.service");
|
||||
private static final NenjimComponentId JOURNAL = new NenjimComponentId("nenjim.journal.service");
|
||||
private static final NenjimComponentId LIQUIDITY = new NenjimComponentId("evelyn.liquidity.prod");
|
||||
}
|
||||
Reference in New Issue
Block a user