89: Review

This commit is contained in:
2026-10-04 19:02:07 +02:00
parent 7dfd43dfd7
commit 7641a0cc54
11 changed files with 227 additions and 19 deletions
@@ -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),
@@ -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);}
}