89: Review
This commit is contained in:
@@ -57,13 +57,16 @@ identities come from CIS; token programs and precision come from chain metadata.
|
||||
A single worker runs immediately on startup and then with fixed delay after
|
||||
completion. Every iteration follows the same three independent steps:
|
||||
|
||||
1. Read current on-chain pool state in SyrupUSDC per EVE. For each owned,
|
||||
positive-liquidity position in the configured pool, withdraw complete
|
||||
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
|
||||
@@ -71,8 +74,8 @@ completion. Every iteration follows the same three independent steps:
|
||||
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 unusable positive market price skips
|
||||
withdrawals while retaining steps 2 and 3. EVE returned as principal/fees and
|
||||
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.
|
||||
|
||||
@@ -98,14 +101,21 @@ Withdrawal and closure are separate actions in
|
||||
|
||||
Raydium validates pool/personal-position discriminators, the owned NFT holding,
|
||||
current complete liquidity, mint precision, vaults and tick arrays before signing.
|
||||
Missing destination ATAs are created idempotently in the same transaction,
|
||||
which requests a 600,000 compute-unit limit.
|
||||
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
|
||||
@@ -122,7 +132,10 @@ 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. All financial dependencies are controlled;
|
||||
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
|
||||
|
||||
@@ -15,16 +15,17 @@ See proposal.md for motivation. Raydium already discovers position NFTs and deco
|
||||
- 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, destination ATAs and bitmap extension. Create missing ATAs idempotently in the same transaction and 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.
|
||||
- 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 between read and execution] → Minimum principal limits cause atomic failure; later iterations reread state.
|
||||
- [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.
|
||||
|
||||
@@ -11,6 +11,7 @@ Evelyn needs to realize proceeds from manually funded EVE/SyrupUSDC CLMM positio
|
||||
- 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
|
||||
|
||||
|
||||
+9
-1
@@ -5,7 +5,7 @@ Realize Evelyn's manually funded EVE/SyrupUSDC concentrated liquidity into USDC
|
||||
## 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 current on-chain pool state, both orientations SHALL be normalized, and decisions SHALL use pool precision. Unusable P SHALL skip withdrawals while allowing later steps.
|
||||
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
|
||||
@@ -19,6 +19,14 @@ The service SHALL discover positions owned by its wallet in its configured CLMM
|
||||
- **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.
|
||||
|
||||
|
||||
+9
-1
@@ -5,7 +5,7 @@ Provide a narrowly scoped protected Raydium CLMM full liquidity decrease using t
|
||||
## 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. Transaction/account details SHALL remain behind Raydium. It SHALL submit once and use existing confirmation behavior without close, burn or service-level retry.
|
||||
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
|
||||
@@ -15,6 +15,14 @@ The operation SHALL validate the NFT owner, pool and positive current liquidity,
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
- [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.
|
||||
@@ -23,3 +29,10 @@
|
||||
- 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.
|
||||
|
||||
+12
-5
@@ -85,15 +85,22 @@ public final class EvelynLiquidityServiceImpl implements EvelynLiquidityService,
|
||||
}
|
||||
|
||||
private void withdrawEligiblePositions() throws Exception {
|
||||
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");
|
||||
}
|
||||
for (ΩRaydiumLiquidityPoolPositionNftIdΩ nft : raydium.fetchConcentratedPositionNftIds(wallet.getAddress())) {
|
||||
checkInterrupted();
|
||||
RaydiumConcentratedPositionState position;
|
||||
try {
|
||||
RaydiumConcentratedPositionState position = raydium.fetchConcentratedPositionState(nft);
|
||||
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());
|
||||
|
||||
@@ -20,6 +20,8 @@ public interface Raydium extends NenjimComponent {
|
||||
* 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
|
||||
|
||||
@@ -210,6 +210,13 @@ final class RaydiumClmmWithdrawal {
|
||||
|
||||
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),
|
||||
|
||||
+69
-1
@@ -106,6 +106,68 @@ class EvelynLiquidityServiceImplTest {
|
||||
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")) {
|
||||
@@ -187,6 +249,9 @@ class EvelynLiquidityServiceImplTest {
|
||||
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]); }
|
||||
@@ -217,15 +282,17 @@ class EvelynLiquidityServiceImplTest {
|
||||
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]); yield new LinkedHashSet<>(positions.keySet()); }
|
||||
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) {
|
||||
@@ -236,6 +303,7 @@ class EvelynLiquidityServiceImplTest {
|
||||
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);
|
||||
|
||||
@@ -118,6 +118,69 @@ class RaydiumClmmWithdrawalTest {
|
||||
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));
|
||||
@@ -137,12 +200,13 @@ class RaydiumClmmWithdrawalTest {
|
||||
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;
|
||||
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) {
|
||||
@@ -164,7 +228,16 @@ class RaydiumClmmWithdrawalTest {
|
||||
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]); yield ownsNft ? Map.of(NFT, new SPLTokenHolding(NFT_ACCOUNT, NFT, BigDecimal.ONE, "1", 0, nftProgram)) : Map.of(); }
|
||||
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)
|
||||
@@ -217,6 +290,13 @@ class RaydiumClmmWithdrawalTest {
|
||||
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);}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user