89: Add EvelynLiquidityService to withdraw CLMM proceeds, swap to USDC and send payouts
This commit is contained in:
@@ -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 { }
|
||||
+91
@@ -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");
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
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 {
|
||||
RaydiumConcentratedPoolState state = raydium.fetchConcentratedPoolState(configuration.poolId());
|
||||
if (!configuration.poolId().equals(state.poolId()) || state.sqrtPriceX64() == null || state.sqrtPriceX64().signum() <= 0) {
|
||||
throw new IOException("Unusable current CLMM pool price");
|
||||
}
|
||||
for (ΩRaydiumLiquidityPoolPositionNftIdΩ nft : raydium.fetchConcentratedPositionNftIds(wallet.getAddress())) {
|
||||
checkInterrupted();
|
||||
try {
|
||||
RaydiumConcentratedPositionState position = raydium.fetchConcentratedPositionState(nft);
|
||||
if (!nft.equals(position.nftId()) || !configuration.poolId().equals(position.poolId()) || position.liquidity().signum() <= 0) continue;
|
||||
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,26 @@ 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.
|
||||
* 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 +68,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,306 @@
|
||||
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 {
|
||||
Ω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 =
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
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 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;
|
||||
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" -> {
|
||||
if (priceFails) throw new IOException("fixture pool price failure");
|
||||
yield new RaydiumConcentratedPoolState(POOL, BigInteger.ONE, sqrt, 0);
|
||||
}
|
||||
case "fetchConcentratedPositionNftIds" -> { assertEquals(OWNER, args[0]); yield new LinkedHashSet<>(positions.keySet()); }
|
||||
case "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];
|
||||
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));
|
||||
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,257 @@
|
||||
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 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<>();
|
||||
String personal, protocol, lowerArray, upperArray, ataA, ataB;
|
||||
RaydiumConcentratedPositionState position;
|
||||
RaydiumConcentratedPoolState quote;
|
||||
SolanaUnsignedTransaction transaction;
|
||||
boolean ownsNft = true, timeout, legacyDiscoveryFails;
|
||||
String nftProgram=LEGACY,unreadablePosition;
|
||||
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]); yield ownsNft ? Map.of(NFT, new SPLTokenHolding(NFT_ACCOUNT, NFT, BigDecimal.ONE, "1", 0, nftProgram)) : Map.of(); }
|
||||
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)));}
|
||||
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";
|
||||
}
|
||||
+41
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user