diff --git a/docs/EvelynLiquidityService.md b/docs/EvelynLiquidityService.md index 62f8090..aefdb34 100644 --- a/docs/EvelynLiquidityService.md +++ b/docs/EvelynLiquidityService.md @@ -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 diff --git a/src/main/tjava/com/r35157/evelyn/service/liquidity/impl/ref/EvelynLiquidityServiceImpl.tjava b/src/main/tjava/com/r35157/evelyn/service/liquidity/impl/ref/EvelynLiquidityServiceImpl.tjava index 73a44b4..7280eb1 100644 --- a/src/main/tjava/com/r35157/evelyn/service/liquidity/impl/ref/EvelynLiquidityServiceImpl.tjava +++ b/src/main/tjava/com/r35157/evelyn/service/liquidity/impl/ref/EvelynLiquidityServiceImpl.tjava @@ -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()); diff --git a/src/main/tjava/com/r35157/libs/raydium/Raydium.tjava b/src/main/tjava/com/r35157/libs/raydium/Raydium.tjava index b0f899f..0f2043a 100644 --- a/src/main/tjava/com/r35157/libs/raydium/Raydium.tjava +++ b/src/main/tjava/com/r35157/libs/raydium/Raydium.tjava @@ -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 diff --git a/src/main/tjava/com/r35157/libs/raydium/impl/ref/RaydiumClmmWithdrawal.tjava b/src/main/tjava/com/r35157/libs/raydium/impl/ref/RaydiumClmmWithdrawal.tjava index a1c9bed..3c973eb 100644 --- a/src/main/tjava/com/r35157/libs/raydium/impl/ref/RaydiumClmmWithdrawal.tjava +++ b/src/main/tjava/com/r35157/libs/raydium/impl/ref/RaydiumClmmWithdrawal.tjava @@ -210,6 +210,13 @@ final class RaydiumClmmWithdrawal { private ΩSPLTokenAccountΩ destination(ΩSolanaWalletIdΩ wallet, ΩSPLMintAddressΩ mint, SolanaSPLTokenProgram program, List 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), diff --git a/src/test/java/com/r35157/evelyn/service/liquidity/impl/ref/EvelynLiquidityServiceImplTest.java b/src/test/java/com/r35157/evelyn/service/liquidity/impl/ref/EvelynLiquidityServiceImplTest.java index fc2e030..23e6eae 100644 --- a/src/test/java/com/r35157/evelyn/service/liquidity/impl/ref/EvelynLiquidityServiceImplTest.java +++ b/src/test/java/com/r35157/evelyn/service/liquidity/impl/ref/EvelynLiquidityServiceImplTest.java @@ -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); diff --git a/src/test/java/com/r35157/libs/raydium/impl/ref/RaydiumClmmWithdrawalTest.java b/src/test/java/com/r35157/libs/raydium/impl/ref/RaydiumClmmWithdrawalTest.java index 7b47b94..0fe6f9b 100644 --- a/src/test/java/com/r35157/libs/raydium/impl/ref/RaydiumClmmWithdrawalTest.java +++ b/src/test/java/com/r35157/libs/raydium/impl/ref/RaydiumClmmWithdrawalTest.java @@ -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 accounts = new HashMap<>(); + final Map 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 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 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);} }