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
|
A single worker runs immediately on startup and then with fixed delay after
|
||||||
completion. Every iteration follows the same three independent steps:
|
completion. Every iteration follows the same three independent steps:
|
||||||
|
|
||||||
1. Read current on-chain pool state in SyrupUSDC per EVE. For each owned,
|
1. Discover the wallet's position NFTs. For each owned, positive-liquidity
|
||||||
positive-liquidity position in the configured pool, withdraw complete
|
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
|
liquidity when its normalized **upper** boundary satisfies
|
||||||
`U <= P * (1 - h/100)`. Equality qualifies. Both token orientations use
|
`U <= P * (1 - h/100)`. Equality qualifies. Both token orientations use
|
||||||
exact squared Q64.64 comparisons, without display-price rounding. At
|
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
|
`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.
|
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
|
2. Read current SyrupUSDC balance and swap the full balance above its reserve
|
||||||
into USDC through the confirmed exact-input Jupiter operation.
|
into USDC through the confirmed exact-input Jupiter operation.
|
||||||
3. Read current USDC balance and send the full balance above its reserve to
|
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`.
|
The transfer submits once and is awaited at `CONFIRMED`.
|
||||||
|
|
||||||
Missing/zero balances and balances equal to their reserves are skipped. Amounts
|
Missing/zero balances and balances equal to their reserves are skipped. Amounts
|
||||||
are rounded down to mint precision. An unusable positive market price skips
|
are rounded down to mint precision. An unavailable or unusable pool price stops
|
||||||
withdrawals while retaining steps 2 and 3. EVE returned as principal/fees and
|
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
|
other reward tokens remain in the wallet and never enter the conversion/payout
|
||||||
pipeline. All available SyrupUSDC/USDC is eligible, including manual deposits.
|
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,
|
Raydium validates pool/personal-position discriminators, the owned NFT holding,
|
||||||
current complete liquidity, mint precision, vaults and tick arrays before signing.
|
current complete liquidity, mint precision, vaults and tick arrays before signing.
|
||||||
Missing destination ATAs are created idempotently in the same transaction,
|
For every pool or reward mint, existing owner token accounts are reused after
|
||||||
which requests a 600,000 compute-unit limit.
|
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 bitmap extension and initialized reward triplets are included when required.
|
||||||
The integer principal math matches Raydium's
|
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).
|
[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
|
Minimum principal amounts are floored after applying explicit withdrawal
|
||||||
slippage. An amount too small to protect at least one raw unit is rejected.
|
slippage. An amount too small to protect at least one raw unit is rejected.
|
||||||
No withdrawal always substitutes zero for both minimums.
|
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;
|
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
|
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,
|
principal amounts, owner/pool filtering, reserves, independent failures,
|
||||||
restart recovery and shutdown. Captured serialized transactions verify account
|
restart recovery and shutdown. Captured serialized transactions verify account
|
||||||
order, full liquidity, minimum output, NFT retention, bitmap/reward accounts and
|
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.
|
no verification submits live withdrawals, swaps or transfers.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+12
-5
@@ -85,15 +85,22 @@ public final class EvelynLiquidityServiceImpl implements EvelynLiquidityService,
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void withdrawEligiblePositions() throws Exception {
|
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())) {
|
for (ΩRaydiumLiquidityPoolPositionNftIdΩ nft : raydium.fetchConcentratedPositionNftIds(wallet.getAddress())) {
|
||||||
checkInterrupted();
|
checkInterrupted();
|
||||||
|
RaydiumConcentratedPositionState position;
|
||||||
try {
|
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;
|
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;
|
if (!RaydiumClmmMath.isAtOrBelowHarvestThreshold(position, state, baseIsMintA, configuration.harvestDistancePercent())) continue;
|
||||||
log.info("withdraw position={} liquidity={}", nft, position.liquidity());
|
log.info("withdraw position={} liquidity={}", nft, position.liquidity());
|
||||||
ΩSolanaTransactionSignatureΩ signature = raydium.withdrawConcentratedPositionLiquidity(wallet, position, state, configuration.maximumWithdrawalSlippageBps());
|
Ω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
|
* Withdraws the snapshot's full liquidity and fees to its NFT owner, preserving
|
||||||
* the NFT, position and token accounts. Minimums use quoteState and explicit
|
* the NFT, position and token accounts. Minimums use quoteState and explicit
|
||||||
* slippage in [0,10000); the current position must still match the snapshot.
|
* 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.
|
* Submits once and awaits CONFIRMED. Failures after submission may be unknown.
|
||||||
*/
|
*/
|
||||||
@org.jetbrains.annotations.NotNull
|
@org.jetbrains.annotations.NotNull
|
||||||
|
|||||||
@@ -210,6 +210,13 @@ final class RaydiumClmmWithdrawal {
|
|||||||
|
|
||||||
private ΩSPLTokenAccountΩ destination(ΩSolanaWalletIdΩ wallet, ΩSPLMintAddressΩ mint, SolanaSPLTokenProgram program, List<Instruction> instructions)
|
private ΩSPLTokenAccountΩ destination(ΩSolanaWalletIdΩ wallet, ΩSPLMintAddressΩ mint, SolanaSPLTokenProgram program, List<Instruction> instructions)
|
||||||
throws IOException, InterruptedException {
|
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);
|
ΩSPLTokenAccountΩ ata = chain.findAssociatedTokenAccount(wallet, mint, program);
|
||||||
if (chain.getAccountInfo(ata) == null) {
|
if (chain.getAccountInfo(ata) == null) {
|
||||||
instructions.add(new Instruction(ATA_PROGRAM, List.of(new Meta(wallet, true, true),
|
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);
|
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 {
|
@Test void invalidIdentityPrecisionAndBindingFailBeforeWorker() throws Exception {
|
||||||
Fixture fixture = new Fixture();
|
Fixture fixture = new Fixture();
|
||||||
try (var service = fixture.service("0.0000001", "0", "10")) {
|
try (var service = fixture.service("0.0000001", "0", "10")) {
|
||||||
@@ -187,6 +249,9 @@ class EvelynLiquidityServiceImplTest {
|
|||||||
Object creationPolicy;
|
Object creationPolicy;
|
||||||
SolanaWallet withdrawWallet;
|
SolanaWallet withdrawWallet;
|
||||||
RaydiumConcentratedPositionState withdrawPosition;
|
RaydiumConcentratedPositionState withdrawPosition;
|
||||||
|
RaydiumConcentratedPoolState withdrawQuote;
|
||||||
|
int poolReads;
|
||||||
|
Runnable afterDiscovery = () -> { }, afterWithdrawal = () -> { };
|
||||||
final SolanaWallet wallet = proxy(SolanaWallet.class, (method, args) -> switch (method) {
|
final SolanaWallet wallet = proxy(SolanaWallet.class, (method, args) -> switch (method) {
|
||||||
case "getAddress" -> walletAddress;
|
case "getAddress" -> walletAddress;
|
||||||
case "getSPLTokenBalance" -> { balanceReads.add((String) args[0]); yield balances.get(args[0]); }
|
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) {
|
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 "fetchConcentratedPoolInfoOnChain" -> new RaydiumConcentratedPoolInfo(POOL, wrongPool ? USDC : reversed ? SYRUP : EVE, reversed ? 6 : 8, reversed ? EVE : SYRUP, reversed ? 8 : 6, BigDecimal.ONE);
|
||||||
case "fetchConcentratedPoolState" -> {
|
case "fetchConcentratedPoolState" -> {
|
||||||
|
poolReads++;
|
||||||
if (priceFails) throw new IOException("fixture pool price failure");
|
if (priceFails) throw new IOException("fixture pool price failure");
|
||||||
yield new RaydiumConcentratedPoolState(POOL, BigInteger.ONE, sqrt, 0);
|
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" -> {
|
case "fetchConcentratedPositionState" -> {
|
||||||
if (args[0].equals(unreadable)) throw new IOException("fixture unreadable position"); yield positions.get(args[0]);
|
if (args[0].equals(unreadable)) throw new IOException("fixture unreadable position"); yield positions.get(args[0]);
|
||||||
}
|
}
|
||||||
case "withdrawConcentratedPositionLiquidity" -> {
|
case "withdrawConcentratedPositionLiquidity" -> {
|
||||||
withdrawWallet = (SolanaWallet) args[0]; withdrawPosition = (RaydiumConcentratedPositionState) args[1]; withdrawSlippage = (int) args[3];
|
withdrawWallet = (SolanaWallet) args[0]; withdrawPosition = (RaydiumConcentratedPositionState) args[1]; withdrawSlippage = (int) args[3];
|
||||||
|
withdrawQuote = (RaydiumConcentratedPoolState) args[2];
|
||||||
operations.add("withdraw:" + withdrawPosition.nftId());
|
operations.add("withdraw:" + withdrawPosition.nftId());
|
||||||
if (interruptWithdraw) throw new InterruptedException("fixture interrupted");
|
if (interruptWithdraw) throw new InterruptedException("fixture interrupted");
|
||||||
if (blockWithdraw) {
|
if (blockWithdraw) {
|
||||||
@@ -236,6 +303,7 @@ class EvelynLiquidityServiceImplTest {
|
|||||||
if (withdrawFails) throw new IOException("fixture withdrawal failure");
|
if (withdrawFails) throw new IOException("fixture withdrawal failure");
|
||||||
if(creditProceeds) balances.merge(SYRUP,new BigDecimal("3.04"),BigDecimal::add);
|
if(creditProceeds) balances.merge(SYRUP,new BigDecimal("3.04"),BigDecimal::add);
|
||||||
positions.put(withdrawPosition.nftId(), new RaydiumConcentratedPositionState(withdrawPosition.nftId(), POOL, -100, 0, BigInteger.ZERO));
|
positions.put(withdrawPosition.nftId(), new RaydiumConcentratedPositionState(withdrawPosition.nftId(), POOL, -100, 0, BigInteger.ZERO));
|
||||||
|
afterWithdrawal.run();
|
||||||
yield "withdrawal-signature";
|
yield "withdrawal-signature";
|
||||||
}
|
}
|
||||||
default -> throw new AssertionError("Unexpected Raydium operation: " + method);
|
default -> throw new AssertionError("Unexpected Raydium operation: " + method);
|
||||||
|
|||||||
@@ -118,6 +118,69 @@ class RaydiumClmmWithdrawalTest {
|
|||||||
assertEquals(NFT_ACCOUNT,transaction.instructions.getLast().accounts.get(1));
|
assertEquals(NFT_ACCOUNT,transaction.instructions.getLast().accounts.get(1));
|
||||||
assertFalse(transaction.writable.contains(NFT_ACCOUNT));
|
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 {
|
@Test void discoveryIsolatesCandidateAndTokenProgramReadFailures() throws Exception {
|
||||||
Fixture fixture = new Fixture();
|
Fixture fixture = new Fixture();
|
||||||
String badNft=address(31);fixture.unreadablePosition=fixture.pda("position",SolanaProgramAddressSeed.solanaAddress(badNft));
|
String badNft=address(31);fixture.unreadablePosition=fixture.pda("position",SolanaProgramAddressSeed.solanaAddress(badNft));
|
||||||
@@ -137,12 +200,13 @@ class RaydiumClmmWithdrawalTest {
|
|||||||
private static final class Fixture {
|
private static final class Fixture {
|
||||||
final SolanaBlockChainImpl math = new SolanaBlockChainImpl(HttpClient.newHttpClient(), new ObjectMapper(), new HardcodedCurrencyIdentityService());
|
final SolanaBlockChainImpl math = new SolanaBlockChainImpl(HttpClient.newHttpClient(), new ObjectMapper(), new HardcodedCurrencyIdentityService());
|
||||||
final Map<String, SolanaAccountInfo> accounts = new HashMap<>();
|
final Map<String, SolanaAccountInfo> accounts = new HashMap<>();
|
||||||
|
final Map<String, SPLTokenHolding> ownerHoldings = new HashMap<>();
|
||||||
String personal, protocol, lowerArray, upperArray, ataA, ataB;
|
String personal, protocol, lowerArray, upperArray, ataA, ataB;
|
||||||
RaydiumConcentratedPositionState position;
|
RaydiumConcentratedPositionState position;
|
||||||
RaydiumConcentratedPoolState quote;
|
RaydiumConcentratedPoolState quote;
|
||||||
SolanaUnsignedTransaction transaction;
|
SolanaUnsignedTransaction transaction;
|
||||||
boolean ownsNft = true, timeout, legacyDiscoveryFails;
|
boolean ownsNft = true, timeout, legacyDiscoveryFails;
|
||||||
String nftProgram=LEGACY,unreadablePosition;
|
String nftProgram=LEGACY,unreadablePosition,unreadableHoldingsProgram;
|
||||||
Set<String> candidateNfts=Set.of(NFT);
|
Set<String> candidateNfts=Set.of(NFT);
|
||||||
int submissions, confirmations, tickSpacing=60;
|
int submissions, confirmations, tickSpacing=60;
|
||||||
final SolanaWallet wallet = proxy(SolanaWallet.class, (method, args) -> switch (method) {
|
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 "findAssociatedTokenAccount" -> math.findAssociatedTokenAccount((String) args[0], (String) args[1], (SolanaSPLTokenProgram) args[2]);
|
||||||
case "encodeSolanaAddress" -> math.encodeSolanaAddress((byte[]) args[0]);
|
case "encodeSolanaAddress" -> math.encodeSolanaAddress((byte[]) args[0]);
|
||||||
case "getLatestBlockhash" -> new SolanaLatestBlockhash(address(30), 100);
|
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" -> {
|
case "awaitTransaction" -> {
|
||||||
assertEquals("fixture-signature", args[0]); assertEquals(SolanaCommitment.CONFIRMED, args[1]); confirmations++;
|
assertEquals("fixture-signature", args[0]); assertEquals(SolanaCommitment.CONFIRMED, args[1]); confirmations++;
|
||||||
yield timeout ? new SolanaTransactionOutcome(SolanaTransactionOutcome.Status.TIMED_OUT, null, null)
|
yield timeout ? new SolanaTransactionOutcome(SolanaTransactionOutcome.Status.TIMED_OUT, null, null)
|
||||||
@@ -217,6 +290,13 @@ class RaydiumClmmWithdrawalTest {
|
|||||||
return math.findProgramAddress(RaydiumClmmWithdrawal.PROGRAM,seeds).address();
|
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 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());}
|
byte[] data(String account) {return Base64.getDecoder().decode(accounts.get(account).data());}
|
||||||
RaydiumClmmWithdrawal operation() {return new RaydiumClmmWithdrawal(chain);}
|
RaydiumClmmWithdrawal operation() {return new RaydiumClmmWithdrawal(chain);}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user