diff --git a/build.gradle.kts b/build.gradle.kts index 715ab0a..202c7c6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { runtimeOnly("org.apache.logging.log4j:log4j-core:2.26.0") runtimeOnly("org.apache.logging.log4j:log4j-slf4j2-impl:2.26.0") + implementation("com.fasterxml.jackson.core:jackson-databind:2.17.2") implementation("org.slf4j:slf4j-api:2.0.18") implementation("com.r35157.nenjim:hubd-api:0.1-dev") } diff --git a/src/main/tjava/com/r35157/libs/raydium/impl/ref/RaydiumImpl.tjava b/src/main/tjava/com/r35157/libs/raydium/impl/ref/RaydiumImpl.tjava new file mode 100644 index 0000000..25e214a --- /dev/null +++ b/src/main/tjava/com/r35157/libs/raydium/impl/ref/RaydiumImpl.tjava @@ -0,0 +1,960 @@ +package com.r35157.libs.raydium.impl.ref; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.r35157.libs.raydium.Raydium; +import com.r35157.libs.raydium.RaydiumConcentratedPositionState; +import com.r35157.libs.raydium.RaydiumConcentratedPoolInfo; +import com.r35157.libs.raydium.RaydiumConcentratedPoolState; +import com.r35157.libs.raydium.RaydiumLiquidityPoolTokenAmounts; +import com.r35157.libs.solana.SPLTokenHolding; +import com.r35157.libs.solana.SPLTokenSupply; +import com.r35157.libs.solana.SolanaAccountInfo; +import com.r35157.libs.solana.SolanaBlockChain; +import com.r35157.libs.solana.SolanaProgramAddressSeed; +import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress; +import com.r35157.libs.solana.valuetypes.WellKnownCurrencyTypes; +import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram; +import com.r35157.libs.valuetypes.basic.CurrencyType; +import com.r35157.libs.valuetypes.basic.MoneyAmount; +import com.r35157.libs.valuetypes.basic.Range; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.MathContext; +import java.math.RoundingMode; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM; +import static com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram.TOKEN_2022_PROGRAM; + +public class RaydiumImpl implements Raydium { + + public RaydiumImpl(SolanaBlockChain solanaBlockChain) { + this.solanaBlockChain = solanaBlockChain; + this.httpClient = HttpClient.newHttpClient(); + this.objectMapper = new ObjectMapper(); + } + + @Override + public ΩSolanaAmountΩ fetchPoolPrice(ΩRaydiumLiquidityPoolIdΩ raydiumLiquidityPoolId) throws IOException, InterruptedException { + ΩRestEndpointΩ endpoint = createPoolInfoByIdEndpoint(raydiumLiquidityPoolId); + + JsonNode root = fetchJson(endpoint); + + JsonNode firstPool = getFirstPoolNode(root); + JsonNode priceNode = firstPool.path("price"); + + if (priceNode.isMissingNode() || !priceNode.isNumber()) { + throw new IOException("Could NOT find field 'price' in JSON!"); + } + + CurrencyType ct = WellKnownCurrencyTypes.SOLANA.getCurrencyType(); + ΩAmountΩ a = new ΩAmountΩ(priceNode.toString()); + ΩSolanaAmountΩ sa = new ΩSolanaAmountΩ(a, ct); + + return sa; + } + + @Override + public Set<ΩRaydiumLiquidityPoolIdΩ> fetchLiquidityPoolIds(ΩSolanaAddressΩ ownerAddress) throws IOException, InterruptedException { + Set<ΩRaydiumLiquidityPoolIdΩ> liquidityPoolIds = new HashSet<>(); + + liquidityPoolIds.addAll(fetchStandardLiquidityPoolIds(ownerAddress)); + liquidityPoolIds.addAll(fetchConcentratedLiquidityPoolIds(ownerAddress)); + + return Set.copyOf(liquidityPoolIds); + } + + @Override + public Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> fetchConcentratedPositionNftIds( + ΩSolanaAddressΩ ownerAddress + ) throws IOException, InterruptedException { + Set<ΩSolanaNFTAddressΩ> candidates; + Set<ΩSolanaNFTAddressΩ> allCandidates = new HashSet<>(); + + System.out.println("Searching for legacy NFTs..."); + candidates = solanaBlockChain.getSolanaNFTCandidateAddresses(ownerAddress, SPL_TOKEN_PROGRAM); + System.out.println("Found " + candidates.size() + " legacy NFTs"); + allCandidates.addAll(candidates); + + System.out.println("Searching for token_2022 NFTs..."); + candidates = solanaBlockChain.getSolanaNFTCandidateAddresses(ownerAddress, TOKEN_2022_PROGRAM); + System.out.println("Found " + candidates.size() + " token_2022 NFTs"); + allCandidates.addAll(candidates); + + Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> ids = filterCandidatePositionNftIds(allCandidates); + return ids; + } + + @Override + public RaydiumLiquidityPoolTokenAmounts fetchStandardLiquidityPoolTokenAmounts( + ΩRaydiumLiquidityPoolStandardIdΩ poolId + ) throws IOException, InterruptedException { + RaydiumStandardPoolInfo poolInfo = fetchStandardPoolInfo(poolId); + + RaydiumLiquidityPoolTokenAmounts amounts = new RaydiumLiquidityPoolTokenAmounts( + poolInfo.poolId(), + poolInfo.mintA(), + poolInfo.amountA(), + poolInfo.mintB(), + poolInfo.amountB() + ); + return amounts; + } + + @Override + public RaydiumLiquidityPoolTokenAmounts fetchStandardLiquidityPoolPositionTokenAmounts( + ΩSolanaAddressΩ ownerAddress, + ΩRaydiumLiquidityPoolStandardIdΩ poolId + ) throws IOException, InterruptedException { + RaydiumStandardPoolInfo poolInfo = fetchStandardPoolInfo(poolId); + + SPLTokenHolding ownerLpTokenHolding = fetchOwnerLpTokenHolding( + ownerAddress, + poolInfo.lpMint() + ); + + SPLTokenSupply lpTokenSupply = solanaBlockChain.getSPLTokenSupply( + poolInfo.lpMint(), + SPL_TOKEN_PROGRAM + ); + + ΩAmountΩ ownerLpAmount = ownerLpTokenHolding.uiAmount(); + ΩAmountΩ ownerShare = ownerLpAmount.divide( + lpTokenSupply.uiAmount(), + MathContext.DECIMAL128 + ); + + // TODO: This is a pool-share estimate, not an exact Raydium withdraw preview. + // The calculation uses Raydium REST pool amounts and Solana LP mint supply: + // + // ownerShare = ownerLpAmount / lpMintSupply + // ownerAmount = poolAmount * ownerShare + // + // This matches the simple LP-share model, but it may not match the Raydium UI's + // remove-liquidity preview exactly. + // + // Observed example: + // poolId: 8os8bnXoy5voKv3uBPPuVGyqWZGJaa2RRri5RbLUwPCY + // LP amount owned: 0.077459898 + // LP mint supply: 0.077459898 + // calculated: 2.1594651 EVE / 31.867153 USDT + // Raydium UI 100% withdraw preview: 2.1062559 EVE / 31.55046 USDT + // + // Likely reason: + // The REST mintAmountA/mintAmountB values do not necessarily represent the + // exact withdrawable vault amounts. Raydium's on-chain CPMM pool state has + // fee buckets such as protocol fees, fund fees and creator fees. A precise + // withdraw-preview calculation probably needs to decode the on-chain pool + // state, fetch vault balances and subtract non-withdrawable fee amounts. + // + // Future fix: + // Decode the on-chain CPMM pool state, fetch the token vault balances, + // subtract protocol/fund/creator fee buckets, and calculate the owner's + // share from those withdrawable balances instead of from REST mintAmountA + // and mintAmountB. + ΩAmountΩ ownerAmountA = poolInfo.amountA().multiply(ownerShare); + ΩAmountΩ ownerAmountB = poolInfo.amountB().multiply(ownerShare); + + RaydiumLiquidityPoolTokenAmounts amounts = new RaydiumLiquidityPoolTokenAmounts( + poolInfo.poolId(), + poolInfo.mintA(), + ownerAmountA, + poolInfo.mintB(), + ownerAmountB + ); + return amounts; + } + + @Override + public RaydiumConcentratedPositionState fetchConcentratedPositionState( + ΩRaydiumLiquidityPoolPositionNftIdΩ positionNftId + ) throws IOException, InterruptedException { + SolanaAccountInfo positionAccountInfo = fetchRaydiumPositionAccountInfo(positionNftId); + byte[] accountData = decodeBase64AccountData(positionAccountInfo); + + byte[] poolIdBytes = extractConcentratedLiquidityPoolIdBytes(accountData); + ΩRaydiumLiquidityPoolConcentratedIdΩ poolId = solanaBlockChain.encodeSolanaAddress(poolIdBytes); + + ΩraydiumLiquidityTickIndexΩ tickLowerIndex = extractConcentratedLiquidityTickIndex( + accountData, + RAYDIUM_POSITION_ACCOUNT_TICK_LOWER_INDEX_OFFSET, + "tickLowerIndex" + ); + + ΩraydiumLiquidityTickIndexΩ tickUpperIndex = extractConcentratedLiquidityTickIndex( + accountData, + RAYDIUM_POSITION_ACCOUNT_TICK_UPPER_INDEX_OFFSET, + "tickUpperIndex" + ); + + ΩRaydiumLiquidityΩ liquidity = extractConcentratedLiquidity(accountData); + + RaydiumConcentratedPositionState state = new RaydiumConcentratedPositionState( + positionNftId, + poolId, + tickLowerIndex, + tickUpperIndex, + liquidity + ); + return state; + } + + @Override + public RaydiumConcentratedPoolInfo fetchConcentratedPoolInfo( + ΩRaydiumLiquidityPoolConcentratedIdΩ poolId + ) throws IOException, InterruptedException { + ΩRestEndpointΩ endpoint = createPoolInfoByIdEndpoint(poolId); + + JsonNode root = fetchJson(endpoint); + JsonNode firstPool = getFirstPoolNode(root); + + ΩSPLMintAddressΩ mintA = extractMintAddress(firstPool, "mintA"); + ΩamountDecimalsΩ mintADecimals = extractMintDecimals(firstPool, "mintA"); + ΩSPLMintAddressΩ mintB = extractMintAddress(firstPool, "mintB"); + ΩamountDecimalsΩ mintBDecimals = extractMintDecimals(firstPool, "mintB"); + ΩPriceΩ priceEstimate = extractPriceEstimate(firstPool); + + RaydiumConcentratedPoolInfo poolInfo = new RaydiumConcentratedPoolInfo( + poolId, + mintA, + mintADecimals, + mintB, + mintBDecimals, + priceEstimate + ); + return poolInfo; + } + + @Override + public RaydiumConcentratedPoolState fetchConcentratedPoolState( + ΩRaydiumLiquidityPoolConcentratedIdΩ poolId + ) throws IOException, InterruptedException { + SolanaAccountInfo poolAccountInfo = fetchRaydiumPoolAccountInfo(poolId); + byte[] accountData = decodeBase64AccountData(poolAccountInfo); + + ΩRaydiumLiquidityΩ liquidity = extractConcentratedPoolLiquidity(accountData); + ΩRaydiumSqrtPriceX64Ω sqrtPriceX64 = extractConcentratedPoolSqrtPriceX64(accountData); + ΩraydiumLiquidityTickIndexΩ tickCurrent = extractConcentratedLiquidityTickIndex( + accountData, + RAYDIUM_POOL_STATE_TICK_CURRENT_OFFSET, + "tickCurrent" + ); + + RaydiumConcentratedPoolState state = new RaydiumConcentratedPoolState( + poolId, + liquidity, + sqrtPriceX64, + tickCurrent + ); + return state; + } + + @Override + public Range<ΩPriceΩ> calculateConcentratedPositionPriceRange( + RaydiumConcentratedPositionState positionState, + RaydiumConcentratedPoolInfo poolInfo, + int decimalPlaces + ) { + ΩPriceΩ priceFrom = calculatePriceFromTick( + positionState.tickLowerIndex(), + poolInfo, + decimalPlaces + ); + + ΩPriceΩ priceTo = calculatePriceFromTick( + positionState.tickUpperIndex(), + poolInfo, + decimalPlaces + ); + + Range<ΩPriceΩ> range = new Range<>(priceFrom, true, priceTo, false); + return range; + } + + @Override + public RaydiumLiquidityPoolTokenAmounts calculateConcentratedPositionTokenAmounts( + RaydiumConcentratedPositionState positionState, + RaydiumConcentratedPoolInfo poolInfo, + RaydiumConcentratedPoolState poolState + ) { + RaydiumLiquidityPoolTokenAmounts amounts = calculateConcentratedPositionTokenAmounts( + positionState, + poolInfo, + calculateCurrentSqrtRawPrice(poolState) + ); + return amounts; + } + + private RaydiumLiquidityPoolTokenAmounts calculateConcentratedPositionTokenAmounts( + RaydiumConcentratedPositionState positionState, + RaydiumConcentratedPoolInfo poolInfo, + ΩRaydiumSqrtPriceΩ sqrtPriceCurrent + ) { + ΩRaydiumSqrtPriceΩ sqrtPriceLower = calculateSqrtRawPriceFromTick(positionState.tickLowerIndex()); + ΩRaydiumSqrtPriceΩ sqrtPriceUpper = calculateSqrtRawPriceFromTick(positionState.tickUpperIndex()); + BigDecimal liquidity = new BigDecimal(positionState.liquidity()); + + BigDecimal rawAmountA; + BigDecimal rawAmountB; + + if (sqrtPriceCurrent.compareTo(sqrtPriceLower) <= 0) { + rawAmountA = calculateAmountAForLiquidity(liquidity, sqrtPriceLower, sqrtPriceUpper); + rawAmountB = BigDecimal.ZERO; + } else if (sqrtPriceCurrent.compareTo(sqrtPriceUpper) >= 0) { + rawAmountA = BigDecimal.ZERO; + rawAmountB = calculateAmountBForLiquidity(liquidity, sqrtPriceLower, sqrtPriceUpper); + } else { + rawAmountA = calculateAmountAForLiquidity(liquidity, sqrtPriceCurrent, sqrtPriceUpper); + rawAmountB = calculateAmountBForLiquidity(liquidity, sqrtPriceLower, sqrtPriceCurrent); + } + + ΩAmountΩ amountA = convertRawTokenAmountToUiAmount( + rawAmountA, + poolInfo.mintADecimals() + ); + + ΩAmountΩ amountB = convertRawTokenAmountToUiAmount( + rawAmountB, + poolInfo.mintBDecimals() + ); + + RaydiumLiquidityPoolTokenAmounts amounts = new RaydiumLiquidityPoolTokenAmounts( + poolInfo.poolId(), + poolInfo.mintA(), + amountA, + poolInfo.mintB(), + amountB + ); + return amounts; + } + + private ΩRaydiumSqrtPriceΩ calculateCurrentSqrtRawPrice(RaydiumConcentratedPoolState poolState) { + ΩRaydiumSqrtPriceΩ p = new BigDecimal(poolState.sqrtPriceX64()).divide( + RAYDIUM_Q64_FACTOR, + RAYDIUM_PRICE_MATH_CONTEXT + ); + return p; + } + + private ΩRaydiumSqrtPriceΩ calculateSqrtRawPriceFromTick(ΩraydiumLiquidityTickIndexΩ tickIndex) { + BigDecimal rawPrice = pow(RAYDIUM_TICK_BASE, tickIndex); + BigDecimal result = rawPrice.sqrt(RAYDIUM_PRICE_MATH_CONTEXT); + return result; + } + + private BigDecimal calculateAmountAForLiquidity( + BigDecimal liquidity, + ΩRaydiumSqrtPriceΩ sqrtPriceLower, + ΩRaydiumSqrtPriceΩ sqrtPriceUpper + ) { + BigDecimal numerator = liquidity.multiply( + sqrtPriceUpper.subtract(sqrtPriceLower, RAYDIUM_PRICE_MATH_CONTEXT), + RAYDIUM_PRICE_MATH_CONTEXT + ); + + BigDecimal denominator = sqrtPriceUpper.multiply( + sqrtPriceLower, + RAYDIUM_PRICE_MATH_CONTEXT + ); + + BigDecimal result = numerator.divide( + denominator, + RAYDIUM_PRICE_MATH_CONTEXT + ); + return result; + } + + private BigDecimal calculateAmountBForLiquidity( + BigDecimal liquidity, + ΩRaydiumSqrtPriceΩ sqrtPriceLower, + ΩRaydiumSqrtPriceΩ sqrtPriceUpper + ) { + BigDecimal result = liquidity.multiply( + sqrtPriceUpper.subtract(sqrtPriceLower, RAYDIUM_PRICE_MATH_CONTEXT), + RAYDIUM_PRICE_MATH_CONTEXT + ); + return result; + } + + private ΩAmountΩ convertRawTokenAmountToUiAmount( + BigDecimal rawAmount, + ΩamountDecimalsΩ decimals + ) { + BigDecimal decimalFactor = pow(BigDecimal.TEN, decimals); + + ΩAmountΩ a = rawAmount.divide( + decimalFactor, + RAYDIUM_PRICE_MATH_CONTEXT + ); + + a = a.setScale(decimals, RoundingMode.HALF_UP); + return a; + } + + private ΩPriceΩ calculatePriceFromTick( + ΩraydiumLiquidityTickIndexΩ tickIndex, + RaydiumConcentratedPoolInfo poolInfo, + int decimalPlaces + ) { + if (decimalPlaces < 0) { + throw new IllegalArgumentException("decimalPlaces must not be negative!"); + } + + BigDecimal rawPrice = pow(RAYDIUM_TICK_BASE, tickIndex); + BigDecimal decimalFactor = calculateDecimalFactor(poolInfo); + + ΩPriceΩ p = rawPrice.multiply( + decimalFactor, + RAYDIUM_PRICE_MATH_CONTEXT + ); + + p = p.setScale( + decimalPlaces, + RoundingMode.HALF_UP + ); + + return p; + } + + private BigDecimal calculateDecimalFactor(RaydiumConcentratedPoolInfo poolInfo) { + BigDecimal result = pow( + BigDecimal.TEN, + poolInfo.mintADecimals() - poolInfo.mintBDecimals() + ); + + return result; + } + + private BigDecimal pow(BigDecimal base, int exponent) { + BigDecimal power = base.pow( + Math.abs(exponent), + RAYDIUM_PRICE_MATH_CONTEXT + ); + + if (exponent >= 0) { + return power; + } + + BigDecimal result = BigDecimal.ONE.divide( + power, + RAYDIUM_PRICE_MATH_CONTEXT + ); + + return result; + } + + private SPLTokenHolding fetchOwnerLpTokenHolding( + ΩSolanaAddressΩ ownerAddress, + ΩSPLMintAddressΩ lpMint + ) throws IOException, InterruptedException { + var tokenHoldings = solanaBlockChain.getSPLTokenHoldings( + ownerAddress, + SPL_TOKEN_PROGRAM + ); + + SPLTokenHolding tokenHolding = tokenHoldings.get(lpMint); + + if (tokenHolding == null) { + throw new IOException("Owner does not hold LP token mint: " + lpMint); + } + + return tokenHolding; + } + + private RaydiumStandardPoolInfo fetchStandardPoolInfo( + ΩRaydiumLiquidityPoolStandardIdΩ poolId + ) throws IOException, InterruptedException { + ΩRestEndpointΩ endpoint = createPoolInfoByIdEndpoint(poolId); + + JsonNode root = fetchJson(endpoint); + JsonNode firstPool = getFirstPoolNode(root); + + ΩSPLMintAddressΩ mintA = extractMintAddress(firstPool, "mintA"); + ΩAmountΩ amountA = extractAmount(firstPool, "mintAmountA"); + ΩSPLMintAddressΩ mintB = extractMintAddress(firstPool, "mintB"); + ΩAmountΩ amountB = extractAmount(firstPool, "mintAmountB"); + ΩSPLMintAddressΩ lpMint = extractMintAddress(firstPool, "lpMint"); + ΩAmountΩ totalLpAmount = extractAmount(firstPool, "lpAmount"); + + return new RaydiumStandardPoolInfo( + poolId, + mintA, + amountA, + mintB, + amountB, + lpMint, + totalLpAmount + ); + } + + private ΩSPLMintAddressΩ extractMintAddress(JsonNode poolNode, String mintFieldName) throws IOException { + JsonNode addressNode = poolNode.path(mintFieldName).path("address"); + + if (addressNode.isMissingNode() || !addressNode.isTextual()) { + throw new IOException("Could NOT find textual field '" + mintFieldName + ".address' in pool JSON!"); + } + + return addressNode.asText(); + } + + private ΩAmountΩ extractAmount(JsonNode poolNode, String amountFieldName) throws IOException { + JsonNode amountNode = poolNode.path(amountFieldName); + + if (amountNode.isMissingNode() || !amountNode.isNumber()) { + throw new IOException("Could NOT find numeric field '" + amountFieldName + "' in pool JSON!"); + } + + return new BigDecimal(amountNode.toString()); + } + + private ΩamountDecimalsΩ extractMintDecimals(JsonNode poolNode, String mintFieldName) throws IOException { + JsonNode decimalsNode = poolNode.path(mintFieldName).path("decimals"); + + if (decimalsNode.isMissingNode() || !decimalsNode.isNumber()) { + throw new IOException("Could NOT find numeric field '" + mintFieldName + ".decimals' in pool JSON!"); + } + + return decimalsNode.asInt(); + } + + private ΩPriceΩ extractPriceEstimate(JsonNode poolNode) throws IOException { + JsonNode priceNode = poolNode.path("price"); + + if (priceNode.isMissingNode() || !priceNode.isNumber()) { + throw new IOException("Could NOT find numeric field 'price' in pool JSON!"); + } + + return new BigDecimal(priceNode.toString()); + } + + private Set<ΩRaydiumLiquidityPoolStandardIdΩ> fetchStandardLiquidityPoolIds(ΩSolanaAddressΩ ownerAddress) throws IOException, InterruptedException { + var tokenHoldings = solanaBlockChain.getSPLTokenHoldings( + ownerAddress, + SPL_TOKEN_PROGRAM + ); + + Set<ΩSPLMintAddressΩ> mintAddresses = tokenHoldings.keySet(); + + if (mintAddresses.isEmpty()) { + return Set.of(); + } + + ΩRestEndpointΩ endpoint = createPoolInfoByCandidateLpMintsEndpoint(mintAddresses); + JsonNode root = fetchJson(endpoint); + + return extractStandardLiquidityPoolIds(root); + } + + private Set<ΩRaydiumLiquidityPoolConcentratedIdΩ> fetchConcentratedLiquidityPoolIds( + ΩSolanaAddressΩ ownerAddress + ) throws IOException, InterruptedException { + Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> positionNftIds = fetchConcentratedPositionNftIds(ownerAddress); + Set positionAccountInfos = fetchRaydiumPositionAccountInfos(positionNftIds); + + return extractConcentratedLiquidityPoolIds(positionAccountInfos); + } + + private Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> filterCandidatePositionNftIds( + Set<ΩSolanaNFTAddressΩ> candidatePositionNftIds + ) throws IOException, InterruptedException { + Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> positionNftIds = new HashSet<>(); + + System.out.println("Filtering " + candidatePositionNftIds.size() + " NFTs for Raydium Concentrated pools..."); + for (ΩSolanaNFTAddressΩ candidatePositionNftId : candidatePositionNftIds) { + System.out.print(" " + candidatePositionNftId + " "); + SolanaProgramDerivedAddress positionPda = findRaydiumPositionPda(candidatePositionNftId); + SolanaAccountInfo accountInfo = solanaBlockChain.getAccountInfo(positionPda.address()); + + if (accountInfo == null) { + System.out.println("NO"); + continue; + } + + if (RAYDIUM_CLMM_PROGRAM_ID.equals(accountInfo.owner())) { + System.out.println("YES"); + positionNftIds.add(candidatePositionNftId); + } + } + + return Set.copyOf(positionNftIds); + } + + private SolanaProgramDerivedAddress findRaydiumPositionPda(ΩSolanaNFTAddressΩ candidatePositionNftId) { + return solanaBlockChain.findProgramAddress( + RAYDIUM_CLMM_PROGRAM_ID, + List.of( + SolanaProgramAddressSeed.utf8("position"), + SolanaProgramAddressSeed.solanaAddress(candidatePositionNftId) + ) + ); + } + + private SolanaAccountInfo fetchRaydiumPositionAccountInfo( + ΩRaydiumLiquidityPoolPositionNftIdΩ positionNftId + ) throws IOException, InterruptedException { + SolanaProgramDerivedAddress positionPda = findRaydiumPositionPda(positionNftId); + + SolanaAccountInfo accountInfo = solanaBlockChain.getAccountInfo(positionPda.address()); + + if (accountInfo == null) { + throw new IOException("Raydium position account was not found for position NFT id: " + positionNftId); + } + + if (!RAYDIUM_CLMM_PROGRAM_ID.equals(accountInfo.owner())) { + throw new IOException("Raydium position account is not owned by the Raydium CLMM program: " + positionPda.address()); + } + + return accountInfo; + } + + private SolanaAccountInfo fetchRaydiumPoolAccountInfo( + ΩRaydiumLiquidityPoolConcentratedIdΩ poolId + ) throws IOException, InterruptedException { + SolanaAccountInfo accountInfo = solanaBlockChain.getAccountInfo(poolId); + + if (accountInfo == null) { + throw new IOException("Raydium concentrated pool account was not found for pool id: " + poolId); + } + + if (!RAYDIUM_CLMM_PROGRAM_ID.equals(accountInfo.owner())) { + throw new IOException("Raydium concentrated pool account is not owned by the Raydium CLMM program: " + poolId); + } + + return accountInfo; + } + + private Set fetchRaydiumPositionAccountInfos( + Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> positionNftIds + ) throws IOException, InterruptedException { + Set accountInfos = new HashSet<>(); + + for (ΩRaydiumLiquidityPoolPositionNftIdΩ positionNftId : positionNftIds) { + accountInfos.add(fetchRaydiumPositionAccountInfo(positionNftId)); + } + + return Set.copyOf(accountInfos); + } + + private Set<ΩRaydiumLiquidityPoolConcentratedIdΩ> extractConcentratedLiquidityPoolIds( + Set positionAccountInfos + ) throws IOException { + Set<ΩRaydiumLiquidityPoolConcentratedIdΩ> liquidityPoolIds = new HashSet<>(); + + for (SolanaAccountInfo positionAccountInfo : positionAccountInfos) { + byte[] accountData = decodeBase64AccountData(positionAccountInfo); + + byte[] poolIdBytes = extractConcentratedLiquidityPoolIdBytes(accountData); + ΩRaydiumLiquidityPoolConcentratedIdΩ liquidityPoolId = solanaBlockChain.encodeSolanaAddress(poolIdBytes); + + liquidityPoolIds.add(liquidityPoolId); + } + + return Set.copyOf(liquidityPoolIds); + } + + private Set<ΩRaydiumLiquidityPoolStandardIdΩ> extractStandardLiquidityPoolIds(JsonNode root) throws IOException { + JsonNode data = root.path("data"); + if (!data.isArray()) { + throw new IOException("Returned JSON do NOT contain a valid data-array!"); + } + + Set<ΩRaydiumLiquidityPoolStandardIdΩ> liquidityPoolIds = new HashSet<>(); + + for (JsonNode poolNode : data) { + if (poolNode == null || poolNode.isNull()) { + continue; + } + + JsonNode idNode = poolNode.path("id"); + + if (idNode.isMissingNode() || !idNode.isTextual()) { + throw new IOException("Could NOT find textual field 'id' in pool JSON!"); + } + + liquidityPoolIds.add(idNode.asText()); + } + + return Set.copyOf(liquidityPoolIds); + } + + private ΩRestEndpointΩ createPoolInfoByIdEndpoint(ΩRaydiumLiquidityPoolIdΩ raydiumLiquidityPoolId) { + return ΩRestEndpointΩ.create(RAYDIUM_API_V3_BASE_URI + "/pools/info/ids?ids=" + raydiumLiquidityPoolId); + } + + private JsonNode fetchJson(ΩRestEndpointΩ restEndpoint) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(restEndpoint) + .GET() + .header("Accept", "application/json") + .build(); + + HttpResponse response = sendThrottled(request); + + if (response.statusCode() != 200) { + throw new IOException("HTTP error: " + response.statusCode() + ", body: " + response.body()); + } + + return objectMapper.readTree(response.body()); + } + + private synchronized HttpResponse sendThrottled(HttpRequest request) throws IOException, InterruptedException { + waitBeforeRemoteCall(); + + try { + return httpClient.send( + request, + HttpResponse.BodyHandlers.ofString() + ); + } finally { + previousRemoteCallTime = System.currentTimeMillis(); + } + } + + private void waitBeforeRemoteCall() throws InterruptedException { + long now = System.currentTimeMillis(); + long elapsed = now - previousRemoteCallTime; + + if (elapsed < MINIMUM_REMOTE_CALL_INTERVAL) { + ΩmilliSecondsΩ sleepTime = MINIMUM_REMOTE_CALL_INTERVAL - elapsed; + //System.out.println("Throttling Raydium request for " + sleepTime + "ms..."); + Thread.sleep(sleepTime); + //System.out.println("Ready"); + } + } + + private ΩRestEndpointΩ createPoolInfoByCandidateLpMintsEndpoint(Set<ΩSPLMintAddressΩ> candidateLpMintAddresses) { + String joinedLpMintAddresses = String.join( + ",", + candidateLpMintAddresses.stream() + .map(Object::toString) + .toList() + ); + + return ΩRestEndpointΩ.create(RAYDIUM_API_V3_BASE_URI + "/pools/info/lps?lps=" + joinedLpMintAddresses); + } + + private byte[] decodeBase64AccountData(SolanaAccountInfo accountInfo) throws IOException { + try { + return Base64.getDecoder().decode(accountInfo.dataBase64()); + } catch (IllegalArgumentException e) { + throw new IOException("Could not decode Solana account data as Base64 for account: " + accountInfo.address(), e); + } + } + + private byte[] extractConcentratedLiquidityPoolIdBytes(byte[] accountData) throws IOException { + ensureAccountDataContains( + accountData, + RAYDIUM_POSITION_ACCOUNT_POOL_ID_OFFSET, + SOLANA_ADDRESS_LENGTH, + "pool id" + ); + + byte[] poolIdBytes = new byte[SOLANA_ADDRESS_LENGTH]; + + System.arraycopy( + accountData, + RAYDIUM_POSITION_ACCOUNT_POOL_ID_OFFSET, + poolIdBytes, + 0, + SOLANA_ADDRESS_LENGTH + ); + + return poolIdBytes; + } + + private ΩraydiumLiquidityTickIndexΩ extractConcentratedLiquidityTickIndex( + byte[] accountData, + int offset, + String fieldName + ) throws IOException { + ensureAccountDataContains( + accountData, + offset, + RAYDIUM_POSITION_ACCOUNT_TICK_INDEX_LENGTH, + fieldName + ); + + return (accountData[offset] & 0xFF) + | ((accountData[offset + 1] & 0xFF) << 8) + | ((accountData[offset + 2] & 0xFF) << 16) + | ((accountData[offset + 3] & 0xFF) << 24); + } + + private ΩRaydiumLiquidityΩ extractConcentratedLiquidity(byte[] accountData) throws IOException { + return extractUnsigned128LittleEndian( + accountData, + RAYDIUM_POSITION_ACCOUNT_LIQUIDITY_OFFSET, + "liquidity" + ); + } + + private ΩRaydiumLiquidityΩ extractConcentratedPoolLiquidity(byte[] accountData) throws IOException { + return extractUnsigned128LittleEndian( + accountData, + RAYDIUM_POOL_STATE_LIQUIDITY_OFFSET, + "liquidity" + ); + } + + private ΩRaydiumSqrtPriceX64Ω extractConcentratedPoolSqrtPriceX64(byte[] accountData) throws IOException { + return extractUnsigned128LittleEndian( + accountData, + RAYDIUM_POOL_STATE_SQRT_PRICE_X64_OFFSET, + "sqrtPriceX64" + ); + } + + private BigInteger extractUnsigned128LittleEndian( + byte[] accountData, + int offset, + String fieldName + ) throws IOException { + ensureAccountDataContains( + accountData, + offset, + RAYDIUM_U128_LENGTH, + fieldName + ); + + byte[] bigEndianBytes = new byte[RAYDIUM_U128_LENGTH + 1]; + + for (int i = 0; i < RAYDIUM_U128_LENGTH; i++) { + bigEndianBytes[bigEndianBytes.length - 1 - i] = accountData[offset + i]; + } + + return new BigInteger(bigEndianBytes); + } + + private void ensureAccountDataContains( + byte[] accountData, + int offset, + int length, + String fieldName + ) throws IOException { + if (accountData.length < offset + length) { + throw new IOException("Raydium position account data is too short to contain " + fieldName + "."); + } + } + + private JsonNode getFirstPoolNode(JsonNode root) throws IOException { + JsonNode data = root.path("data"); + + if (!data.isArray() || data.isEmpty()) { + throw new IOException("Returned JSON do NOT contain a valid data-array!"); + } + + JsonNode firstPool = data.get(0); + + if (firstPool == null || firstPool.isNull()) { + throw new IOException("Returned JSON data-array did NOT contain a pool!"); + } + + return firstPool; + } + + private record RaydiumStandardPoolInfo( + ΩRaydiumLiquidityPoolStandardIdΩ poolId, + ΩSPLMintAddressΩ mintA, + ΩAmountΩ amountA, + ΩSPLMintAddressΩ mintB, + ΩAmountΩ amountB, + ΩSPLMintAddressΩ lpMint, + ΩAmountΩ totalLpAmount + ) { + } + + private static final ΩRestEndpointBaseΩ RAYDIUM_API_V3_BASE_URI = ΩRestEndpointBaseΩ.create("https://api-v3.raydium.io"); + private static final ΩRaydiumProgramIdΩ RAYDIUM_CLMM_PROGRAM_ID = "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK"; + private static final ΩmilliSecondsΩ MINIMUM_REMOTE_CALL_INTERVAL = 5000L; + private static final BigDecimal RAYDIUM_TICK_BASE = new BigDecimal("1.0001"); + private static final BigDecimal RAYDIUM_Q64_FACTOR = new BigDecimal(BigInteger.ONE.shiftLeft(64)); + private static final MathContext RAYDIUM_PRICE_MATH_CONTEXT = MathContext.DECIMAL128; + + /** + * Byte layout used when decoding Raydium CLMM PersonalPositionState account data. + * + *

This layout is based on Raydium's on-chain {@code PersonalPositionState} + * account. The account starts with the 8-byte Anchor discriminator, followed by + * the packed position fields. The fields decoded here are the NFT mint, pool id, + * lower/upper tick indexes and position liquidity.

+ * + *
+     * 0   - 8    Anchor discriminator
+     * 8   - 9    bump
+     * 9   - 41   nft_mint
+     * 41  - 73   pool_id
+     * 73  - 77   tick_lower_index       i32 little-endian
+     * 77  - 81   tick_upper_index       i32 little-endian
+     * 81  - 97   liquidity              u128 little-endian
+     * 
+ * + *

If Raydium changes the on-chain {@code PersonalPositionState} layout, these + * offsets must be checked and updated before decoding position state.

+ */ + private static final int SOLANA_ADDRESS_LENGTH = 32; + private static final int RAYDIUM_POSITION_ACCOUNT_DISCRIMINATOR_LENGTH = 8; + private static final int RAYDIUM_POSITION_ACCOUNT_BUMP_LENGTH = 1; + private static final int RAYDIUM_POSITION_ACCOUNT_NFT_MINT_OFFSET = + RAYDIUM_POSITION_ACCOUNT_DISCRIMINATOR_LENGTH + RAYDIUM_POSITION_ACCOUNT_BUMP_LENGTH; + private static final int RAYDIUM_POSITION_ACCOUNT_POOL_ID_OFFSET = + RAYDIUM_POSITION_ACCOUNT_NFT_MINT_OFFSET + SOLANA_ADDRESS_LENGTH; + private static final int RAYDIUM_POSITION_ACCOUNT_TICK_INDEX_LENGTH = 4; + private static final int RAYDIUM_POSITION_ACCOUNT_TICK_LOWER_INDEX_OFFSET = + RAYDIUM_POSITION_ACCOUNT_POOL_ID_OFFSET + SOLANA_ADDRESS_LENGTH; + private static final int RAYDIUM_POSITION_ACCOUNT_TICK_UPPER_INDEX_OFFSET = + RAYDIUM_POSITION_ACCOUNT_TICK_LOWER_INDEX_OFFSET + RAYDIUM_POSITION_ACCOUNT_TICK_INDEX_LENGTH; + private static final int RAYDIUM_U128_LENGTH = 16; + private static final int RAYDIUM_POSITION_ACCOUNT_LIQUIDITY_OFFSET = + RAYDIUM_POSITION_ACCOUNT_TICK_UPPER_INDEX_OFFSET + RAYDIUM_POSITION_ACCOUNT_TICK_INDEX_LENGTH; + + /** + * Byte layout used when decoding Raydium CLMM PoolState account data. + * + *

This layout is based on Raydium's on-chain {@code PoolState} account. + * The account starts with the 8-byte Anchor discriminator, followed by the packed + * {@code PoolState} fields. The fields decoded here are pool liquidity, + * current square-root price in Q64.64 format, and current tick index.

+ * + *
+     * 0   - 8    Anchor discriminator
+     * 8   - 9    bump
+     * 9   - 41   amm_config
+     * 41  - 73   owner
+     * 73  - 105  token_mint_0
+     * 105 - 137  token_mint_1
+     * 137 - 169  token_vault_0
+     * 169 - 201  token_vault_1
+     * 201 - 233  observation_key
+     * 233 - 234  mint_decimals_0
+     * 234 - 235  mint_decimals_1
+     * 235 - 237  tick_spacing
+     * 237 - 253  liquidity              u128 little-endian
+     * 253 - 269  sqrt_price_x64         u128 little-endian
+     * 269 - 273  tick_current           i32 little-endian
+     * 
+ * + *

If Raydium changes the on-chain {@code PoolState} layout, these offsets must + * be checked and updated before decoding pool state.

+ */ + private static final int RAYDIUM_POOL_STATE_LIQUIDITY_OFFSET = 237; + private static final int RAYDIUM_POOL_STATE_SQRT_PRICE_X64_OFFSET = + RAYDIUM_POOL_STATE_LIQUIDITY_OFFSET + RAYDIUM_U128_LENGTH; + private static final int RAYDIUM_POOL_STATE_TICK_CURRENT_OFFSET = + RAYDIUM_POOL_STATE_SQRT_PRICE_X64_OFFSET + RAYDIUM_U128_LENGTH; + + private final SolanaBlockChain solanaBlockChain; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + + private ΩmilliSecondsΩ previousRemoteCallTime = 0L; +}