Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions src/connectors/clmm-v3-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,18 @@ export async function computeV3BinDistribution(args: {

// liquidityNet at each boundary, asking the tick bitmap which boundaries have one.
//
// Reading `ticks()` at every boundary is mostly wasted work: nearly all of them are
// uninitialized and answer zero. The bitmap reports which are not, one bit per
// tickSpacing, so a 401-bin window costs a couple of word reads plus one call per tick
// that actually carries liquidity — dozens of calls instead of hundreds. A boundary the
// bitmap reports as uninitialized has liquidityNet zero by definition, so not reading it
// loses nothing.
// The bitmap reports which boundaries are initialized, one bit per tickSpacing, so the
// window costs a couple of word reads plus one call per tick that actually carries
// liquidity. A boundary the bitmap reports as uninitialized has liquidityNet zero by
// definition, so not reading it loses nothing.
//
// How much this saves depends entirely on the pool, and on the busiest pools it saves
// nothing. Measured over a 401-bin window on mainnet: WETH/USDC 0.3% had 400 of 402
// boundaries initialized (0% fewer calls), WETH/USDC 0.05% 396 of 402 (1%), WBTC/WETH
// 0.3% 311 of 402 (22%), USDC/USDT 0.01% 100 of 402 (75%). So this is a real win on
// sparse pools and a no-op on dense ones — it is not what keeps a wide binCount under a
// node's rate limit. That is the batching and backoff below, and on a limit as tight as
// a public endpoint's a large binCount can still legitimately fail with a 429.
//
// This previously read all of them at once and caught each rejection as
// `liquidityNet: 0`. Both getters cannot revert for in-range inputs, so every rejection
Expand All @@ -120,7 +126,7 @@ export async function computeV3BinDistribution(args: {
// Measured against a node rate-limiting the 401 concurrent calls, a pool whose liquidity
// truly falls to 7% of its value at spot by +10% reported 94% — a flat curve where the
// real one drops twelvefold, with nothing in the response to indicate it. Rejections now
// propagate, and the bitmap keeps the request small enough not to provoke them.
// propagate instead, so a rate-limited read fails loudly rather than flattening the curve.
const wordOf = (tick: number) => Math.floor(Math.floor(tick / tickSpacing) / 256);
const words: number[] = [];
for (let word = wordOf(boundaries[0]); word <= wordOf(boundaries[boundaries.length - 1]); word++) {
Expand Down
24 changes: 14 additions & 10 deletions src/connectors/meteora/clmm-routes/openPosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,13 @@ export async function openPosition(
const totalXAmount = new BN(DecimalUtil.toBN(new Decimal(baseTokenAmount || 0), dlmmPool.tokenX.mint.decimals));
const totalYAmount = new BN(DecimalUtil.toBN(new Decimal(quoteTokenAmount || 0), dlmmPool.tokenY.mint.decimals));

// Create position transaction following SDK example
// Slippage needs to be in BPS (basis points): percentage * 100
const slippageBps = slippagePct * 100;

// The SDK takes slippage as a percentage, not basis points. This used to pass
// `slippagePct * 100`, which the SDK reads as a percentage and clamps with
// capSlippagePercentage() at 100 — and getSlippageMaxAmount(amount, 100) returns
// U64_MAX, i.e. no ceiling on what the deposit may consume. At the template default
// of 2% that meant every narrow-range open ran with slippage protection switched off,
// and any slippagePct >= 1 did the same. The chunked path below always passed the
// percentage, so the two halves of this route disagreed.
const resolvedStrategyType = strategyType ?? MeteoraConfig.config.strategyType;

logger.info(
Expand All @@ -136,8 +139,8 @@ export async function openPosition(
`Token amounts: ${(baseTokenAmount || 0).toFixed(6)} ${tokenXSymbol}, ${(quoteTokenAmount || 0).toFixed(6)} ${tokenYSymbol}`,
);
logger.info(`Bin IDs: min=${minBinId}, max=${maxBinId}, active=${activeBin.binId}, width=${positionWidth}`);
if (slippageBps) {
logger.info(`Slippage: ${slippagePct}% (${slippageBps} BPS)`);
if (slippagePct) {
logger.info(`Slippage: ${slippagePct}%`);
}

// The position account, and every transaction that built it. A narrow range is one
Expand All @@ -163,15 +166,16 @@ export async function openPosition(
totalXAmount,
totalYAmount,
strategy: { maxBinId, minBinId, strategyType: resolvedStrategyType },
// Only add slippage if provided and greater than 0
...(slippageBps ? { slippage: slippageBps } : {}),
// Only add slippage if provided and greater than 0. A percentage, matching the
// chunked path below and add-liquidity.
...(slippagePct ? { slippage: slippagePct } : {}),
});
await sendStep(createPositionTx, [newImbalancePosition], 'Create position');
} else {
// The SDK sizes the work from the strategy and hands back raw instructions grouped by
// position: the position init, idempotent ATA creations, and the deposit split into
// chunks of DEFAULT_BIN_PER_POSITION bins. Note this takes slippage as a percentage,
// not the basis points the single-transaction builder above wants.
// chunks of DEFAULT_BIN_PER_POSITION bins. Slippage is a percentage here, same as the
// single-transaction builder above.
const { instructionsByPositions } = await dlmmPool.initializeMultiplePositionAndAddLiquidityByStrategy(
async (count: number) => Array.from({ length: count }, () => Keypair.generate()),
totalXAmount,
Expand Down
62 changes: 59 additions & 3 deletions src/connectors/meteora/meteora.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,24 @@ export class Meteora {
// position can hold: this is how much of the book to show, and it shared a constant
// with the position cap only by coincidence.
private static readonly POOL_LIQUIDITY_BIN_RANGE = 69;

// How long a cached DLMM instance's on-chain state is trusted before it is refetched.
// DLMM.create() populates `lbPair` once and the SDK never refreshes it on its own, but
// nearly every read goes through it: getBinsAroundActiveBin centers the pool-info window
// on `lbPair.activeId`, swapQuote prices from that id and from `lbPair.vParameters`, and
// initializePositionAndAddLiquidityByStrategy bakes the id into the instruction as
// `active_id`. Left alone a long-lived instance keeps answering with whatever the pool
// looked like the first time Gateway touched it. refetchStates() is a single batched
// getMultipleAccounts, so bounding the staleness costs one round trip per pool per
// window rather than one per request.
private static readonly DLMM_STATE_TTL_MS = 5000;

private solana: Solana;
public config: MeteoraConfig.RootConfig;
private dlmmPools: Map<string, DLMM> = new Map();
private dlmmPoolPromises: Map<string, Promise<DLMM>> = new Map();
private dlmmPoolRefreshedAt: Map<string, number> = new Map();
private dlmmPoolRefreshes: Map<string, Promise<void>> = new Map();

private constructor() {
this.config = MeteoraConfig.config;
Expand All @@ -102,18 +116,22 @@ export class Meteora {
this.solana = await Solana.getInstance(network); // Get initialized Solana instance
this.dlmmPools = new Map();
this.dlmmPoolPromises = new Map();
this.dlmmPoolRefreshedAt = new Map();
this.dlmmPoolRefreshes = new Map();
logger.info('Initializing Meteora');
} catch (error) {
logger.error('Failed to initialize Meteora:', error);
throw error;
}
}

/** Gets DLMM pool instance */
/** Gets DLMM pool instance, with its on-chain state no more than DLMM_STATE_TTL_MS old */
async getDlmmPool(poolAddress: string): Promise<DLMM> {
// Check if we already have the pool instance
if (this.dlmmPools.has(poolAddress)) {
return this.dlmmPools.get(poolAddress);
const cachedPool = this.dlmmPools.get(poolAddress);
if (cachedPool) {
await this.refreshDlmmPoolState(poolAddress, cachedPool);
return cachedPool;
Comment on lines +132 to +134

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shared Pool State Race

The TTL refresh mutates the shared cached DLMM instance after earlier callers may already have received it. A long-running quote or position request can read the active bin, await other RPC work, and then have another request refresh lbPair before the builder runs. The first request can therefore select swap arrays or calculate a position range from one pool snapshot, but build against another. Pool state needs to remain consistent for the full read-and-build operation.

Knowledge Base Used:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct that the shared instance is mutated under in-flight readers, but this is pre-existing rather than introduced here — and the SDK is the one doing it on the hottest route.

getBinArrayForSwap opens with await this.refetchStates() (@meteora-ag/dlmm/dist/index.js:15358), so every clmm/quote-swap has always refreshed the shared lbPair mid-request, on every call, long before this change. Bounding staleness makes it reachable on more routes; it is not a new class of hazard.

Severity is also lower than the comment implies, and inverted on one of the two builders:

  • open-position / add-liquidity — a mid-flight refresh makes the active_id baked into the instruction fresher, so it is closer to the value the program validates against within max_active_bin_slippage. That direction helps rather than hurts.
  • quote-swap — the one place it could bite is a refresh landing during getBinArrayForSwap's own RPC, leaving arrays selected for one active bin and swapQuote iterating from another. A bin array spans 70 bins, so the move has to be large to escape the fetched window.

Chasing this down did catch a real error in the description, now fixed: it listed swapQuote among the readers left stale by the cache. It never was, because of that same refetchStates(). The three genuinely affected readers are getBinsAroundActiveBin and the two position builders.

Keeping it as a documented residual. Closing it properly means per-request instances or copy-on-read — the shared mutable SDK object is the root cause, and a better refresh policy cannot fix it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s a fair correction. The shared mutable-instance race is pre-existing, and getBinArrayForSwap() already refreshes the SDK state on the quote path, so this change does not introduce a new class of inconsistency there. The position-builder case is also not uniformly harmful: a refresh makes the baked active_id fresher, while the remaining quote risk requires the active bin to move enough during getBinArrayForSwap() to invalidate the fetched array window. I agree this should remain a documented residual rather than a blocking finding.

}

// Check if we have a pending promise for this pool
Expand All @@ -128,6 +146,7 @@ export class Meteora {
cluster: this.solana.network as any,
});
await dlmmPool.refetchStates();
this.dlmmPoolRefreshedAt.set(poolAddress, Date.now());
this.dlmmPools.set(poolAddress, dlmmPool);
this.dlmmPoolPromises.delete(poolAddress);
return dlmmPool;
Expand All @@ -143,6 +162,43 @@ export class Meteora {
return dlmmPoolPromise;
}

/**
* Refetch a cached instance's on-chain state once it has aged past DLMM_STATE_TTL_MS.
* Concurrent callers share the one in-flight refetch. A failure propagates instead of
* leaving the caller holding state it has no way to tell is stale — a silently stale
* `activeId` prices a swap off the wrong bin and goes into a position instruction as
* `active_id`, which is worse than the request failing.
*/
private async refreshDlmmPoolState(poolAddress: string, dlmmPool: DLMM): Promise<void> {
const refreshedAt = this.dlmmPoolRefreshedAt.get(poolAddress) ?? 0;
if (Date.now() - refreshedAt < Meteora.DLMM_STATE_TTL_MS) {
return;
}

const inFlight = this.dlmmPoolRefreshes.get(poolAddress);
if (inFlight) {
return inFlight;
}

const refresh = (async () => {
const activeIdBefore = dlmmPool.lbPair.activeId;
try {
await dlmmPool.refetchStates();
this.dlmmPoolRefreshedAt.set(poolAddress, Date.now());
if (dlmmPool.lbPair.activeId !== activeIdBefore) {
logger.info(
`Pool ${poolAddress} active bin moved ${activeIdBefore} -> ${dlmmPool.lbPair.activeId} since the last refresh`,
);
}
} finally {
this.dlmmPoolRefreshes.delete(poolAddress);
}
})();

this.dlmmPoolRefreshes.set(poolAddress, refresh);
return refresh;
}

/** Gets Meteora pools with optional token filtering */
async getPools(
limit: number = 100,
Expand Down
44 changes: 35 additions & 9 deletions src/connectors/pancakeswap/clmm-routes/positionsOwned.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,24 @@ export async function getPositionsOwned(
price: parseFloat(pool.token0Price.toSignificant(6)),
});
} catch (err) {
// Do not swallow this. A failure here is indistinguishable from the
// A wallet that loses a position mid-scan renumbers underneath this loop: the
// enumerable bookkeeping swap-and-pops on removal, so an index that was inside the
// set when balanceOf was read can fall off the end, and a tokenId read a moment ago
// can stop existing before positions() is asked about it. Verified against the
// mainnet position manager: the first reverts with "EnumerableSet: index out of
// bounds", the second with "Invalid token ID".
//
// That is the caller's own concurrent close, not a Gateway fault. Reporting the raw
// revert as a 500 reads like a bug in here and tells them nothing; a conflict says
// what happened and that the request is worth repeating.
const revert = err.message ?? '';
if (revert.includes('index out of bounds') || revert.includes('Invalid token ID')) {
throw fastify.httpErrors.conflict(
`Wallet ${walletAddress} lost a position while its ${numPositions} positions were being listed. Retry.`,
);
}

// Do not swallow anything else. A failure here is indistinguishable from the
// position not existing, so returning the rest would hand the caller a
// silently short list — and callers size new exposure against it.
throw fastify.httpErrors.internalServerError(
Expand All @@ -136,14 +153,23 @@ export async function getPositionsOwned(
}
}

// Index-based enumeration is only consistent if the wallet's balance did not
// change mid-scan. If it did, the list is not the inventory it claims to be.
if (positions.length !== numPositions) {
throw fastify.httpErrors.internalServerError(
`Resolved ${positions.length} positions but balanceOf reported ${numPositions} for wallet ` +
`${walletAddress}; the wallet's positions likely changed during enumeration. Retry.`,
);
}
// No final balanceOf comparison. A count check cannot say what it appears to say, and
// the version that was here rejected correct answers to do it.
//
// Enumeration is EnumerableSet-backed, which appends on add and swap-and-pops on remove.
// So a position ADDED mid-scan cannot disturb indices 0..N-1 — the loop reads exactly the
// set that existed when the request arrived, and the list it returns is a correct
// snapshot. A count check sees N+1 against N and throws that correct snapshot away, which
// for a wallet that opens positions while polling this route is the common case, not the
// edge. A position REMOVED mid-scan is caught by the loop itself, above: the index walks
// off the end, or the tokenId stops existing, and either way the pass throws.
//
// What neither catches is a change that lands after the loop's last read. That window is
// narrow and closing it needs atomicity, not arithmetic — pinning every read to one
// blockTag, at which point nothing here has to be inferred. Deliberately not done: a
// pinned read against the load-balanced public RPCs Gateway defaults to can land on a
// node that has not yet seen that block, trading a rare stale list for a routine failed
// request. Known and accepted.

return positions;
}
80 changes: 63 additions & 17 deletions src/connectors/raydium/raydium.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '@raydium-io/raydium-sdk-v2';
import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token';
import { Keypair, PublicKey } from '@solana/web3.js';
import bs58 from 'bs58';

import { Solana } from '../../chains/solana/solana';
import { PoolInfo as AmmPoolInfo } from '../../schemas/amm-schema';
Expand All @@ -32,6 +33,29 @@ interface InternalAmmPoolInfo extends AmmPoolInfo {
poolType?: 'amm' | 'cpmm';
}

// SPL token account layout: mint at 0..32, owner at 32..64, amount (u64 LE) at 64..72.
const TOKEN_ACCOUNT_OWNER_OFFSET = 32;
const TOKEN_ACCOUNT_AMOUNT_OFFSET = 64;

/** Return only the mint, so a wallet's whole token ledger does not come back. */
const MINT_SLICE = { offset: 0, length: 32 };

/** Base58 of a u64 little-endian 1 — the balance an NFT holding carries. */
const AMOUNT_ONE_BASE58 = bs58.encode(Buffer.from([1, 0, 0, 0, 0, 0, 0, 0]));

/**
* Accounts that `walletAddress` owns holding exactly one unit.
*
* This is deliberately looser than the old check, which also required the mint to have
* zero decimals and so meant reading every mint. Letting through a dust balance of one
* raw unit costs nothing: the personal-position PDA lookup below is what decides whether
* a mint is really a Raydium position.
*/
const nftHoldingFilters = (walletAddress: string) => [
{ memcmp: { offset: TOKEN_ACCOUNT_OWNER_OFFSET, bytes: walletAddress } },
{ memcmp: { offset: TOKEN_ACCOUNT_AMOUNT_OFFSET, bytes: AMOUNT_ONE_BASE58 } },
];

export class Raydium {
private static _instances: { [name: string]: Raydium };
public solana: Solana; // Changed to public for use in route handlers
Expand Down Expand Up @@ -286,21 +310,39 @@ export class Raydium {
* Gateway has stored.
*/
async getPositionsForWalletAddress(walletAddress: string): Promise<PositionInfo[]> {
const ownerPubkey = new PublicKey(walletAddress);

// Raydium position NFTs are minted under either the SPL Token or Token-2022 program
// Validates the address and normalises it to the canonical base58 the memcmp wants.
const owner = new PublicKey(walletAddress).toBase58();

// Raydium position NFTs are minted under either the SPL Token or Token-2022 program.
//
// The filtering happens on the node, not here. Asking for the owner's token accounts
// and sifting them locally means the node ships every account the address holds:
// `getParsedTokenAccountsByOwner` renders each as verbose JSON and an exchange-scale
// address produces ~200MB, which times out; the unparsed form is about a tenth of that
// and still overflowed V8's maximum string length on the same address. A memcmp on the
// balance asks the node for only the NFT-shaped accounts, and dataSlice trims each
// reply to the mint — 1011 accounts in ~3s for that same address. The filters are what
// keep this affordable, and both the default public endpoint and the paid providers
// serve it; it is an unfiltered getProgramAccounts on the token program that they
// refuse.
const [splTokenAccounts, token2022Accounts] = await Promise.all([
this.solana.connection.getParsedTokenAccountsByOwner(ownerPubkey, { programId: TOKEN_PROGRAM_ID }),
this.solana.connection.getParsedTokenAccountsByOwner(ownerPubkey, { programId: TOKEN_2022_PROGRAM_ID }),
this.solana.connection.getProgramAccounts(TOKEN_PROGRAM_ID, {
dataSlice: MINT_SLICE,
// Classic token accounts are always exactly 165 bytes.
filters: [{ dataSize: 165 }, ...nftHoldingFilters(owner)],
}),
this.solana.connection.getProgramAccounts(TOKEN_2022_PROGRAM_ID, {
dataSlice: MINT_SLICE,
// No dataSize here: Token-2022 accounts grow past 165 bytes with each extension,
// so pinning the size would silently skip every position NFT that carries one.
filters: nftHoldingFilters(owner),
}),
]);

// Position NFTs have decimals 0 and a balance of exactly 1
const nftMints = [...splTokenAccounts.value, ...token2022Accounts.value]
.filter((account) => {
const tokenAmount = account.account.data.parsed?.info?.tokenAmount;
return tokenAmount?.decimals === 0 && tokenAmount?.uiAmount === 1;
})
.map((account) => account.account.data.parsed.info.mint as string);
// dataSlice left exactly the mint in each account's data.
const nftMints = [...splTokenAccounts, ...token2022Accounts].map(({ account }) =>
new PublicKey(account.data).toBase58(),
);

logger.debug(`Found ${nftMints.length} NFT(s) for wallet ${walletAddress}, checking for Raydium CLMM positions`);

Expand All @@ -321,12 +363,16 @@ export class Raydium {
});
}

// Read the positions a few at a time. Each getPositionInfo is several round trips, so
// one after another a wallet with many positions spends minutes here; unbounded
// Promise.all instead rate-limits the node. Order follows positionMints so the
// response does not reshuffle between calls.
const positions: PositionInfo[] = [];
for (const mint of positionMints) {
const positionInfo = await this.getPositionInfo(mint);
if (positionInfo) {
positions.push(positionInfo);
}
const readConcurrency = 8;
for (let i = 0; i < positionMints.length; i += readConcurrency) {
const batch = positionMints.slice(i, i + readConcurrency);
const read = await Promise.all(batch.map((mint) => this.getPositionInfo(mint)));
positions.push(...read.filter((position): position is PositionInfo => Boolean(position)));
}

return positions;
Expand Down
Loading
Loading