89: Add EvelynLiquidityService to withdraw CLMM proceeds, swap to USDC and send payouts
This commit is contained in:
@@ -39,6 +39,7 @@ This policy currently applies to:
|
|||||||
- `alarmaction_Pushover.conf`
|
- `alarmaction_Pushover.conf`
|
||||||
- `alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf`
|
- `alarmaction_JupiterPerpsPositionIncreaseAlarmAction.conf`
|
||||||
- `alarmaction_JupiterPerpsPositionDecreaseAlarmAction.conf`
|
- `alarmaction_JupiterPerpsPositionDecreaseAlarmAction.conf`
|
||||||
|
- `evelyn-liquidity.conf` (optional version 1 liquidity realization/payout configuration)
|
||||||
- `evelyn.conf`
|
- `evelyn.conf`
|
||||||
- `positions.conf`
|
- `positions.conf`
|
||||||
|
|
||||||
@@ -63,6 +64,11 @@ 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
|
without accessing Ticker or history storage. See [Evelyn](docs/Evelyn.md) for the
|
||||||
curve and service contract.
|
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:
|
## Release log:
|
||||||
| Date | Version | Description |
|
| Date | Version | Description |
|
||||||
|:-----------------|:--------------:|----------------------|
|
|:-----------------|:--------------:|----------------------|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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,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
|
||||||
@@ -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
|
- **THEN** no component ID identifies the manager and no lookup returns it as a component or process
|
||||||
|
|
||||||
### Requirement: NenjimHub owns composition and runtime startup
|
### 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
|
#### Scenario: Registry starts before passive graph construction
|
||||||
- **WHEN** the Hub has validated a complete startup plan and performs its first startup attempt
|
- **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
|
- **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
|
- **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
|
### 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.
|
`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
|
- **THEN** validation fails before Registry startup and identifies the required placement
|
||||||
|
|
||||||
### Requirement: Registry bootstraps eager passive composition
|
### 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
|
#### Scenario: Registry surrounds passive composition
|
||||||
- **WHEN** a valid Hub startup begins
|
- **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
|
#### Scenario: Passive composition completes
|
||||||
- **WHEN** Registry bootstrap succeeds
|
- **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
|
#### Scenario: Passive construction fails
|
||||||
- **WHEN** any passive component cannot be constructed or registered
|
- **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
|
- **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
|
### 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 Hub SHALL support the modern process IDs `nenjim.registry.service`, `nenjim.journal.service`, `assetaz.ticker.default`, `jupiter-perps-alarm.default`, `evelyn.status.prod`, `evelyn.status.test`, `evelyn.iou-burner.prod`, `evelyn.liquidity.prod`, `nenjim.javafx-runtime.service`, `evelyn.mission-control.default`, and `nenjim.process-manager.default` through implementation-only construction capability that creates no process instance during validation. After passive composition, the Hub SHALL visit every configured entry after Registry in declared order, beginning with Journal. At each entry it SHALL construct only that process and any explicitly composed process-specific passive dependencies or administration view, register the process under the exact configured ID, resolve it through the canonical `NenjimProcess` contract where applicable, call `start()`, and record the ID only after successful return.
|
||||||
|
|
||||||
The configuration SHALL be the complete manually ordered process dependency closure. The Hub SHALL not discover, insert, recursively construct, reorder, or automatically start dependencies. A process omitted from `[AUTO_START]` SHALL not be constructed, registered, started, or recorded, even when its implementation is available. Legacy process prototypes outside the modern Hub graph SHALL not be selectable.
|
The 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.
|
||||||
|
|
||||||
@@ -117,6 +121,10 @@ The configuration SHALL be the complete manually ordered process dependency clos
|
|||||||
- **WHEN** a private deployment configuration contains `evelyn.service.prod` or `evelyn.service.test`
|
- **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
|
- **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
|
### Requirement: Configured startup is fail-fast and reports actual milestones
|
||||||
Registry and every selected process SHALL be registered before its `start()` call and appended to the Hub's immutable ordered startup snapshot only after `start()` returns successfully. Construction failure SHALL leave the selected process unregistered. Registration or startup failure SHALL stop the plan before any later entry is constructed or started; a startup failure MAY leave that process registered but SHALL not record it. Earlier successful processes SHALL remain in snapshots, no rollback or retry SHALL occur, and the Hub SHALL announce its existing online point and enter its blocking runtime wait only after every configured process succeeds.
|
Registry and every selected process SHALL be registered before its `start()` call and appended to the Hub's immutable ordered startup snapshot only after `start()` returns successfully. Construction failure SHALL leave the selected process unregistered. Registration or startup failure SHALL stop the plan before any later entry is constructed or started; a startup failure MAY leave that process registered but SHALL not record it. Earlier successful processes SHALL remain in snapshots, no rollback or retry SHALL occur, and the Hub SHALL announce its existing online point and enter its blocking runtime wait only after every configured process succeeds.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,25 @@ import com.r35157.libs.codec.Base58Codec;
|
|||||||
|
|
||||||
public class Base58CodecImpl implements 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) {
|
public String encode(byte[] input) {
|
||||||
if (input.length == 0) {
|
if (input.length == 0) {
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@@ -10,6 +10,28 @@ import java.math.BigDecimal;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public interface Raydium extends NenjimComponent {
|
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.
|
* 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
|
* <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
|
* 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
|
* @param ownerAddress the Solana owner address to inspect
|
||||||
* @return the Raydium concentrated liquidity position NFT ids owned by the address
|
* @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
|
@Override
|
||||||
public AssetPrice fetchPoolPrice(ΩRaydiumLiquidityPoolIdΩ raydiumLiquidityPoolId) throws IOException, InterruptedException {
|
public AssetPrice fetchPoolPrice(ΩRaydiumLiquidityPoolIdΩ raydiumLiquidityPoolId) throws IOException, InterruptedException {
|
||||||
ΩRestEndpointΩ endpoint = createPoolInfoByIdEndpoint(raydiumLiquidityPoolId);
|
ΩRestEndpointΩ endpoint = createPoolInfoByIdEndpoint(raydiumLiquidityPoolId);
|
||||||
@@ -102,19 +116,22 @@ public class RaydiumImpl implements Raydium {
|
|||||||
public Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> fetchConcentratedPositionNftIds(
|
public Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> fetchConcentratedPositionNftIds(
|
||||||
ΩSolanaAddressΩ ownerAddress
|
ΩSolanaAddressΩ ownerAddress
|
||||||
) throws IOException, InterruptedException {
|
) throws IOException, InterruptedException {
|
||||||
Set<ΩSolanaNFTAddressΩ> candidates;
|
|
||||||
Set<ΩSolanaNFTAddressΩ> allCandidates = new HashSet<>();
|
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...");
|
if (!discoveredAnyProgram) {
|
||||||
candidates = solanaBlockChain.getSolanaNFTCandidateAddresses(ownerAddress, SPL_TOKEN_PROGRAM);
|
throw new IOException("Could not discover NFT holdings under either Solana token program for " + ownerAddress);
|
||||||
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);
|
|
||||||
|
|
||||||
Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> ids = filterCandidatePositionNftIds(allCandidates);
|
Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> ids = filterCandidatePositionNftIds(allCandidates);
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
@@ -636,18 +653,16 @@ public class RaydiumImpl implements Raydium {
|
|||||||
|
|
||||||
System.out.println("Filtering " + candidatePositionNftIds.size() + " NFTs for Raydium Concentrated pools...");
|
System.out.println("Filtering " + candidatePositionNftIds.size() + " NFTs for Raydium Concentrated pools...");
|
||||||
for (ΩSolanaNFTAddressΩ candidatePositionNftId : candidatePositionNftIds) {
|
for (ΩSolanaNFTAddressΩ candidatePositionNftId : candidatePositionNftIds) {
|
||||||
System.out.print(" " + candidatePositionNftId + " ");
|
try {
|
||||||
SolanaProgramDerivedAddress positionPda = findRaydiumPositionPda(candidatePositionNftId);
|
SolanaProgramDerivedAddress positionPda = findRaydiumPositionPda(candidatePositionNftId);
|
||||||
SolanaAccountInfo accountInfo = solanaBlockChain.getAccountInfo(positionPda.address());
|
SolanaAccountInfo accountInfo = solanaBlockChain.getAccountInfo(positionPda.address());
|
||||||
|
if (accountInfo != null && RAYDIUM_CLMM_PROGRAM_ID.equals(accountInfo.owner())) {
|
||||||
if (accountInfo == null) {
|
positionNftIds.add(candidatePositionNftId);
|
||||||
System.out.println("NO");
|
}
|
||||||
continue;
|
} catch (IOException | RuntimeException exception) {
|
||||||
}
|
org.slf4j.LoggerFactory.getLogger(RaydiumImpl.class).warn(
|
||||||
|
"Could not inspect CLMM position candidate {}; continuing discovery",
|
||||||
if (RAYDIUM_CLMM_PROGRAM_ID.equals(accountInfo.owner())) {
|
candidatePositionNftId, exception);
|
||||||
System.out.println("YES");
|
|
||||||
positionNftIds.add(candidatePositionNftId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ public record SolanaProgramAddressSeed(
|
|||||||
SolanaProgramAddressSeedKind kind,
|
SolanaProgramAddressSeedKind kind,
|
||||||
String value
|
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.
|
* Creates a UTF-8 seed.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ public enum SolanaProgramAddressSeedKind {
|
|||||||
*/
|
*/
|
||||||
UTF8,
|
UTF8,
|
||||||
|
|
||||||
|
/** Exact binary seed represented by hexadecimal text. */
|
||||||
|
HEX,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A seed that represents a Solana address and should be decoded from its
|
* A seed that represents a Solana address and should be decoded from its
|
||||||
* textual Solana address representation to raw address bytes.
|
* textual Solana address representation to raw address bytes.
|
||||||
|
|||||||
@@ -1741,6 +1741,8 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
|
|||||||
|
|
||||||
private byte[] toSeedBytes(SolanaProgramAddressSeed seed) {
|
private byte[] toSeedBytes(SolanaProgramAddressSeed seed) {
|
||||||
return switch (seed.kind()) {
|
return switch (seed.kind()) {
|
||||||
|
case HEX -> java.util.HexFormat.of().parseHex(seed.value());
|
||||||
|
|
||||||
case UTF8 -> seed.value().getBytes(StandardCharsets.UTF_8);
|
case UTF8 -> seed.value().getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
case SOLANA_ADDRESS -> {
|
case SOLANA_ADDRESS -> {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.r35157.nenjim.hubd.impl.ref;
|
|||||||
import com.r35157.assetaz.services.journal.impl.ref.AssetAZJournalServiceImpl;
|
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.AssetOwnerRegisteredCodec;
|
||||||
import com.r35157.assetaz.services.journal.codec.impl.ref.JournalFormatVersionChangedCodec;
|
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.EvelynStatusService;
|
||||||
import com.r35157.evelyn.service.status.impl.ref.EvelynStatusServiceImpl;
|
import com.r35157.evelyn.service.status.impl.ref.EvelynStatusServiceImpl;
|
||||||
import com.fanitas.evelyn.service.burner.EvelynIOUBurnerService;
|
import com.fanitas.evelyn.service.burner.EvelynIOUBurnerService;
|
||||||
@@ -326,6 +328,20 @@ public final class NenjimHubImpl implements NenjimHub {
|
|||||||
return process;
|
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() {
|
private NenjimProcess constructJavaFxRuntimeProcess() {
|
||||||
JavaFxRuntimeServiceImpl process = new JavaFxRuntimeServiceImpl();
|
JavaFxRuntimeServiceImpl process = new JavaFxRuntimeServiceImpl();
|
||||||
javaFxRuntimeService = process;
|
javaFxRuntimeService = process;
|
||||||
@@ -387,6 +403,7 @@ public final class NenjimHubImpl implements NenjimHub {
|
|||||||
Map.entry(JUPITER_PERPS_ALARM_ID, this::constructJupiterPerpsAlarmProcess),
|
Map.entry(JUPITER_PERPS_ALARM_ID, this::constructJupiterPerpsAlarmProcess),
|
||||||
Map.entry(EVELYN_STATUS_PROD_ID, this::constructEvelynStatusProdProcess),
|
Map.entry(EVELYN_STATUS_PROD_ID, this::constructEvelynStatusProdProcess),
|
||||||
Map.entry(EVELYN_STATUS_TEST_ID, this::constructEvelynStatusTestProcess),
|
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(EVELYN_IOU_BURNER_PROD_ID, this::constructEvelynIOUBurnerProdProcess),
|
||||||
Map.entry(JAVAFX_RUNTIME_SERVICE_ID, this::constructJavaFxRuntimeProcess),
|
Map.entry(JAVAFX_RUNTIME_SERVICE_ID, this::constructJavaFxRuntimeProcess),
|
||||||
Map.entry(EVELYN_MISSION_CONTROL_ID, this::constructMissionControlProcess),
|
Map.entry(EVELYN_MISSION_CONTROL_ID, this::constructMissionControlProcess),
|
||||||
@@ -501,6 +518,8 @@ public final class NenjimHubImpl implements NenjimHub {
|
|||||||
new NenjimComponentId("notification.discord.assetaz-token");
|
new NenjimComponentId("notification.discord.assetaz-token");
|
||||||
private static final NenjimComponentId EVELYN_IOU_BURNER_PROD_ID =
|
private static final NenjimComponentId EVELYN_IOU_BURNER_PROD_ID =
|
||||||
new NenjimComponentId("evelyn.iou-burner.prod");
|
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 =
|
private static final NenjimComponentId EVELYN_MISSION_CONTROL_ID =
|
||||||
new NenjimComponentId("evelyn.mission-control.default");
|
new NenjimComponentId("evelyn.mission-control.default");
|
||||||
private static final NenjimComponentId JAVAFX_RUNTIME_SERVICE_ID =
|
private static final NenjimComponentId JAVAFX_RUNTIME_SERVICE_ID =
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
@@ -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