89: Review
This commit is contained in:
+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