89: Add EvelynLiquidityService to withdraw CLMM proceeds, swap to USDC and send payouts

This commit is contained in:
2026-10-04 19:42:21 +02:00
parent 9c636bc93d
commit b9369a870f
31 changed files with 2186 additions and 29 deletions
@@ -0,0 +1,6 @@
package com.r35157.evelyn.service.liquidity;
import com.r35157.nenjim.component.NenjimComponent;
/** Realizes manually funded Evelyn liquidity into USDC payouts; lifecycle belongs to the Hub. */
public interface EvelynLiquidityService extends NenjimComponent { }
@@ -0,0 +1,91 @@
package com.r35157.evelyn.service.liquidity.impl.ref;
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
import com.r35157.libs.configuration.ConfigurationFormatVersionValidator;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.*;
/** Versioned deployment input. Signer configuration itself remains external to this file. */
public record EvelynLiquidityConfiguration(
@NotNull ΩSolanaWalletIdΩ walletAddress,
@NotNull String signerKeyName,
@NotNull ΩRaydiumLiquidityPoolConcentratedIdΩ poolId,
@NotNull BigDecimal harvestDistancePercent,
@NotNull Duration iterationInterval,
@NotNull ΩSolanaWalletIdΩ usdcRecipient,
@NotNull ΩAmountΩ minimumSyrupUSDCReserve,
@NotNull ΩUSDCAmountΩ minimumUSDCReserve,
int maximumSwapSlippageBps,
int maximumWithdrawalSlippageBps) {
public EvelynLiquidityConfiguration {
requireAddress(walletAddress, "LIQUIDITY_WALLET");
requireAddress(poolId, "CLMM_POOL");
requireAddress(usdcRecipient, "USDC_RECIPIENT");
if (walletAddress.equals(usdcRecipient)) throw new IllegalArgumentException("USDC recipient must differ from liquidity wallet");
if (signerKeyName == null || !signerKeyName.matches("[A-Za-z0-9][A-Za-z0-9._-]*")) {
throw new IllegalArgumentException("SIGNER_KEY_NAME must name an external jup signer");
}
Objects.requireNonNull(harvestDistancePercent, "harvestDistancePercent");
if (harvestDistancePercent.signum() < 0 || harvestDistancePercent.compareTo(new BigDecimal("100")) >= 0) {
throw new IllegalArgumentException("HARVEST_DISTANCE_PERCENT must be in [0,100)");
}
Objects.requireNonNull(iterationInterval, "iterationInterval");
if (iterationInterval.isNegative() || iterationInterval.isZero()) throw new IllegalArgumentException("ITERATION_INTERVAL_SECONDS must be positive");
try { iterationInterval.toNanos(); }
catch (ArithmeticException exception) { throw new IllegalArgumentException("Iteration interval is too large", exception); }
requireReserve(minimumSyrupUSDCReserve, "MINIMUM_SYRUPUSDC_RESERVE");
requireReserve(minimumUSDCReserve, "MINIMUM_USDC_RESERVE");
if (maximumSwapSlippageBps < 0 || maximumSwapSlippageBps > 10000) throw new IllegalArgumentException("MAXIMUM_SWAP_SLIPPAGE_BPS must be in [0,10000]");
if (maximumWithdrawalSlippageBps < 0 || maximumWithdrawalSlippageBps >= 10000) throw new IllegalArgumentException("MAXIMUM_WITHDRAWAL_SLIPPAGE_BPS must be in [0,10000)");
}
public static @NotNull EvelynLiquidityConfiguration parse(@NotNull Path path) throws IOException {
// Deliberately no .example fallback.
List<String> lines = Files.readAllLines(path);
int versionLine = ConfigurationFormatVersionValidator.validate(path, lines, 1);
Map<String, String> values = new HashMap<>();
for (int i = versionLine + 1; i < lines.size(); i++) {
String line = lines.get(i).split("#", 2)[0].strip();
if (line.isEmpty()) continue;
String[] parts = line.split("=", 2);
if (parts.length != 2 || !KEYS.contains(parts[0].strip()) || parts[1].isBlank()) {
throw new IllegalArgumentException(path + ":" + (i + 1) + ": Invalid liquidity configuration entry");
}
if (values.putIfAbsent(parts[0].strip(), parts[1].strip()) != null) {
throw new IllegalArgumentException(path + ":" + (i + 1) + ": Duplicate " + parts[0].strip());
}
}
if (!values.keySet().equals(KEYS)) {
Set<String> missing = new TreeSet<>(KEYS); missing.removeAll(values.keySet());
throw new IllegalArgumentException(path + ": Missing settings " + missing);
}
try {
return new EvelynLiquidityConfiguration(values.get("LIQUIDITY_WALLET"), values.get("SIGNER_KEY_NAME"),
values.get("CLMM_POOL"), new BigDecimal(values.get("HARVEST_DISTANCE_PERCENT")),
Duration.ofSeconds(Long.parseLong(values.get("ITERATION_INTERVAL_SECONDS"))),
values.get("USDC_RECIPIENT"), new BigDecimal(values.get("MINIMUM_SYRUPUSDC_RESERVE")),
new BigDecimal(values.get("MINIMUM_USDC_RESERVE")), Integer.parseInt(values.get("MAXIMUM_SWAP_SLIPPAGE_BPS")),
Integer.parseInt(values.get("MAXIMUM_WITHDRAWAL_SLIPPAGE_BPS")));
} catch (IllegalArgumentException | ArithmeticException exception) {
throw new IllegalArgumentException(path + ": Invalid liquidity settings: " + exception.getMessage(), exception);
}
}
private static void requireAddress(String value, String setting) {
try {
if (value == null || new Base58CodecImpl().decode(value).length != 32) throw new IllegalArgumentException();
} catch (IllegalArgumentException exception) { throw new IllegalArgumentException(setting + " must be a 32-byte Solana address", exception); }
}
private static void requireReserve(BigDecimal value, String setting) {
if (value == null || value.signum() < 0) throw new IllegalArgumentException(setting + " must be nonnegative");
}
private static final Set<String> KEYS = Set.of("LIQUIDITY_WALLET", "SIGNER_KEY_NAME", "CLMM_POOL", "HARVEST_DISTANCE_PERCENT",
"ITERATION_INTERVAL_SECONDS", "USDC_RECIPIENT", "MINIMUM_SYRUPUSDC_RESERVE", "MINIMUM_USDC_RESERVE",
"MAXIMUM_SWAP_SLIPPAGE_BPS", "MAXIMUM_WITHDRAWAL_SLIPPAGE_BPS");
}
@@ -0,0 +1,198 @@
package com.r35157.evelyn.service.liquidity.impl.ref;
import com.r35157.assetaz.services.cis.CurrencyIdentityService;
import com.r35157.evelyn.service.liquidity.EvelynLiquidityService;
import com.r35157.cryptowallet.solana.MissingRecipientTokenAccountPolicy;
import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.libs.jupiter.swap.JupiterSwapService;
import com.r35157.libs.jupiter.swap.JupiterSwapResult;
import com.r35157.libs.raydium.*;
import com.r35157.libs.solana.*;
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
import com.r35157.nenjim.process.NenjimProcess;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.*;
import static com.r35157.assetaz.services.cis.ExternalCurrencyReference.SOLANA_MINT_NAMESPACE;
/** Three independent steps; the chain and wallet are the only durable queue. */
public final class EvelynLiquidityServiceImpl implements EvelynLiquidityService, NenjimProcess, AutoCloseable {
public EvelynLiquidityServiceImpl(@NotNull SolanaWallet wallet, @NotNull JupiterSwapService swap,
@NotNull Raydium raydium, @NotNull SolanaBlockChain chain,
@NotNull CurrencyIdentityService cis, @NotNull EvelynLiquidityConfiguration configuration) {
this.wallet = Objects.requireNonNull(wallet);
this.swap = Objects.requireNonNull(swap);
this.raydium = Objects.requireNonNull(raydium);
this.chain = Objects.requireNonNull(chain);
this.cis = Objects.requireNonNull(cis);
this.configuration = Objects.requireNonNull(configuration);
if (!wallet.getAddress().equals(configuration.walletAddress())) throw new IllegalArgumentException("Liquidity wallet/configuration mismatch");
}
@Override
public synchronized void start() throws IOException, InterruptedException {
if (startAttempted || closed) throw new IllegalStateException("Liquidity service start is single-use");
startAttempted = true;
initialize(); // All configuration, identities and chain metadata before worker creation.
worker = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "evelyn-liquidity"); thread.setDaemon(true); return thread;
});
shutdownHook = new Thread(this::close, "evelyn-liquidity-shutdown");
try {
Runtime.getRuntime().addShutdownHook(shutdownHook);
worker.scheduleWithFixedDelay(this::runIterationSafely, 0, configuration.iterationInterval().toNanos(), TimeUnit.NANOSECONDS);
} catch (RuntimeException exception) { close(); throw exception; }
}
// Package-visible synchronous seam for offline controlled verification. Does no financial work.
void initialize() throws IOException, InterruptedException {
SolanaAccountInfo recipient = chain.getAccountInfo(configuration.usdcRecipient());
if (recipient != null && !"11111111111111111111111111111111".equals(recipient.owner())) {
throw new IllegalArgumentException("USDC recipient must be a wallet owner, not a token or program account");
}
eve = resolveToken(EVE_ID); syrup = resolveToken(SYRUPUSDC_ID); usdc = resolveToken(USDC_ID);
requirePrecision(configuration.minimumSyrupUSDCReserve(), syrup.decimals(), "SyrupUSDC reserve");
requirePrecision(configuration.minimumUSDCReserve(), usdc.decimals(), "USDC reserve");
poolInfo = raydium.fetchConcentratedPoolInfoOnChain(configuration.poolId());
if (!configuration.poolId().equals(poolInfo.poolId())) throw new IllegalArgumentException("CLMM pool mismatch");
baseIsMintA = eve.mint().equals(poolInfo.mintA()) && syrup.mint().equals(poolInfo.mintB());
boolean reversed = syrup.mint().equals(poolInfo.mintA()) && eve.mint().equals(poolInfo.mintB());
if ((!baseIsMintA && !reversed)
|| poolInfo.mintADecimals() != (baseIsMintA ? eve.decimals() : syrup.decimals())
|| poolInfo.mintBDecimals() != (baseIsMintA ? syrup.decimals() : eve.decimals())) {
throw new IllegalArgumentException("CLMM pool must contain canonical EVE/SyrupUSDC with matching precision");
}
initialized = true;
}
void runIteration() throws InterruptedException {
if (!initialized) throw new IllegalStateException("Liquidity service not initialized");
checkInterrupted();
independently("withdraw", this::withdrawEligiblePositions);
checkInterrupted();
independently("swap", this::swapAvailableSyrup);
checkInterrupted();
independently("payout", this::sendAvailableUsdc);
}
private void withdrawEligiblePositions() throws Exception {
for (ΩRaydiumLiquidityPoolPositionNftIdΩ nft : raydium.fetchConcentratedPositionNftIds(wallet.getAddress())) {
checkInterrupted();
RaydiumConcentratedPositionState position;
try {
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());
log.info("withdraw position={} liquidity={} signature={}", nft, position.liquidity(), signature);
} catch (InterruptedException exception) { throw exception; }
catch (Exception exception) { log.warn("withdraw position={} failed", nft, exception); }
}
}
private void swapAvailableSyrup() throws Exception {
ΩAmountΩ amount = available(syrup, configuration.minimumSyrupUSDCReserve());
if (amount.signum() <= 0) return;
checkInterrupted();
log.info("swap SyrupUSDC amount={} to USDC", amount);
JupiterSwapResult result = swap.swap(syrup.mint(), amount, usdc.mint(), configuration.maximumSwapSlippageBps());
log.info("swap SyrupUSDC spent={} USDC received={} signature={}", result.spentInputTokenAmount(), result.receivedOutputTokenAmount(), result.transactionSignature());
}
private void sendAvailableUsdc() throws Exception {
ΩAmountΩ amount = available(usdc, configuration.minimumUSDCReserve());
if (amount.signum() <= 0) return;
checkInterrupted();
log.info("payout USDC amount={} recipient={}", amount, configuration.usdcRecipient());
ΩSolanaTransactionSignatureΩ signature = wallet.sendSPLToken(usdc.mint(), configuration.usdcRecipient(), amount, MissingRecipientTokenAccountPolicy.CREATE_AT_SENDER_EXPENSE);
log.info("payout USDC amount={} signature={}", amount, signature);
// Existing wallet transfer submits once; wait before the next iteration can reuse balances.
SolanaTransactionOutcome outcome = chain.awaitTransaction(signature, SolanaCommitment.CONFIRMED, Duration.ofSeconds(60));
if (outcome.status() != SolanaTransactionOutcome.Status.SUCCEEDED) throw new IOException("payout signature=" + signature + " outcome=" + outcome);
}
private ΩAmountΩ available(Token token, ΩAmountΩ reserve) throws IOException, InterruptedException {
ΩAmountΩ balance = wallet.getSPLTokenBalance(token.mint(), token.program());
if (balance == null || balance.compareTo(reserve) <= 0) return BigDecimal.ZERO;
return balance.subtract(reserve).setScale(token.decimals(), RoundingMode.DOWN);
}
private Token resolveToken(UUID id) throws IOException, InterruptedException {
cis.resolve(id);
List<ΩSPLMintAddressΩ> mints = cis.findExternalReferences(id).stream().filter(reference -> SOLANA_MINT_NAMESPACE.equals(reference.namespace()))
.map(reference -> reference.externalId()).filter(mint -> mint != null && !mint.isBlank()).toList();
if (mints.size() != 1) throw new IllegalArgumentException("Currency must have one canonical Solana mint: " + id);
ΩSPLMintAddressΩ mint = mints.getFirst();
SolanaAccountInfo info = chain.getAccountInfo(mint);
SolanaSPLTokenProgram program = Arrays.stream(SolanaSPLTokenProgram.values())
.filter(candidate -> info != null && candidate.getAddress().equals(info.owner())).findFirst()
.orElseThrow(() -> new IllegalArgumentException("Missing/unsupported canonical mint: " + mint));
byte[] data;
try { data = Base64.getDecoder().decode(info.data()); }
catch (RuntimeException exception) { throw new IllegalArgumentException("Invalid canonical mint data", exception); }
if (data.length < 82 || data[45] != 1) throw new IllegalArgumentException("Canonical mint is not initialized");
int decimals = Byte.toUnsignedInt(data[44]);
return new Token(mint, program, decimals);
}
private static void requirePrecision(BigDecimal amount, int decimals, String name) {
try { amount.setScale(decimals, RoundingMode.UNNECESSARY); }
catch (ArithmeticException exception) { throw new IllegalArgumentException(name + " exceeds mint precision", exception); }
}
private void independently(String step, Step operation) throws InterruptedException {
try { operation.run(); }
catch (InterruptedException exception) { throw exception; }
catch (Exception exception) { log.warn("{} step failed; independent work continues", step, exception); }
}
private void runIterationSafely() {
try { runIteration(); }
catch (InterruptedException exception) { Thread.currentThread().interrupt(); close(); }
catch (RuntimeException exception) { log.error("Liquidity iteration failed", exception); }
}
private void checkInterrupted() throws InterruptedException {
if (closed || Thread.currentThread().isInterrupted()) throw new InterruptedException("Liquidity service interrupted");
}
@Override
public synchronized void close() {
closed = true;
if (worker != null) worker.shutdownNow();
if (shutdownHook != null && Thread.currentThread() != shutdownHook) {
try { Runtime.getRuntime().removeShutdownHook(shutdownHook); }
catch (IllegalStateException ignored) { /* JVM is already shutting down. */ }
}
}
@FunctionalInterface private interface Step { void run() throws Exception; }
private record Token(ΩSPLMintAddressΩ mint, SolanaSPLTokenProgram program, ΩamountDecimalsΩ decimals) { }
private static final Logger log = LoggerFactory.getLogger(EvelynLiquidityServiceImpl.class);
private final SolanaWallet wallet;
private final JupiterSwapService swap;
private final Raydium raydium;
private final SolanaBlockChain chain;
private final CurrencyIdentityService cis;
private final EvelynLiquidityConfiguration configuration;
private Token eve, syrup, usdc;
private RaydiumConcentratedPoolInfo poolInfo;
private boolean baseIsMintA, initialized, startAttempted;
private volatile boolean closed;
private ScheduledExecutorService worker;
private Thread shutdownHook;
}
@@ -4,6 +4,25 @@ import com.r35157.libs.codec.Base58Codec;
public class Base58CodecImpl implements Base58Codec {
/** Decodes Base58 without assuming a particular address/signature width. */
public @org.jetbrains.annotations.NotNull byte[] decode(@org.jetbrains.annotations.NotNull ΩBase58StringΩ input) {
java.util.Objects.requireNonNull(input, "input");
java.math.BigInteger value = java.math.BigInteger.ZERO;
String alphabet = new String(ALPHABET);
for (char character : input.toCharArray()) {
int digit = alphabet.indexOf(character);
if (digit < 0) throw new IllegalArgumentException("Invalid Base58 character");
value = value.multiply(java.math.BigInteger.valueOf(58)).add(java.math.BigInteger.valueOf(digit));
}
int zeros = 0;
while (zeros < input.length() && input.charAt(zeros) == '1') zeros++;
byte[] encoded = value.toByteArray();
int start = encoded[0] == 0 ? 1 : 0;
byte[] result = new byte[zeros + encoded.length - start];
System.arraycopy(encoded, start, result, zeros, encoded.length - start);
return result;
}
public String encode(byte[] input) {
if (input.length == 0) {
return "";
@@ -10,6 +10,28 @@ import java.math.BigDecimal;
import java.util.Set;
public interface Raydium extends NenjimComponent {
/** Reads validated CLMM mint identities/precision directly from chain. */
@org.jetbrains.annotations.NotNull
RaydiumConcentratedPoolInfo fetchConcentratedPoolInfoOnChain(
@org.jetbrains.annotations.NotNull ΩRaydiumLiquidityPoolConcentratedIdΩ poolId
) throws IOException, InterruptedException;
/**
* 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
ΩSolanaTransactionSignatureΩ withdrawConcentratedPositionLiquidity(
@org.jetbrains.annotations.NotNull com.r35157.cryptowallet.solana.SolanaWallet wallet,
@org.jetbrains.annotations.NotNull RaydiumConcentratedPositionState position,
@org.jetbrains.annotations.NotNull RaydiumConcentratedPoolState quoteState,
int maximumSlippageBps
) throws IOException, InterruptedException;
/**
* Fetches the current price for a Raydium liquidity pool.
*
@@ -48,7 +70,9 @@ public interface Raydium extends NenjimComponent {
*
* <p>The returned set contains position NFT ids, not pool ids. Callers can use the returned
* NFT ids to fetch concentrated position state and then determine which pools the positions
* belong to.</p>
* belong to. Individual candidate or token-program read failures are logged
* and isolated, so a readable subset may be returned. Failure to read either
* token program fails discovery.</p>
*
* @param ownerAddress the Solana owner address to inspect
* @return the Raydium concentrated liquidity position NFT ids owned by the address
@@ -0,0 +1,78 @@
package com.r35157.libs.raydium;
import java.math.BigDecimal;
import java.math.BigInteger;
import org.jetbrains.annotations.NotNull;
/** Integer Q64.64 math matching Raydium's official tick_math.rs. */
public final class RaydiumClmmMath {
public static @NotNull BigInteger sqrtPriceAtTick(int tick) {
if (tick < -443636 || tick > 443636) {
throw new IllegalArgumentException("CLMM tick out of range: " + tick);
}
int absolute = Math.abs(tick);
BigInteger ratio = Q64;
for (int bit = 0; bit < FACTORS.length; bit++) {
if ((absolute & (1 << bit)) != 0) {
ratio = ratio.multiply(FACTORS[bit]).shiftRight(64);
}
}
return tick > 0 ? MAX_U128.divide(ratio) : ratio;
}
/** Decimal factors cancel between position and pool; no rounded UI prices. */
public static boolean isAtOrBelowHarvestThreshold(
@NotNull RaydiumConcentratedPositionState position,
@NotNull RaydiumConcentratedPoolState pool,
boolean baseIsMintA,
@NotNull BigDecimal harvestDistancePercent) {
validate(position, pool);
if (harvestDistancePercent.signum() < 0
|| harvestDistancePercent.compareTo(new BigDecimal("100")) >= 0) {
throw new IllegalArgumentException("Harvest distance must be in [0,100)");
}
BigDecimal retained = BigDecimal.ONE.subtract(harvestDistancePercent.movePointLeft(2));
BigInteger boundary = sqrtPriceAtTick(baseIsMintA
? position.tickUpperIndex() : position.tickLowerIndex());
BigDecimal boundarySquared = new BigDecimal(boundary.multiply(boundary));
BigDecimal marketSquared = new BigDecimal(pool.sqrtPriceX64().pow(2));
return baseIsMintA
? boundarySquared.compareTo(marketSquared.multiply(retained)) <= 0
: marketSquared.compareTo(boundarySquared.multiply(retained)) <= 0;
}
/** Floor principal in raw units, as used by the on-chain decrease. */
public static @NotNull BigInteger[] principalRawAmounts(
@NotNull RaydiumConcentratedPositionState position,
@NotNull RaydiumConcentratedPoolState pool) {
validate(position, pool);
BigInteger lower = sqrtPriceAtTick(position.tickLowerIndex());
BigInteger upper = sqrtPriceAtTick(position.tickUpperIndex());
BigInteger current = pool.sqrtPriceX64().max(lower).min(upper);
BigInteger amountA = position.liquidity().multiply(Q64)
.multiply(upper.subtract(current)).divide(upper.multiply(current));
BigInteger amountB = position.liquidity().multiply(current.subtract(lower)).divide(Q64);
return new BigInteger[] { amountA, amountB };
}
private static void validate(RaydiumConcentratedPositionState position,
RaydiumConcentratedPoolState pool) {
if (!position.poolId().equals(pool.poolId()) || position.tickLowerIndex() >= position.tickUpperIndex()
|| position.liquidity().signum() < 0 || position.liquidity().bitLength() > 128
|| pool.sqrtPriceX64().compareTo(sqrtPriceAtTick(-443636)) < 0
|| pool.sqrtPriceX64().compareTo(sqrtPriceAtTick(443636)) >= 0) {
throw new IllegalArgumentException("Invalid or mismatched CLMM state");
}
}
private static final BigInteger Q64 = BigInteger.ONE.shiftLeft(64);
private static final BigInteger MAX_U128 = BigInteger.ONE.shiftLeft(128).subtract(BigInteger.ONE);
private static final BigInteger[] FACTORS = java.util.Arrays.stream(new String[] {
"fffcb933bd6fb800", "fff97272373d4000", "fff2e50f5f657000", "ffe5caca7e10f000",
"ffcb9843d60f7000", "ff973b41fa98e800", "ff2ea16466c9b000", "fe5dee046a9a3800",
"fcbe86c7900bb000", "f987a7253ac65800", "f3392b0822bb6000", "e7159475a2caf000",
"d097f3bdfd2f2000", "a9f746462d9f8000", "70d869a156f31c00", "31be135f97ed3200",
"9aa508b5b85a500", "5d6af8dedc582c", "2216e584f5fa"
}).map(hex -> new BigInteger(hex, 16)).toArray(BigInteger[]::new);
private RaydiumClmmMath() { }
}
@@ -0,0 +1,313 @@
package com.r35157.libs.raydium.impl.ref;
import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
import com.r35157.libs.raydium.*;
import com.r35157.libs.solana.*;
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.*;
/** Narrow decrease_liquidity_v2 encoder; never closes positions or token accounts. */
final class RaydiumClmmWithdrawal {
static final ΩRaydiumProgramIdΩ PROGRAM = "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK";
private static final ΩSolanaProgramIdΩ ATA_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
private static final ΩSolanaProgramIdΩ SYSTEM = "11111111111111111111111111111111";
private static final ΩSolanaProgramIdΩ MEMO = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
private static final Base58CodecImpl BASE58 = new Base58CodecImpl();
private final SolanaBlockChain chain;
RaydiumClmmWithdrawal(SolanaBlockChain chain) { this.chain = chain; }
RaydiumConcentratedPoolInfo poolInfo(ΩRaydiumLiquidityPoolConcentratedIdΩ poolId) throws IOException, InterruptedException {
byte[] data = poolData(poolId);
ΩSPLMintAddressΩ mintA = address(data, 73), mintB = address(data, 105);
int decimalsA = Byte.toUnsignedInt(data[233]), decimalsB = Byte.toUnsignedInt(data[234]);
validateMint(mintA, decimalsA);
validateMint(mintB, decimalsB);
BigDecimal price = new BigDecimal(unsigned(data, 253, 16).pow(2))
.divide(new BigDecimal(BigInteger.ONE.shiftLeft(128)), java.math.MathContext.DECIMAL128)
.scaleByPowerOfTen(decimalsA - decimalsB);
return new RaydiumConcentratedPoolInfo(poolId, mintA, decimalsA, mintB, decimalsB, price);
}
ΩSolanaTransactionSignatureΩ withdraw(SolanaWallet wallet, RaydiumConcentratedPositionState position,
RaydiumConcentratedPoolState quoteState, int slippageBps)
throws IOException, InterruptedException {
if (slippageBps < 0 || slippageBps >= 10000 || position.liquidity().signum() <= 0) {
throw new IllegalArgumentException("Withdrawal needs positive liquidity and slippage in [0,10000)");
}
BigInteger[] expected = RaydiumClmmMath.principalRawAmounts(position, quoteState);
BigInteger[] minimum = Arrays.stream(expected)
.map(amount -> amount.multiply(BigInteger.valueOf(10000 - slippageBps))
.divide(BigInteger.valueOf(10000))).toArray(BigInteger[]::new);
if (minimum[0].signum() == 0 && minimum[1].signum() == 0) {
throw new IllegalArgumentException("Position too small for protected withdrawal");
}
byte[] pool = poolData(position.poolId());
ΩRaydiumLiquidityPoolPositionIdΩ personal = pda("position", SolanaProgramAddressSeed.solanaAddress(position.nftId()));
byte[] personalData = accountData(personal, PROGRAM, "PersonalPositionState", 97);
if (!position.nftId().equals(address(personalData, 9))
|| !position.poolId().equals(address(personalData, 41))
|| position.tickLowerIndex() != integer(personalData, 73)
|| position.tickUpperIndex() != integer(personalData, 77)
|| !position.liquidity().equals(unsigned(personalData, 81, 16))) {
throw new IOException("Position changed or mismatches requested full withdrawal: " + position.nftId());
}
SolanaSPLTokenProgram nftProgram = mintProgram(position.nftId());
SPLTokenHolding nft = chain.getSPLTokenHoldings(wallet.getAddress(), nftProgram).get(position.nftId());
if (nft == null || !BigInteger.ONE.equals(new BigInteger(nft.rawAmount())) || nft.decimals() != 0) {
throw new IOException("Wallet does not own position NFT: " + position.nftId());
}
validateTokenAccount(nft.tokenAccount(), position.nftId(), wallet.getAddress(), nftProgram);
ΩSPLMintAddressΩ mintA = address(pool, 73), mintB = address(pool, 105);
SolanaSPLTokenProgram programA = validateMint(mintA, Byte.toUnsignedInt(pool[233]));
SolanaSPLTokenProgram programB = validateMint(mintB, Byte.toUnsignedInt(pool[234]));
int tickSpacing = Short.toUnsignedInt(ByteBuffer.wrap(pool, 235, 2).order(ByteOrder.LITTLE_ENDIAN).getShort());
if (tickSpacing == 0 || position.tickLowerIndex() % tickSpacing != 0
|| position.tickUpperIndex() % tickSpacing != 0 || (pool[389] & 2) != 0) {
throw new IOException("Invalid tick spacing or disabled liquidity decrease");
}
int lowerStart = Math.floorDiv(position.tickLowerIndex(), 60 * tickSpacing) * 60 * tickSpacing;
int upperStart = Math.floorDiv(position.tickUpperIndex(), 60 * tickSpacing) * 60 * tickSpacing;
ΩSolanaAddressΩ lowerArray = tickArray(position.poolId(), lowerStart);
ΩSolanaAddressΩ upperArray = tickArray(position.poolId(), upperStart);
validateTickArray(lowerArray, position.poolId(), lowerStart);
validateTickArray(upperArray, position.poolId(), upperStart);
validateVault(address(pool, 137), mintA, position.poolId(), programA);
validateVault(address(pool, 169), mintB, position.poolId(), programB);
List<Instruction> instructions = new ArrayList<>();
// Allow the decrease plus idempotent token-account creation in one transaction.
instructions.add(new Instruction("ComputeBudget111111111111111111111111111111", List.of(),
ByteBuffer.allocate(5).order(ByteOrder.LITTLE_ENDIAN).put((byte) 2).putInt(600_000).array()));
ΩSPLTokenAccountΩ recipientA = destination(wallet.getAddress(), mintA, programA, instructions);
ΩSPLTokenAccountΩ recipientB = destination(wallet.getAddress(), mintB, programB, instructions);
ΩRaydiumLiquidityPoolPositionIdΩ protocol = pda("position", SolanaProgramAddressSeed.solanaAddress(position.poolId()),
tickSeed(position.tickLowerIndex()), tickSeed(position.tickUpperIndex()));
List<Meta> accounts = new ArrayList<>(List.of(
new Meta(wallet.getAddress(), true, false), new Meta(nft.tokenAccount(), false, false),
new Meta(personal, false, true), new Meta(position.poolId(), false, true),
new Meta(protocol, false, false), new Meta(address(pool, 137), false, true),
new Meta(address(pool, 169), false, true), new Meta(lowerArray, false, true),
new Meta(upperArray, false, true), new Meta(recipientA, false, true),
new Meta(recipientB, false, true),
new Meta(SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM.getAddress(), false, false),
new Meta(SolanaSPLTokenProgram.TOKEN_2022_PROGRAM.getAddress(), false, false),
new Meta(MEMO, false, false), new Meta(mintA, false, false), new Meta(mintB, false, false)));
// Bitmap extension is only required outside the pool's default 1024 arrays.
if (lowerStart < -512 * 60 * tickSpacing || upperStart >= 512 * 60 * tickSpacing) {
ΩSolanaAddressΩ bitmap = pda("pool_tick_array_bitmap_extension",
SolanaProgramAddressSeed.solanaAddress(position.poolId()));
byte[] bitmapData = accountData(bitmap, PROGRAM, "TickArrayBitmapExtension", 40);
if (!position.poolId().equals(address(bitmapData, 8))) throw new IOException("Wrong bitmap pool");
accounts.add(new Meta(bitmap, false, true));
}
// The official instruction requires reward triplets for all initialized rewards.
// They are collected to this wallet only, never exchanged or paid out by Evelyn.
if ((pool[389] & 8) == 0) {
for (int reward = 0; reward < 3; reward++) {
int offset = 397 + reward * 169;
ΩSPLMintAddressΩ mint = address(pool, offset + 57);
if (mint.equals(SYSTEM)) continue;
ΩSPLTokenAccountΩ vault = address(pool, offset + 89);
SolanaSPLTokenProgram program = mintProgram(mint);
validateVault(vault, mint, position.poolId(), program);
ΩSPLTokenAccountΩ recipient = destination(wallet.getAddress(), mint, program, instructions);
accounts.add(new Meta(vault, false, true));
accounts.add(new Meta(recipient, false, true));
accounts.add(new Meta(mint, false, false));
}
}
ByteArrayOutputStream data = new ByteArrayOutputStream();
data.writeBytes(discriminator("global:decrease_liquidity_v2"));
data.writeBytes(littleEndian(position.liquidity(), 16));
data.writeBytes(littleEndian(minimum[0], 8));
data.writeBytes(littleEndian(minimum[1], 8));
instructions.add(new Instruction(PROGRAM, accounts, data.toByteArray()));
checkInterrupted();
ΩSolanaTransactionSignatureΩ signature = wallet.signAndSendTransaction(serialize(wallet.getAddress(), instructions));
org.slf4j.LoggerFactory.getLogger(RaydiumClmmWithdrawal.class).info(
"withdraw position={} liquidity={} minimumA={} minimumB={} signature={}",
position.nftId(), position.liquidity(), minimum[0], minimum[1], signature);
SolanaTransactionOutcome outcome = chain.awaitTransaction(signature, SolanaCommitment.CONFIRMED, Duration.ofSeconds(60));
if (outcome.status() != SolanaTransactionOutcome.Status.SUCCEEDED) {
throw new IOException("CLMM withdrawal signature=" + signature + " outcome=" + outcome);
}
return signature;
}
private byte[] poolData(ΩRaydiumLiquidityPoolConcentratedIdΩ poolId) throws IOException, InterruptedException {
return accountData(poolId, PROGRAM, "PoolState", 904);
}
private byte[] accountData(ΩSolanaAddressΩ address, ΩSolanaProgramIdΩ owner, String type, int minimumLength)
throws IOException, InterruptedException {
SolanaAccountInfo info = chain.getAccountInfo(address);
if (info == null || !owner.equals(info.owner())) throw new IOException("Missing/wrong owner for " + type + " " + address);
byte[] data = decode(info);
if (data.length < minimumLength || !Arrays.equals(Arrays.copyOf(data, 8), discriminator("account:" + type))) {
throw new IOException("Invalid " + type + " account layout: " + address);
}
return data;
}
private SolanaSPLTokenProgram mintProgram(ΩSPLMintAddressΩ mint) throws IOException, InterruptedException {
SolanaAccountInfo info = chain.getAccountInfo(mint);
if (info != null) for (SolanaSPLTokenProgram program : SolanaSPLTokenProgram.values()) {
if (program.getAddress().equals(info.owner())) return program;
}
throw new IOException("Missing or unsupported mint: " + mint);
}
private SolanaSPLTokenProgram validateMint(ΩSPLMintAddressΩ mint, ΩamountDecimalsΩ decimals) throws IOException, InterruptedException {
SolanaSPLTokenProgram program = mintProgram(mint);
byte[] data = decode(chain.getAccountInfo(mint));
if (data.length < 82 || data[45] != 1 || Byte.toUnsignedInt(data[44]) != decimals) {
throw new IOException("Mint precision/initialization mismatch: " + mint);
}
if (program == SolanaSPLTokenProgram.TOKEN_2022_PROGRAM && data.length > 82) {
if (data.length < 166 || data[165] != 1) throw new IOException("Invalid Token-2022 mint layout");
for (int offset = 166; offset + 4 <= data.length;) {
int type = Short.toUnsignedInt(ByteBuffer.wrap(data, offset, 2).order(ByteOrder.LITTLE_ENDIAN).getShort());
int size = Short.toUnsignedInt(ByteBuffer.wrap(data, offset + 2, 2).order(ByteOrder.LITTLE_ENDIAN).getShort());
if (type == 0 && size == 0) break;
if (offset + 4 + size > data.length || !Set.of(3, 18, 19, 20, 21, 22, 23).contains(type)) {
throw new IOException("Unsupported transfer-affecting mint extension " + type + " on " + mint);
}
offset += 4 + size;
}
}
return program;
}
private void validateTickArray(ΩSolanaAddressΩ account, ΩRaydiumLiquidityPoolConcentratedIdΩ pool, ΩraydiumLiquidityTickIndexΩ start) throws IOException, InterruptedException {
byte[] data = accountData(account, PROGRAM, "TickArrayState", 44);
if (!pool.equals(address(data, 8)) || integer(data, 40) != start) throw new IOException("Invalid tick array");
}
private void validateVault(ΩSPLTokenAccountΩ account, ΩSPLMintAddressΩ mint, ΩSolanaAddressΩ owner, SolanaSPLTokenProgram program)
throws IOException, InterruptedException { validateTokenAccount(account, mint, owner, program); }
private void validateTokenAccount(ΩSPLTokenAccountΩ account, ΩSPLMintAddressΩ mint, ΩSolanaAddressΩ owner, SolanaSPLTokenProgram program)
throws IOException, InterruptedException {
SolanaAccountInfo info = chain.getAccountInfo(account);
if (info == null || !program.getAddress().equals(info.owner())) throw new IOException("Wrong token account program");
byte[] data = decode(info);
if (data.length < 165 || !mint.equals(address(data, 0)) || !owner.equals(address(data, 32)) || data[108] != 1) {
throw new IOException("Invalid token account: " + account);
}
}
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),
new Meta(ata, false, true), new Meta(wallet, false, false), new Meta(mint, false, false),
new Meta(SYSTEM, false, false), new Meta(program.getAddress(), false, false)), new byte[] {1}));
} else {
validateTokenAccount(ata, mint, wallet, program);
}
return ata;
}
private ΩSolanaAddressΩ tickArray(ΩRaydiumLiquidityPoolConcentratedIdΩ pool, ΩraydiumLiquidityTickIndexΩ start) {
return pda("tick_array", SolanaProgramAddressSeed.solanaAddress(pool), tickSeed(start));
}
private static SolanaProgramAddressSeed tickSeed(ΩraydiumLiquidityTickIndexΩ tick) {
return SolanaProgramAddressSeed.hex(HexFormat.of().formatHex(ByteBuffer.allocate(4).putInt(tick).array()));
}
private ΩSolanaAddressΩ pda(String seed, SolanaProgramAddressSeed... rest) {
List<SolanaProgramAddressSeed> seeds = new ArrayList<>();
seeds.add(SolanaProgramAddressSeed.utf8(seed)); seeds.addAll(List.of(rest));
return chain.findProgramAddress(PROGRAM, seeds).address();
}
private SolanaUnsignedTransaction serialize(ΩSolanaWalletIdΩ payer, List<Instruction> instructions)
throws IOException, InterruptedException {
Map<String, Meta> catalogue = new LinkedHashMap<>();
catalogue.put(payer, new Meta(payer, true, true));
for (Instruction instruction : instructions) {
for (Meta meta : instruction.accounts()) catalogue.merge(meta.address(), meta,
(a,b) -> new Meta(a.address(), a.signer() || b.signer(), a.writable() || b.writable()));
catalogue.putIfAbsent(instruction.program(), new Meta(instruction.program(), false, false));
}
List<Meta> keys = new ArrayList<>(catalogue.values());
keys.sort(Comparator.comparingInt(meta -> meta.signer() ? (meta.writable() ? 0 : 1) : (meta.writable() ? 2 : 3)));
if (keys.stream().filter(Meta::signer).count() != 1 || keys.size() > 256) throw new IOException("Unsupported transaction accounts");
SolanaLatestBlockhash blockhash = chain.getLatestBlockhash();
ByteArrayOutputStream message = new ByteArrayOutputStream();
message.write(1); message.write(0);
message.write((int) keys.stream().filter(meta -> !meta.signer() && !meta.writable()).count());
compact(message, keys.size());
for (Meta meta : keys) message.writeBytes(addressBytes(meta.address()));
message.writeBytes(addressBytes(blockhash.blockhash()));
compact(message, instructions.size());
for (Instruction instruction : instructions) {
message.write(index(keys, instruction.program()));
compact(message, instruction.accounts().size());
for (Meta meta : instruction.accounts()) message.write(index(keys, meta.address()));
compact(message, instruction.data().length); message.writeBytes(instruction.data());
}
ByteArrayOutputStream transaction = new ByteArrayOutputStream();
transaction.write(1); transaction.writeBytes(new byte[64]); transaction.writeBytes(message.toByteArray());
if (transaction.size() > 1232) throw new IOException("CLMM transaction exceeds Solana packet limit");
return new SolanaUnsignedTransaction(Base64.getEncoder().encodeToString(transaction.toByteArray()),
blockhash.blockhash(), blockhash.lastValidBlockHeight());
}
private static int index(List<Meta> keys, ΩSolanaAddressΩ address) {
for (int i = 0; i < keys.size(); i++) if (keys.get(i).address().equals(address)) return i;
throw new IllegalArgumentException("Missing transaction account");
}
private static void compact(ByteArrayOutputStream stream, int value) {
do { int next = value & 127; value >>>= 7; stream.write(next | (value > 0 ? 128 : 0)); } while (value > 0);
}
private static byte[] addressBytes(ΩSolanaAddressΩ address) {
byte[] bytes = BASE58.decode(address);
if (bytes.length != 32) throw new IllegalArgumentException("Solana address must decode to 32 bytes");
return bytes;
}
private ΩSolanaAddressΩ address(byte[] data, int offset) { return chain.encodeSolanaAddress(Arrays.copyOfRange(data, offset, offset + 32)); }
private static int integer(byte[] data, int offset) { return ByteBuffer.wrap(data, offset, 4).order(ByteOrder.LITTLE_ENDIAN).getInt(); }
private static BigInteger unsigned(byte[] data, int offset, int length) {
byte[] reversed = new byte[length];
for (int i = 0; i < length; i++) reversed[length - 1 - i] = data[offset + i];
return new BigInteger(1, reversed);
}
private static byte[] littleEndian(BigInteger value, int length) {
if (value.signum() < 0 || value.bitLength() > length * 8) throw new IllegalArgumentException("Unsigned amount overflow");
byte[] result = new byte[length];
for (int i = 0; i < length; i++) result[i] = value.shiftRight(i * 8).byteValue();
return result;
}
private static byte[] decode(SolanaAccountInfo info) throws IOException {
try { return Base64.getDecoder().decode(info.data()); }
catch (RuntimeException exception) { throw new IOException("Invalid account data", exception); }
}
private static byte[] discriminator(String name) {
try { return Arrays.copyOf(MessageDigest.getInstance("SHA-256").digest(name.getBytes(StandardCharsets.UTF_8)), 8); }
catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
private static void checkInterrupted() throws InterruptedException {
if (Thread.currentThread().isInterrupted()) throw new InterruptedException("CLMM withdrawal interrupted");
}
private record Meta(ΩSolanaAddressΩ address, boolean signer, boolean writable) { }
private record Instruction(ΩSolanaProgramIdΩ program, List<Meta> accounts, byte[] data) { }
}
@@ -64,6 +64,20 @@ public class RaydiumImpl implements Raydium {
);
}
@Override
public RaydiumConcentratedPoolInfo fetchConcentratedPoolInfoOnChain(ΩRaydiumLiquidityPoolConcentratedIdΩ poolId)
throws IOException, InterruptedException {
return new RaydiumClmmWithdrawal(solanaBlockChain).poolInfo(poolId);
}
@Override
public ΩSolanaTransactionSignatureΩ withdrawConcentratedPositionLiquidity(
com.r35157.cryptowallet.solana.SolanaWallet wallet,
RaydiumConcentratedPositionState position, RaydiumConcentratedPoolState quoteState,
int maximumSlippageBps) throws IOException, InterruptedException {
return new RaydiumClmmWithdrawal(solanaBlockChain).withdraw(wallet, position, quoteState, maximumSlippageBps);
}
@Override
public AssetPrice fetchPoolPrice(ΩRaydiumLiquidityPoolIdΩ raydiumLiquidityPoolId) throws IOException, InterruptedException {
ΩRestEndpointΩ endpoint = createPoolInfoByIdEndpoint(raydiumLiquidityPoolId);
@@ -102,19 +116,22 @@ public class RaydiumImpl implements Raydium {
public Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> fetchConcentratedPositionNftIds(
ΩSolanaAddressΩ ownerAddress
) throws IOException, InterruptedException {
Set<ΩSolanaNFTAddressΩ> candidates;
Set<ΩSolanaNFTAddressΩ> allCandidates = new HashSet<>();
boolean discoveredAnyProgram = false;
for (var program : List.of(SPL_TOKEN_PROGRAM, TOKEN_2022_PROGRAM)) {
try {
allCandidates.addAll(solanaBlockChain.getSolanaNFTCandidateAddresses(ownerAddress, program));
discoveredAnyProgram = true;
} catch (IOException | RuntimeException exception) {
org.slf4j.LoggerFactory.getLogger(RaydiumImpl.class).warn(
"Could not discover CLMM NFT candidates for owner={} program={}; continuing independent discovery",
ownerAddress, program, exception);
}
}
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);
if (!discoveredAnyProgram) {
throw new IOException("Could not discover NFT holdings under either Solana token program for " + ownerAddress);
}
Set<ΩRaydiumLiquidityPoolPositionNftIdΩ> ids = filterCandidatePositionNftIds(allCandidates);
return ids;
}
@@ -636,18 +653,16 @@ public class RaydiumImpl implements Raydium {
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);
try {
SolanaProgramDerivedAddress positionPda = findRaydiumPositionPda(candidatePositionNftId);
SolanaAccountInfo accountInfo = solanaBlockChain.getAccountInfo(positionPda.address());
if (accountInfo != null && RAYDIUM_CLMM_PROGRAM_ID.equals(accountInfo.owner())) {
positionNftIds.add(candidatePositionNftId);
}
} catch (IOException | RuntimeException exception) {
org.slf4j.LoggerFactory.getLogger(RaydiumImpl.class).warn(
"Could not inspect CLMM position candidate {}; continuing discovery",
candidatePositionNftId, exception);
}
}
@@ -19,6 +19,11 @@ public record SolanaProgramAddressSeed(
SolanaProgramAddressSeedKind kind,
String value
) {
/** Creates a binary seed; needed for CLMM big-endian signed tick indexes. */
public static SolanaProgramAddressSeed hex(String value) {
return new SolanaProgramAddressSeed(SolanaProgramAddressSeedKind.HEX, value);
}
/**
* Creates a UTF-8 seed.
*
@@ -13,6 +13,9 @@ public enum SolanaProgramAddressSeedKind {
*/
UTF8,
/** Exact binary seed represented by hexadecimal text. */
HEX,
/**
* A seed that represents a Solana address and should be decoded from its
* textual Solana address representation to raw address bytes.
@@ -1741,6 +1741,8 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
private byte[] toSeedBytes(SolanaProgramAddressSeed seed) {
return switch (seed.kind()) {
case HEX -> java.util.HexFormat.of().parseHex(seed.value());
case UTF8 -> seed.value().getBytes(StandardCharsets.UTF_8);
case SOLANA_ADDRESS -> {
@@ -3,6 +3,8 @@ package com.r35157.nenjim.hubd.impl.ref;
import com.r35157.assetaz.services.journal.impl.ref.AssetAZJournalServiceImpl;
import com.r35157.assetaz.services.journal.codec.impl.ref.AssetOwnerRegisteredCodec;
import com.r35157.assetaz.services.journal.codec.impl.ref.JournalFormatVersionChangedCodec;
import com.r35157.evelyn.service.liquidity.impl.ref.EvelynLiquidityConfiguration;
import com.r35157.evelyn.service.liquidity.impl.ref.EvelynLiquidityServiceImpl;
import com.r35157.evelyn.service.status.EvelynStatusService;
import com.r35157.evelyn.service.status.impl.ref.EvelynStatusServiceImpl;
import com.fanitas.evelyn.service.burner.EvelynIOUBurnerService;
@@ -326,6 +328,20 @@ public final class NenjimHubImpl implements NenjimHub {
return process;
}
private NenjimProcess constructEvelynLiquidityProdProcess() throws Exception {
EvelynLiquidityConfiguration liquidityConfiguration = EvelynLiquidityConfiguration.parse(
Path.of("conf/evelyn-liquidity.conf"));
SolanaWallet liquidityWallet = new SolanaWalletImpl(liquidityConfiguration.walletAddress(),
liquidityConfiguration.signerKeyName(), solanaBlockChain);
JupiterSwapService liquiditySwap = new JupiterSwapServiceImpl(solanaBlockChain, liquidityWallet);
Raydium liquidityRaydium = new RaydiumImpl(httpClient, objectMapper, solanaBlockChain, cis);
registryServiceManager.registerComponent(new NenjimComponentId("solana.wallet.evelyn-liquidity-prod"), liquidityWallet);
registryServiceManager.registerComponent(new NenjimComponentId("jupiter.swap.evelyn-liquidity-prod"), liquiditySwap);
registryServiceManager.registerComponent(new NenjimComponentId("raydium.service.evelyn-liquidity-prod"), liquidityRaydium);
return new EvelynLiquidityServiceImpl(liquidityWallet, liquiditySwap, liquidityRaydium,
solanaBlockChain, cis, liquidityConfiguration);
}
private NenjimProcess constructJavaFxRuntimeProcess() {
JavaFxRuntimeServiceImpl process = new JavaFxRuntimeServiceImpl();
javaFxRuntimeService = process;
@@ -387,6 +403,7 @@ public final class NenjimHubImpl implements NenjimHub {
Map.entry(JUPITER_PERPS_ALARM_ID, this::constructJupiterPerpsAlarmProcess),
Map.entry(EVELYN_STATUS_PROD_ID, this::constructEvelynStatusProdProcess),
Map.entry(EVELYN_STATUS_TEST_ID, this::constructEvelynStatusTestProcess),
Map.entry(EVELYN_LIQUIDITY_PROD_ID, this::constructEvelynLiquidityProdProcess),
Map.entry(EVELYN_IOU_BURNER_PROD_ID, this::constructEvelynIOUBurnerProdProcess),
Map.entry(JAVAFX_RUNTIME_SERVICE_ID, this::constructJavaFxRuntimeProcess),
Map.entry(EVELYN_MISSION_CONTROL_ID, this::constructMissionControlProcess),
@@ -501,6 +518,8 @@ public final class NenjimHubImpl implements NenjimHub {
new NenjimComponentId("notification.discord.assetaz-token");
private static final NenjimComponentId EVELYN_IOU_BURNER_PROD_ID =
new NenjimComponentId("evelyn.iou-burner.prod");
private static final NenjimComponentId EVELYN_LIQUIDITY_PROD_ID =
new NenjimComponentId("evelyn.liquidity.prod");
private static final NenjimComponentId EVELYN_MISSION_CONTROL_ID =
new NenjimComponentId("evelyn.mission-control.default");
private static final NenjimComponentId JAVAFX_RUNTIME_SERVICE_ID =
@@ -0,0 +1,329 @@
package com.r35157.evelyn.service.liquidity.impl.ref;
import com.r35157.assetaz.services.cis.impl.hc.HardcodedCurrencyIdentityService;
import com.r35157.cryptowallet.solana.*;
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
import com.r35157.libs.jupiter.swap.*;
import com.r35157.libs.raydium.*;
import com.r35157.libs.solana.*;
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.lang.reflect.*;
import java.math.*;
import java.nio.file.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.*;
import static org.junit.jupiter.api.Assertions.*;
class EvelynLiquidityServiceImplTest {
@Test void harvestsOwnedPoolOnlyThenSwapsAndPaysWithConfiguredReserves() throws Exception {
Fixture fixture = new Fixture();
fixture.positions.put("other-pool", new RaydiumConcentratedPositionState("other-pool", address(9), -100, 0, BigInteger.ONE));
fixture.positions.put("empty", new RaydiumConcentratedPositionState("empty", POOL, -100, 0, BigInteger.ZERO));
fixture.positions.put("above", new RaydiumConcentratedPositionState("above", POOL, -100, 1, BigInteger.ONE));
fixture.positions.put("foreign-nft", new RaydiumConcentratedPositionState("unowned", POOL, -100, 0, BigInteger.ONE));
fixture.balances.put(SYRUP, new BigDecimal("1.01"));
fixture.balances.put(USDC, new BigDecimal("2.5"));
try (var service = fixture.service("0.01", "0.5", "0")) {
service.initialize(); service.runIteration();
assertEquals(List.of("withdraw:owned", "swap:1.000000", "send:2.000000"), fixture.operations);
assertEquals(RECIPIENT, fixture.recipient);
assertEquals(MissingRecipientTokenAccountPolicy.CREATE_AT_SENDER_EXPENSE, fixture.creationPolicy);
assertEquals(100, fixture.swapSlippage);
assertEquals(100, fixture.withdrawSlippage);
assertSame(fixture.wallet, fixture.withdrawWallet);
assertEquals(BigInteger.valueOf(1_000_000), fixture.withdrawPosition.liquidity());
assertEquals(List.of(SYRUP, USDC), fixture.balanceReads);
}
}
@Test void eachStepReadsProceedsProducedByThePreviousStepAndKeepsReserves() throws Exception {
Fixture fixture=new Fixture();fixture.creditProceeds=true;fixture.creditSwap=true;
try(var service=fixture.service("0.04","0.5","0")) {
service.initialize();service.runIteration();
assertEquals(List.of("withdraw:owned","swap:3.000000","send:2.000000"),fixture.operations);
assertEquals(0,new BigDecimal("0.04").compareTo(fixture.balances.get(SYRUP)));
assertEquals(0,new BigDecimal("0.5").compareTo(fixture.balances.get(USDC)));
assertEquals(List.of(SYRUP,USDC),fixture.balanceReads);
}
}
@Test void eachFailureLeavesIndependentStepsUsableAndNextIterationRereadsState() throws Exception {
Fixture fixture = new Fixture();
fixture.positions.put("unreadable", fixture.positions.get("owned"));
fixture.unreadable = "unreadable";
fixture.withdrawFails = true; fixture.swapFails = true;
fixture.balances.put(SYRUP, BigDecimal.ONE); fixture.balances.put(USDC, new BigDecimal("2"));
try (var service = fixture.service("0", "0", "0")) {
service.initialize(); service.runIteration();
assertEquals(List.of("withdraw:owned", "swap:1.000000", "send:2.000000"), fixture.operations);
fixture.withdrawFails = false; fixture.swapFails = false;
service.runIteration();
assertEquals(List.of("withdraw:owned", "swap:1.000000", "send:2.000000", "withdraw:owned", "swap:1.000000"), fixture.operations);
}
fixture.operations.clear();
try (var restarted = fixture.service("0", "0", "0")) {
restarted.initialize(); restarted.runIteration(); assertTrue(fixture.operations.isEmpty());
}
}
@Test void unusablePriceStillSwapsAndPaysAndPayoutFailureCanRecover() throws Exception {
Fixture fixture = new Fixture(); fixture.priceFails = true; fixture.sendFails = true;
fixture.balances.put(SYRUP, BigDecimal.ONE); fixture.balances.put(USDC, BigDecimal.ONE);
try (var service = fixture.service("0", "0", "0")) {
service.initialize(); service.runIteration();
assertEquals(List.of("swap:1.000000", "send:1.000000"), fixture.operations);
fixture.sendFails = false; service.runIteration();
assertEquals(List.of("swap:1.000000", "send:1.000000", "send:1.000000"), fixture.operations);
}
}
@Test void missingZeroAndAtReserveBalancesAreSkippedAndAmountsNeverRoundUp() throws Exception {
for (BigDecimal balance : Arrays.asList(null, BigDecimal.ZERO, new BigDecimal("0.5"))) {
Fixture fixture = new Fixture(); fixture.positions.clear();
if (balance != null) { fixture.balances.put(SYRUP, balance); fixture.balances.put(USDC, balance); }
try (var service = fixture.service("0.5", "0.5", "0")) {
service.initialize(); service.runIteration(); assertTrue(fixture.operations.isEmpty());
}
}
Fixture fixture = new Fixture(); fixture.positions.clear();
fixture.balances.put(SYRUP, new BigDecimal("1.0000009")); fixture.balances.put(USDC, new BigDecimal("2.0000009"));
try (var service = fixture.service("0", "0", "0")) {
service.initialize(); service.runIteration();
assertEquals(List.of("swap:1.000000", "send:2.000000"), fixture.operations);
}
}
@Test void reversedPoolAndMarketDeclineUseCorrectBoundaryWithoutRefilling() throws Exception {
Fixture fixture = new Fixture(); fixture.reversed = true;
fixture.sqrt = RaydiumClmmMath.sqrtPriceAtTick(-100);
try (var service = fixture.service("0", "0", "0")) {
service.initialize(); service.runIteration(); assertEquals(List.of("withdraw:owned"), fixture.operations);
}
fixture = new Fixture(); fixture.sqrt = RaydiumClmmMath.sqrtPriceAtTick(-50);
try (var service = fixture.service("0", "0", "0")) {
service.initialize(); service.runIteration(); assertTrue(fixture.operations.isEmpty());
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")) {
assertThrows(IllegalArgumentException.class, service::start); assertTrue(fixture.operations.isEmpty());
}
fixture.wrongPool = true;
try (var service = fixture.service("0", "0", "10")) {
assertThrows(IllegalArgumentException.class, service::start); assertTrue(fixture.operations.isEmpty());
}
Fixture mismatch = new Fixture(); mismatch.walletAddress = RECIPIENT;
assertThrows(IllegalArgumentException.class, () -> mismatch.service("0", "0", "10"));
}
@Test void registeredServiceExposesDomainAndProcessViewsWithoutStarting() throws Exception {
Fixture fixture = new Fixture();
try (var service=fixture.service("0","0","0")) {
var assembly=com.r35157.nenjim.service.registry.impl.ref.NenjimRegistryAssembly.create();
assembly.registryProcess().start();
var id=new com.r35157.nenjim.service.registry.valuetypes.NenjimComponentId("evelyn.liquidity.prod");
assembly.registryServiceManager().registerComponent(id,service);
assertSame(service,assembly.registryService().getComponent(id,com.r35157.evelyn.service.liquidity.EvelynLiquidityService.class));
assertSame(service,assembly.registryService().getComponent(id,com.r35157.nenjim.process.NenjimProcess.class));
assertEquals(0,com.r35157.evelyn.service.liquidity.EvelynLiquidityService.class.getDeclaredMethods().length);
assertTrue(fixture.operations.isEmpty());
}
}
@Test void interruptionStopsFollowingSteps() throws Exception {
Fixture fixture = new Fixture(); fixture.interruptWithdraw = true;
fixture.balances.put(SYRUP, BigDecimal.ONE); fixture.balances.put(USDC, BigDecimal.ONE);
try (var service = fixture.service("0", "0", "0")) {
service.initialize(); assertThrows(InterruptedException.class, service::runIteration);
assertEquals(List.of("withdraw:owned"), fixture.operations);
}
}
@Test void workerDoesNotOverlapAndShutdownInterruptsBlockingIteration() throws Exception {
Fixture fixture = new Fixture(); fixture.blockWithdraw = true;
var service = fixture.service("0", "0", "0", Duration.ofMillis(1));
try {
service.start(); assertTrue(fixture.entered.await(5, TimeUnit.SECONDS));
assertFalse(fixture.secondEntered.await(100, TimeUnit.MILLISECONDS));
assertThrows(IllegalStateException.class, service::start);
service.close(); assertTrue(fixture.interrupted.await(5, TimeUnit.SECONDS));
assertEquals(1, fixture.withdrawCount.get());
} finally { service.close(); }
}
@Test void strictVersionedParserAndZeroReserveExample(@TempDir Path directory) throws Exception {
Path example = Path.of("conf/evelyn-liquidity.conf.example");
String text = Files.readString(example);
assertTrue(text.startsWith("FORMAT_VERSION=1\n"));
assertTrue(text.contains("MINIMUM_SYRUPUSDC_RESERVE=0\n"));
assertTrue(text.contains("MINIMUM_USDC_RESERVE=0\n"));
Path path = directory.resolve("evelyn-liquidity.conf");
assertThrows(IOException.class, () -> EvelynLiquidityConfiguration.parse(path));
String valid = text.replace("REPLACE_WITH_DEDICATED_SOLANA_WALLET", OWNER)
.replace("REPLACE_WITH_EXTERNAL_JUP_SIGNER", "liquidity")
.replace("REPLACE_WITH_EXISTING_EVE_SYRUPUSDC_CLMM_POOL", POOL)
.replace("REPLACE_WITH_RECIPIENT_WALLET_OWNER", RECIPIENT);
Files.writeString(path, valid);
assertEquals(BigDecimal.ZERO, EvelynLiquidityConfiguration.parse(path).minimumSyrupUSDCReserve());
for (String invalid : List.of(valid.replace("FORMAT_VERSION=1", "FORMAT_VERSION=2"), valid + "UNKNOWN=1\n",
valid + "MINIMUM_USDC_RESERVE=0\n", valid.replace("HARVEST_DISTANCE_PERCENT=10", "HARVEST_DISTANCE_PERCENT=100"),
valid.replace("ITERATION_INTERVAL_SECONDS=300", "ITERATION_INTERVAL_SECONDS=0"),
valid.replace("MINIMUM_USDC_RESERVE=0", "MINIMUM_USDC_RESERVE=-1"),
valid.replace("MAXIMUM_WITHDRAWAL_SLIPPAGE_BPS=100", "MAXIMUM_WITHDRAWAL_SLIPPAGE_BPS=10000"),
valid.replace("MAXIMUM_SWAP_SLIPPAGE_BPS=100", "MAXIMUM_SWAP_SLIPPAGE_BPS=10001"),
valid.replace(RECIPIENT, "invalid-address"))) {
Files.writeString(path, invalid); assertThrows(IllegalArgumentException.class, () -> EvelynLiquidityConfiguration.parse(path));
}
}
private static final class Fixture {
final Map<String, RaydiumConcentratedPositionState> positions = new LinkedHashMap<>();
final Map<String, BigDecimal> balances = new HashMap<>();
final List<String> operations = new CopyOnWriteArrayList<>(), balanceReads = new ArrayList<>();
final CountDownLatch entered = new CountDownLatch(1), secondEntered = new CountDownLatch(1), interrupted = new CountDownLatch(1);
final AtomicInteger withdrawCount = new AtomicInteger();
BigInteger sqrt = BigInteger.ONE.shiftLeft(64);
boolean reversed, wrongPool, priceFails, withdrawFails, swapFails, sendFails, interruptWithdraw, blockWithdraw, creditProceeds, creditSwap;
String unreadable, recipient, walletAddress = OWNER;
int swapSlippage, withdrawSlippage;
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]); }
case "sendSPLToken" -> {
operations.add("send:" + args[2]); recipient = (String) args[1]; creationPolicy = args[3];
if (sendFails) throw new IOException("fixture payout failure");
balances.put(USDC, balances.get(USDC).subtract((BigDecimal) args[2])); yield "payout-signature";
}
default -> throw new AssertionError("Unexpected wallet operation: " + method);
});
final JupiterSwapService swap = proxy(JupiterSwapService.class, (method, args) -> {
assertEquals("swap", method); assertEquals(SYRUP, args[0]); assertEquals(USDC, args[2]);
operations.add("swap:" + args[1]); swapSlippage = (int) args[3];
if (swapFails) throw new IOException("fixture swap failure");
balances.put(SYRUP, balances.get(SYRUP).subtract((BigDecimal) args[1]));
if(creditSwap) balances.merge(USDC,new BigDecimal("2.5"),BigDecimal::add);
return new JupiterSwapResult("swap-signature", (BigDecimal) args[1], creditSwap ? new BigDecimal("2.5") : BigDecimal.ONE);
});
final SolanaBlockChain chain = proxy(SolanaBlockChain.class, (method, args) -> switch (method) {
case "getAccountInfo" -> {
if (RECIPIENT.equals(args[0])) yield null;
byte[] data = new byte[82]; data[44] = (byte) (EVE.equals(args[0]) ? 8 : 6); data[45] = 1;
yield new SolanaAccountInfo((String) args[0], SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM.getAddress(), Base64.getEncoder().encodeToString(data));
}
case "awaitTransaction" -> new SolanaTransactionOutcome(SolanaTransactionOutcome.Status.SUCCEEDED, 1L, null);
default -> throw new AssertionError("Unexpected chain operation: " + 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 "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]); 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) {
if (withdrawCount.incrementAndGet() > 1) secondEntered.countDown(); entered.countDown();
try { new CountDownLatch(1).await(); }
catch (InterruptedException exception) { interrupted.countDown(); throw exception; }
}
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);
});
Fixture() { positions.put("owned", new RaydiumConcentratedPositionState("owned", POOL, -100, 0, BigInteger.valueOf(1_000_000))); }
EvelynLiquidityServiceImpl service(String syrupReserve, String usdcReserve, String distance) {
return service(syrupReserve, usdcReserve, distance, Duration.ofSeconds(300));
}
EvelynLiquidityServiceImpl service(String syrupReserve, String usdcReserve, String distance, Duration interval) {
return new EvelynLiquidityServiceImpl(wallet, swap, raydium, chain, CIS, new EvelynLiquidityConfiguration(OWNER, "liquidity", POOL,
new BigDecimal(distance), interval, RECIPIENT, new BigDecimal(syrupReserve), new BigDecimal(usdcReserve), 100, 100));
}
}
@FunctionalInterface private interface Handler { Object invoke(String method, Object[] args) throws Throwable; }
@SuppressWarnings("unchecked") private static <T> T proxy(Class<T> type, Handler handler) {
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] {type}, (object, method, args) -> handler.invoke(method.getName(), args));
}
private static String address(int number) { byte[] data = new byte[32]; data[31] = (byte) number; return new Base58CodecImpl().encode(data); }
private static final HardcodedCurrencyIdentityService CIS = new HardcodedCurrencyIdentityService();
private static String mint(UUID id) { return CIS.findExternalReferences(id).iterator().next().externalId(); }
private static final String EVE = mint(EVE_ID), SYRUP = mint(SYRUPUSDC_ID), USDC = mint(USDC_ID);
private static final String OWNER = address(1), POOL = address(2), RECIPIENT = address(3);
}
@@ -0,0 +1,58 @@
package com.r35157.libs.raydium;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.math.BigInteger;
import static org.junit.jupiter.api.Assertions.*;
class RaydiumClmmMathTest {
@Test void matchesOfficialFixedPointExtremaAndTickZero() {
assertEquals(new BigInteger("4295048016"), RaydiumClmmMath.sqrtPriceAtTick(-443636));
assertEquals(new BigInteger("79226673521066979257578248091"), RaydiumClmmMath.sqrtPriceAtTick(443636));
assertEquals(BigInteger.ONE.shiftLeft(64), RaydiumClmmMath.sqrtPriceAtTick(0));
assertThrows(IllegalArgumentException.class, () -> RaydiumClmmMath.sqrtPriceAtTick(443637));
}
@Test void exactEqualityAndOneUnitAboveDoNotDependOnDisplayRounding() {
var position = position(-100, 100);
var boundary = RaydiumClmmMath.sqrtPriceAtTick(100);
assertTrue(eligible(position, boundary, true, "0"));
assertFalse(eligible(position, boundary.subtract(BigInteger.ONE), true, "0"));
// Lower boundary qualifies but upper boundary does not.
assertFalse(eligible(position, RaydiumClmmMath.sqrtPriceAtTick(0), true, "0"));
assertFalse(eligible(position, boundary, true, "10"));
assertTrue(eligible(position, RaydiumClmmMath.sqrtPriceAtTick(1200), true, "10"));
// Exactly representable equality with a nonzero configured harvest distance.
BigInteger q64=BigInteger.ONE.shiftLeft(64);
assertTrue(eligible(position(-100,0),q64.multiply(BigInteger.TWO),true,"75"));
assertFalse(eligible(position(-100,0),q64.multiply(BigInteger.TWO).subtract(BigInteger.ONE),true,"75"));
assertTrue(eligible(position(0,100),q64.divide(BigInteger.TWO),false,"75"));
assertFalse(eligible(position(0,100),q64.divide(BigInteger.TWO).add(BigInteger.ONE),false,"75"));
}
@Test void inversionUsesRawLowerBoundaryAsNormalizedUpperBoundary() {
var position = position(-100, 100);
var boundary = RaydiumClmmMath.sqrtPriceAtTick(-100);
assertTrue(eligible(position, boundary, false, "0"));
assertFalse(eligible(position, boundary.add(BigInteger.ONE), false, "0"));
assertFalse(eligible(position, RaydiumClmmMath.sqrtPriceAtTick(0), false, "0"));
assertTrue(eligible(position, RaydiumClmmMath.sqrtPriceAtTick(-1200), false, "10"));
}
@Test void principalIsFlooredAndOneSidedOutsideTheRange() {
var position = position(-100, 100);
var above = RaydiumClmmMath.principalRawAmounts(position, pool(RaydiumClmmMath.sqrtPriceAtTick(200)));
var below = RaydiumClmmMath.principalRawAmounts(position, pool(RaydiumClmmMath.sqrtPriceAtTick(-200)));
assertEquals(BigInteger.ZERO, above[0]);
assertEquals(new BigInteger("9999"), above[1]);
assertEquals(BigInteger.ZERO, below[1]);
assertTrue(below[0].signum() > 0);
assertThrows(IllegalArgumentException.class, () -> eligible(position, BigInteger.ZERO, true, "0"));
}
private static boolean eligible(RaydiumConcentratedPositionState position, BigInteger price, boolean direct, String distance) {
return RaydiumClmmMath.isAtOrBelowHarvestThreshold(position, pool(price), direct, new BigDecimal(distance));
}
private static RaydiumConcentratedPositionState position(int lower, int upper) {
return new RaydiumConcentratedPositionState("nft", "pool", lower, upper, BigInteger.valueOf(1_000_000));
}
private static RaydiumConcentratedPoolState pool(BigInteger sqrt) {
return new RaydiumConcentratedPoolState("pool", BigInteger.ONE, sqrt, 0);
}
}
@@ -0,0 +1,337 @@
package com.r35157.libs.raydium.impl.ref;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.r35157.assetaz.services.cis.impl.hc.HardcodedCurrencyIdentityService;
import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.libs.codec.impl.ref.Base58CodecImpl;
import com.r35157.libs.raydium.*;
import com.r35157.libs.solana.*;
import com.r35157.libs.solana.impl.ref.SolanaBlockChainImpl;
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
import org.junit.jupiter.api.Test;
import java.io.*;
import java.lang.reflect.Proxy;
import java.math.*;
import java.net.http.HttpClient;
import java.nio.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class RaydiumClmmWithdrawalTest {
@Test void fullDecreaseMatchesOfficialAccountsAndKeepsNftReadOnly() throws Exception {
Fixture fixture = new Fixture();
assertEquals("fixture-signature", fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 100));
Parsed transaction = parse(fixture.transaction);
assertEquals(4, transaction.instructions.size()); // Compute budget, two ATAs and decrease; no closes.
assertEquals("ComputeBudget111111111111111111111111111111", transaction.instructions.getFirst().program);
for (int i = 1; i < 3; i++) {
assertEquals(ATA, transaction.instructions.get(i).program);
assertArrayEquals(new byte[] {1}, transaction.instructions.get(i).data);
}
ParsedInstruction instruction = transaction.instructions.get(3);
assertEquals(RaydiumClmmWithdrawal.PROGRAM, instruction.program);
assertEquals(List.of(OWNER, NFT_ACCOUNT, fixture.personal, POOL, fixture.protocol, VAULT_A, VAULT_B,
fixture.lowerArray, fixture.upperArray, fixture.ataA, fixture.ataB, LEGACY, TOKEN2022, MEMO, EVE, SYRUP), instruction.accounts);
assertEquals(40, instruction.data.length);
assertArrayEquals(new byte[] {58,127,(byte)188,62,79,82,(byte)196,96}, Arrays.copyOf(instruction.data, 8));
assertEquals(fixture.position.liquidity(), unsigned(instruction.data, 8, 16));
assertEquals(BigInteger.ZERO, unsigned(instruction.data, 24, 8));
// Independent decimal calculation for ticks [-120,0], L=1e9.
// Principal token1 = floor(L * (1 - sqrtPrice(-120)/Q64)).
BigInteger expected = BigInteger.valueOf(1_000_000_000L)
.multiply(BigInteger.ONE.shiftLeft(64).subtract(RaydiumClmmMath.sqrtPriceAtTick(-120)))
.divide(BigInteger.ONE.shiftLeft(64)).multiply(BigInteger.valueOf(9900)).divide(BigInteger.valueOf(10000));
assertEquals(expected, unsigned(instruction.data, 32, 8));
assertTrue(expected.signum() > 0);
assertFalse(transaction.writable.contains(NFT_ACCOUNT));
assertFalse(transaction.keys.contains(NFT));
assertTrue(transaction.writable.contains(fixture.personal));
assertEquals(1, fixture.submissions); assertEquals(1, fixture.confirmations);
assertTrue(fixture.accounts.containsKey(fixture.personal));
}
@Test void directMetadataSupportsActualEveMintExtensionsAndPrecision() throws Exception {
Fixture fixture = new Fixture();
fixture.range(-120,0,0);
var info = fixture.operation().poolInfo(POOL);
assertEquals(EVE, info.mintA()); assertEquals(8, info.mintADecimals());
assertEquals(SYRUP, info.mintB()); assertEquals(6, info.mintBDecimals());
assertEquals(0, new BigDecimal("100").compareTo(info.priceEstimate()));
assertEquals(0, fixture.submissions);
}
@Test void reversedTokenOrderingProtectsSyrupOnTheOtherSide() throws Exception {
Fixture fixture = new Fixture();
fixture.reversed();
fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 50);
ParsedInstruction instruction = parse(fixture.transaction).instructions.get(3);
assertEquals(SYRUP, instruction.accounts.get(14)); assertEquals(EVE, instruction.accounts.get(15));
assertTrue(unsigned(instruction.data, 24, 8).signum() > 0);
assertEquals(BigInteger.ZERO, unsigned(instruction.data, 32, 8));
}
@Test void invalidOwnerChangedPositionAndUnprotectedWithdrawalFailBeforeSigning() throws Exception {
for (int scenario = 0; scenario < 7; scenario++) {
Fixture fixture = new Fixture();
switch (scenario) {
case 0 -> fixture.ownsNft = false;
case 1 -> { byte[] personal = fixture.data(fixture.personal); personal[81]++; fixture.put(fixture.personal, RaydiumClmmWithdrawal.PROGRAM, personal); }
case 2 -> { byte[] data = fixture.data(NFT_ACCOUNT); data[32]++; fixture.put(NFT_ACCOUNT, LEGACY, data); }
case 3 -> { byte[] data = fixture.data(POOL); data[0]++; fixture.put(POOL, RaydiumClmmWithdrawal.PROGRAM, data); }
case 4 -> { byte[] mint = fixture.data(EVE); mint[166] = 1; fixture.put(EVE, TOKEN2022, mint); }
case 5 -> fixture.put(VAULT_B, LEGACY, new byte[165]);
case 6 -> { byte[] data = fixture.data(fixture.upperArray); data[40]++; fixture.put(fixture.upperArray, RaydiumClmmWithdrawal.PROGRAM, data); }
}
assertThrows(Exception.class, () -> fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 100));
assertEquals(0, fixture.submissions); assertNull(fixture.transaction);
}
Fixture fixture = new Fixture();
assertThrows(IllegalArgumentException.class, () -> fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 10000));
var tiny = new RaydiumConcentratedPositionState(NFT, POOL, -120, 0, BigInteger.ONE);
assertThrows(IllegalArgumentException.class, () -> fixture.operation().withdraw(fixture.wallet, tiny, fixture.quote, 100));
assertNull(fixture.transaction);
}
@Test void bitmapExtensionAndInitializedRewardUseCorrectAccounts() throws Exception {
Fixture fixture = new Fixture(); fixture.tickSpacing=1;
byte[] spacingPool=fixture.data(POOL);ByteBuffer.wrap(spacingPool,235,2).order(ByteOrder.LITTLE_ENDIAN).putShort((short)1);
fixture.put(POOL,RaydiumClmmWithdrawal.PROGRAM,spacingPool);
fixture.range(-31000, -30900, -30800);
String bitmap = fixture.pda("pool_tick_array_bitmap_extension", SolanaProgramAddressSeed.solanaAddress(POOL));
byte[] bitmapData = account("TickArrayBitmapExtension", 40); addressInto(bitmapData, 8, POOL);
fixture.put(bitmap, RaydiumClmmWithdrawal.PROGRAM, bitmapData);
byte[] pool = fixture.data(POOL); String reward = address(20), rewardVault = address(21);
addressInto(pool, 397 + 57, reward); addressInto(pool, 397 + 89, rewardVault);
fixture.put(POOL, RaydiumClmmWithdrawal.PROGRAM, pool);
fixture.put(reward, LEGACY, mint(6, false)); fixture.put(rewardVault, LEGACY, token(reward, POOL));
fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 100);
Parsed transaction = parse(fixture.transaction);
ParsedInstruction decrease = transaction.instructions.get(transaction.instructions.size() - 1);
assertEquals(bitmap, decrease.accounts.get(16));
assertEquals(rewardVault, decrease.accounts.get(17));
assertEquals(fixture.math.findAssociatedTokenAccount(OWNER, reward, SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM), decrease.accounts.get(18));
assertEquals(reward, decrease.accounts.get(19));
}
@Test void token2022PositionNftIsPreservedAndUsesItsOwnerAccount() throws Exception {
Fixture fixture = new Fixture(); fixture.nftProgram=TOKEN2022;
fixture.put(NFT,TOKEN2022,mint(0,true)); fixture.put(NFT_ACCOUNT,TOKEN2022,token(NFT,OWNER));
fixture.operation().withdraw(fixture.wallet,fixture.position,fixture.quote,100);
Parsed transaction=parse(fixture.transaction);
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));
fixture.candidateNfts=Set.of(NFT,badNft);
RaydiumImpl raydium=new RaydiumImpl(HttpClient.newHttpClient(),new ObjectMapper(),fixture.chain,new HardcodedCurrencyIdentityService());
assertEquals(Set.of(NFT),raydium.fetchConcentratedPositionNftIds(OWNER));
fixture.legacyDiscoveryFails=true;fixture.nftProgram=TOKEN2022;
fixture.put(NFT,TOKEN2022,mint(0,true));fixture.put(NFT_ACCOUNT,TOKEN2022,token(NFT,OWNER));
assertEquals(Set.of(NFT),raydium.fetchConcentratedPositionNftIds(OWNER));
assertEquals(0,fixture.submissions);
}
@Test void unknownOutcomeIsReportedWithSignatureWithoutRetry() throws Exception {
Fixture fixture = new Fixture(); fixture.timeout = true;
IOException failure = assertThrows(IOException.class, () -> fixture.operation().withdraw(fixture.wallet, fixture.position, fixture.quote, 100));
assertTrue(failure.getMessage().contains("fixture-signature")); assertEquals(1, fixture.submissions); assertEquals(1, fixture.confirmations);
}
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,unreadableHoldingsProgram;
Set<String> candidateNfts=Set.of(NFT);
int submissions, confirmations, tickSpacing=60;
final SolanaWallet wallet = proxy(SolanaWallet.class, (method, args) -> switch (method) {
case "getAddress" -> OWNER;
case "signAndSendTransaction" -> { transaction = (SolanaUnsignedTransaction) args[0]; submissions++; yield "fixture-signature"; }
default -> throw new AssertionError("Unexpected wallet operation " + method);
});
final SolanaBlockChain chain = proxy(SolanaBlockChain.class, (method, args) -> switch (method) {
case "getAccountInfo" -> {
if(args[0].equals(unreadablePosition)) throw new IOException("fixture unreadable candidate");
yield accounts.get(args[0]);
}
case "getSolanaNFTCandidateAddresses" -> {
assertEquals(OWNER,args[0]);
if(legacyDiscoveryFails && args[1]==SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM) throw new IOException("fixture legacy discovery failure");
yield ((SolanaSPLTokenProgram)args[1]).getAddress().equals(nftProgram) ? candidateNfts : Set.of();
}
case "findProgramAddress" -> math.findProgramAddress((String) args[0], seeds(args[1]));
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]); 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)
: new SolanaTransactionOutcome(SolanaTransactionOutcome.Status.SUCCEEDED, 1L, null);
}
default -> throw new AssertionError("Unexpected chain operation " + method);
});
Fixture() throws Exception {
byte[] pool = account("PoolState", 1544);
addressInto(pool, 73, EVE); addressInto(pool, 105, SYRUP);
addressInto(pool, 137, VAULT_A); addressInto(pool, 169, VAULT_B);
pool[233] = 8; pool[234] = 6; ByteBuffer.wrap(pool, 235, 2).order(ByteOrder.LITTLE_ENDIAN).putShort((short)60);
put(POOL, RaydiumClmmWithdrawal.PROGRAM, pool);
put(EVE, TOKEN2022, mint(8, true)); put(SYRUP, LEGACY, mint(6, false)); put(NFT, LEGACY, mint(0, false));
put(VAULT_A, TOKEN2022, token(EVE, POOL)); put(VAULT_B, LEGACY, token(SYRUP, POOL));
put(NFT_ACCOUNT, LEGACY, token(NFT, OWNER));
range(-120, 0, 120);
ataA = math.findAssociatedTokenAccount(OWNER, EVE, SolanaSPLTokenProgram.TOKEN_2022_PROGRAM);
ataB = math.findAssociatedTokenAccount(OWNER, SYRUP, SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM);
}
void reversed() throws Exception {
range(0,120,-120);
byte[] pool = data(POOL); addressInto(pool, 73, SYRUP); addressInto(pool,105,EVE); pool[233] = 6; pool[234] = 8;
put(POOL,RaydiumClmmWithdrawal.PROGRAM,pool);
put(VAULT_A,LEGACY,token(SYRUP,POOL)); put(VAULT_B,TOKEN2022,token(EVE,POOL));
String temp=ataA;ataA=ataB;ataB=temp;
}
void range(int lower, int upper, int current) throws Exception {
position = new RaydiumConcentratedPositionState(NFT,POOL,lower,upper,BigInteger.valueOf(1_000_000_000L));
quote = new RaydiumConcentratedPoolState(POOL,BigInteger.ONE,RaydiumClmmMath.sqrtPriceAtTick(current),current);
personal = pda("position",SolanaProgramAddressSeed.solanaAddress(NFT));
protocol = pda("position",SolanaProgramAddressSeed.solanaAddress(POOL),tickSeed(lower),tickSeed(upper));
byte[] data = account("PersonalPositionState",281);addressInto(data,9,NFT);addressInto(data,41,POOL);
ByteBuffer.wrap(data,73,8).order(ByteOrder.LITTLE_ENDIAN).putInt(lower).putInt(upper);
byte[] amount = position.liquidity().toByteArray(); for(int i=0;i<amount.length;i++)data[81+i]=amount[amount.length-i-1];
put(personal,RaydiumClmmWithdrawal.PROGRAM,data);
int span=60*tickSpacing;int lowerStart=Math.floorDiv(lower,span)*span,upperStart=Math.floorDiv(upper,span)*span;
lowerArray=pda("tick_array",SolanaProgramAddressSeed.solanaAddress(POOL),tickSeed(lowerStart));
upperArray=pda("tick_array",SolanaProgramAddressSeed.solanaAddress(POOL),tickSeed(upperStart));
Map<String,Integer> arrays=new LinkedHashMap<>(); arrays.put(lowerArray,lowerStart);arrays.put(upperArray,upperStart);
for(var entry:arrays.entrySet()) {
byte[] tick=account("TickArrayState",44);addressInto(tick,8,POOL);ByteBuffer.wrap(tick,40,4).order(ByteOrder.LITTLE_ENDIAN).putInt(entry.getValue());
put(entry.getKey(),RaydiumClmmWithdrawal.PROGRAM,tick);
}
byte[] pool=data(POOL);BigInteger sqrt=quote.sqrtPriceX64();for(int i=0;i<16;i++)pool[253+i]=sqrt.shiftRight(8*i).byteValue();
put(POOL,RaydiumClmmWithdrawal.PROGRAM,pool);
}
String pda(String seed, SolanaProgramAddressSeed... rest) {
List<SolanaProgramAddressSeed> seeds=new ArrayList<>();seeds.add(SolanaProgramAddressSeed.utf8(seed));seeds.addAll(List.of(rest));
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);}
}
private record ParsedInstruction(String program,List<String> accounts,byte[] data) { }
private record Parsed(List<String> keys,Set<String> writable,List<ParsedInstruction> instructions) { }
private static Parsed parse(SolanaUnsignedTransaction unsigned) throws Exception {
var stream = new ByteArrayInputStream(Base64.getDecoder().decode(unsigned.serializedTransaction()));
assertEquals(1,stream.read());stream.skipNBytes(64);int signatures=stream.read(),readonlySigners=stream.read(),readonly=stream.read();
assertEquals(1,signatures);assertEquals(0,readonlySigners);
int count=compact(stream);List<String> keys=new ArrayList<>();for(int i=0;i<count;i++)keys.add(CODEC.encode(stream.readNBytes(32)));
assertEquals(OWNER,keys.getFirst());Set<String> writable=new HashSet<>(keys.subList(0,count-readonly));stream.skipNBytes(32);
List<ParsedInstruction> instructions=new ArrayList<>();count=compact(stream);
for(int i=0;i<count;i++) {
String program=keys.get(stream.read());int accountCount=compact(stream);List<String> accounts=new ArrayList<>();
for(int j=0;j<accountCount;j++)accounts.add(keys.get(stream.read()));byte[] data=stream.readNBytes(compact(stream));
instructions.add(new ParsedInstruction(program,accounts,data));
}
assertEquals(0,stream.available());return new Parsed(keys,writable,instructions);
}
private static int compact(InputStream stream) throws IOException {int value=0,shift=0,next;do {next=stream.read();value|=(next&127)<<shift;shift+=7;}while((next&128)!=0);return value;}
private static byte[] account(String type,int size) throws Exception {
byte[] data=new byte[size];System.arraycopy(MessageDigest.getInstance("SHA-256").digest(("account:"+type).getBytes(StandardCharsets.UTF_8)),0,data,0,8);return data;
}
private static byte[] mint(int decimals,boolean extensions) {byte[] data=new byte[extensions?174:82];data[44]=(byte)decimals;data[45]=1;if(extensions){data[165]=1;data[166]=18;data[170]=19;}return data;}
private static byte[] token(String mint,String owner) {byte[] data=new byte[165];addressInto(data,0,mint);addressInto(data,32,owner);data[64]=1;data[108]=1;return data;}
private static void addressInto(byte[] data,int offset,String address) {System.arraycopy(CODEC.decode(address),0,data,offset,32);}
private static SolanaProgramAddressSeed tickSeed(int tick) {return SolanaProgramAddressSeed.hex(HexFormat.of().formatHex(ByteBuffer.allocate(4).putInt(tick).array()));}
private static BigInteger unsigned(byte[] data,int offset,int length) {byte[] copy=new byte[length];for(int i=0;i<length;i++)copy[length-i-1]=data[offset+i];return new BigInteger(1,copy);}
@SuppressWarnings("unchecked") private static List<SolanaProgramAddressSeed> seeds(Object value) {return (List<SolanaProgramAddressSeed>)value;}
@FunctionalInterface private interface Handler {Object invoke(String method,Object[] args) throws Throwable;}
@SuppressWarnings("unchecked") private static <T> T proxy(Class<T> type,Handler handler) {return (T)Proxy.newProxyInstance(type.getClassLoader(),new Class<?>[]{type},(object,method,args)->handler.invoke(method.getName(),args));}
private static final Base58CodecImpl CODEC=new Base58CodecImpl();
private static String address(int value) {byte[] bytes=new byte[32];bytes[31]=(byte)value;return CODEC.encode(bytes);}
private static final String OWNER=address(1),POOL=address(2),NFT=address(3),NFT_ACCOUNT=address(4),VAULT_A=address(5),VAULT_B=address(6);
private static final String EVE="meveYG2iXYSkgSUn1T1uxcthH1EGMZdRHGgCntXZA3Y",SYRUP="AvZZF1YaZDziPY2RCK4oJrRVrbN3mTD9NL24hPeaZeUj";
private static final String LEGACY=SolanaSPLTokenProgram.SPL_TOKEN_PROGRAM.getAddress(),TOKEN2022=SolanaSPLTokenProgram.TOKEN_2022_PROGRAM.getAddress();
private static final String ATA="ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",MEMO="MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
}
@@ -0,0 +1,41 @@
package com.r35157.nenjim.hubd.impl.ref;
import com.r35157.nenjim.service.registry.valuetypes.NenjimComponentId;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.lang.reflect.Method;
import java.nio.file.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class NenjimHubLiquidityConfigurationTest {
@Test void catalogueConstructionIsPassiveAndRegistersOptionalLiquidityChoice(@TempDir Path directory) throws Exception {
NenjimHubImpl hub = new NenjimHubImpl();
Method method = NenjimHubImpl.class.getDeclaredMethod("createProcessConstructorCatalogue"); method.setAccessible(true);
Map<?, ?> catalogue = (Map<?, ?>) method.invoke(hub);
Set<NenjimComponentId> ids = new HashSet<>();
catalogue.keySet().forEach(key -> ids.add((NenjimComponentId)key)); ids.add(REGISTRY);
assertTrue(ids.contains(LIQUIDITY));
assertTrue(ids.contains(new NenjimComponentId("evelyn.iou-burner.prod")));
assertTrue(ids.contains(new NenjimComponentId("evelyn.status.prod")));
assertTrue(hub.getStartedProcessIds().isEmpty());
Path path = directory.resolve("NenjimHub.conf");
Files.writeString(path, "FORMAT_VERSION=1\n[AUTO_START]\nnenjim.registry.service\nnenjim.journal.service\nevelyn.liquidity.prod\n");
var configuration = NenjimHubConfigurationParser.parse(path, ids, REGISTRY, JOURNAL);
assertEquals(List.of(REGISTRY, JOURNAL, LIQUIDITY), configuration.autoStartProcessIds());
// Parsing a selection constructs no process or signer and adds no inferred dependencies.
assertTrue(hub.getStartedProcessIds().isEmpty());
var example = NenjimHubConfigurationParser.parse(Path.of("conf/NenjimHub.conf.example"), ids, REGISTRY, JOURNAL);
assertEquals(List.of(REGISTRY, JOURNAL), example.autoStartProcessIds());
}
@Test void optionalAdditionPreservesExistingExplicitOrdering(@TempDir Path directory) throws Exception {
Path path = directory.resolve("NenjimHub.conf");
NenjimComponentId ticker = new NenjimComponentId("assetaz.ticker.default"), status = new NenjimComponentId("evelyn.status.prod");
List<NenjimComponentId> ordered = List.of(REGISTRY, JOURNAL, ticker, LIQUIDITY, status);
Files.writeString(path,"FORMAT_VERSION=1\n[AUTO_START]\n"+String.join("\n",ordered.stream().map(NenjimComponentId::value).toList())+"\n");
assertEquals(ordered,NenjimHubConfigurationParser.parse(path,Set.copyOf(ordered),REGISTRY,JOURNAL).autoStartProcessIds());
}
private static final NenjimComponentId REGISTRY = new NenjimComponentId("nenjim.registry.service");
private static final NenjimComponentId JOURNAL = new NenjimComponentId("nenjim.journal.service");
private static final NenjimComponentId LIQUIDITY = new NenjimComponentId("evelyn.liquidity.prod");
}