Repository navigation
fix: slippage units, unbounded position enumeration, and two misleading guards - #699
Conversation
|
| if (cachedPool) { | ||
| await this.refreshDlmmPoolState(poolAddress, cachedPool); | ||
| return cachedPool; |
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
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_idbaked into the instruction fresher, so it is closer to the value the program validates against withinmax_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 andswapQuoteiterating 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.
There was a problem hiding this comment.
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.
3ab9078 to
e7dcefa
Compare
openPosition converted slippagePct to basis points before handing it to initializePositionAndAddLiquidityByStrategy. That builder takes a percentage. The SDK defends itself against a nonsense value rather than rejecting it: capSlippagePercentage() clamps to 100, and getSlippageMaxAmount(amount, 100) returns U64_MAX. So the conversion did not merely widen the tolerance, it removed the deposit ceiling entirely. At the template default of slippagePct 2 the SDK was handed 200, clamped to 100, and every narrow-range open ran with no slippage protection at all; any slippagePct >= 1 did the same. addLiquidity always passed the percentage, and so does the chunked path added for wide ranges, which left the two halves of this one route disagreeing: a range of 70 bins or fewer was unprotected, a wider one was not. The conversion is gone. Nothing else read slippageBps. Covered by open-position-slippage-units.test.ts, which pins the value passed to each builder and that the two agree - nothing asserted this before, which is how a unit mismatch survived in a route that spends the caller's money. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… ledger
getPositionsForWalletAddress asked the node for every token account the
address owns and sifted them here. Since the route became read-only and takes
any address, that is unbounded: getParsedTokenAccountsByOwner renders each
account as verbose JSON, and on an exchange-scale address the response is
about 200MB. The request timed out at 90 seconds without reading a position.
Asking for the unparsed accounts instead is roughly a tenth the bytes and not
enough - the same address then failed at 84 seconds on V8's maximum string
length, which is a worse error for the same reason.
So the filtering moves to the node. A memcmp on the balance returns only the
accounts holding exactly one unit, and dataSlice trims each reply to the mint.
That address now answers in about 3 seconds with 1011 candidates, and a wallet
with real positions returns 36 of them in 3 seconds.
The filters are what make this affordable. An unfiltered getProgramAccounts on
the token program is what providers refuse; this one is served by the default
public endpoint as well as the paid ones, so it does not quietly require a
particular provider.
Two details worth keeping:
- Token-2022 deliberately has no dataSize filter. Those accounts grow past
165 bytes with each extension, so pinning the size would skip every
position NFT that carries one.
- The decimals-0 half of the old NFT test is gone, because it meant reading
every mint. A dust balance of one raw unit now reaches the PDA lookup,
which is what decides whether a mint is really a position anyway.
The per-position reads run eight at a time rather than one after another.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment on computeV3BinDistribution claimed the bitmap turns hundreds of calls into dozens and keeps a request small enough not to draw a 429. Measured over a 401-bin window on mainnet, that is true only where it is least needed: WETH/USDC 0.3% 400/402 boundaries initialized 0% fewer calls WETH/USDC 0.05% 396/402 1% WBTC/WETH 0.3% 311/402 22% USDC/USDT 0.01% 100/402 75% On a dense pool nearly every boundary carries liquidity, so there is almost nothing to skip - and dense pools are the ones most likely to be asked for. A binCount of 401 against a public endpoint still returns 429, as it should. What keeps the request survivable is the batching and backoff below, and what makes a rate-limited read safe is that it now propagates instead of being recorded as zero liquidity. The bitmap is a real win on sparse pools and a no-op on dense ones. Saying so here means the next person debugging a 429 does not start from a wrong premise. Comment only; no behaviour change. Also folds in the import reorder that `pnpm lint` (which runs with --fix) applies to quote-position-transaction-count.test.ts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rst saw it
DLMM.create() reads lbPair once and the SDK never refreshes it again on its own.
getDlmmPool cached the instance for the life of the process and called
refetchStates() exactly once, at creation, so a long-lived Gateway kept answering
from whatever the pool looked like the first time it was touched.
Three readers go through that cached state with nothing refreshing it:
- getBinsAroundActiveBin centers the pool-info bins window on lbPair.activeId
- initializePositionAndAddLiquidityByStrategy bakes it into the open-position
instruction as active_id
- addLiquidityByStrategy does the same for add-liquidity
Not swap quotes, despite appearances. swapQuote does read lbPair.activeId and
lbPair.vParameters, but clmm/quote-swap calls getBinArrayForSwap first, and that
opens with `await this.refetchStates()` - so the SDK refreshes the state
immediately before swapQuote reads it. That path was always self-healing. The
cost is that it now refetches twice per quote, once here and once in the SDK,
which is waste rather than a correctness problem.
#690 reported the visible reader: pool-info returned a fresh price and
activeBinId (getActiveBin fetches from chain, but never writes back) next to a
bins window frozen around the creation-time bin, and once the drift passed the
window the bins no longer contained the active bin at all. Observed live on
SOL/USDC 5rCf1DM8LjKTw4YqhnoLcngyZYeNnQqztScTogYHAS6 after hours of uptime:
window -5790..-5650 against active bin -5837, and a restart re-centered it.
The two position builders read the same stale id and are the reason this sits in
getDlmmPool rather than in getPoolInfo, where #690 put it. Cached state older
than DLMM_STATE_TTL_MS (5s) is refetched before the instance is handed out,
concurrent callers share the one refetch, and a failed refetch propagates instead
of quietly returning state the caller has no way to tell is stale.
refetchStates() is a single batched getMultipleAccounts, so this is one round
trip per pool per window, not per request.
This also fixes an ordering bug in #690's version, which refreshed after
getPoolInfo had already read feeInfo and getDynamicFee() - the latter derives
from lbPair.vParameters, the volatility accumulator, so the fixed response still
reported dynamicFeePct from stale state. Refreshing inside getDlmmPool puts every
read in getPoolInfo after the refresh.
Sequenced with the slippage fix in this PR, which is what makes it urgent.
getAndCapMaxActiveBinSlippage derives the on-chain active_id tolerance from the
same slippage argument: ceil(slippage / (binStep / 100)). Passing basis points
gave a 2% open on a binStep-20 pool a tolerance of ceil(200 / 0.2) = 1000 bins,
wide enough to absorb any amount of drift silently. With the percentage restored
that tolerance is ceil(2 / 0.2) = 10 bins, and the ~47 bins of drift #690
measured would start reverting on-chain instead.
Known residual, pre-existing and not addressed here: the DLMM instance is shared
and mutable, so a refresh triggered by one request can move lbPair.activeId under
another that is mid-flight between getDlmmPool and its builder call. The SDK
already does exactly this on every clmm/quote-swap, via the refetchStates() at
the top of getBinArrayForSwap, so this is not a new class of hazard - but
bounding staleness does make it reachable on more routes. Fixing it properly
means per-request instances or copy-on-read, which is a larger change than this
PR should carry.
Covered by dlmm-state-refresh.test.ts: no refetch inside the TTL, state follows
the active bin once past it, one refetch serves concurrent callers, and a failed
refetch surfaces without wedging the pool.
Co-Authored-By: azothzephyr <azothzephyr@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
positionsOwned guarded against the wallet changing during enumeration by comparing positions.length with the balanceOf it started from. That check could never fire: the loop runs exactly numPositions times and, since the two `continue` paths were removed, each pass either pushes once or throws - so the two numbers always agree and the guard read as protection that was not there. The hazard it described is real, but a count is the wrong instrument for it, and re-reading balanceOf at the end - which is what this branch tried first - is worse than nothing. Enumeration is EnumerableSet-backed. Confirmed against the mainnet position manager: tokenOfOwnerByIndex(owner, balanceOf) reverts with "EnumerableSet: index out of bounds", that set's own error. It appends on add and swap-and-pops on remove, and those two cases are nothing alike. A position ADDED mid-scan appends, so indices 0..N-1 are untouched and the loop reads exactly the set that existed when the request arrived. The list is a correct snapshot. A count check sees N+1 against N and throws it away. For a wallet that opens positions while polling this route - a rebalancing bot, which is the workload - that is the common case, not an edge. A position REMOVED mid-scan is already caught one level up, by the loop: the index walks off the end, or the tokenId stops existing before positions() is asked about it, and either way the pass throws. So there is no final check here now. What the loop catches was being reported as Failed to read position 12 of 20 for wallet 0x...: execution reverted: EnumerableSet: index out of bounds - a 500 that reads like a Gateway bug and tells the caller nothing. Both revert strings are now recognised and answered with a 409 naming what happened and that the request is worth repeating. Anything else still fails loudly. What none of this catches is a change landing after the loop's last read. That window is narrow, and closing it needs atomicity rather than arithmetic - one blockTag across every read - which is 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. Recorded in the comment at the site. Same change in both connectors, which carry the same loop. positions-owned-completeness.test.ts covers the add returning its snapshot (and balanceOf never being re-read), both revert strings reporting 409, and an unrelated failure still reporting 500. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e7dcefa to
9724900
Compare
There was a problem hiding this comment.
- Meteora deposit with a small price range, opened a SOL/USDC position at 2% slippage and it went through in one transaction, with the deposit staying within the amounts requested.
- Meteora deposit with a wide price range, the same request over a wider range completed across three transactions with the same slippage applied, so both halves of the route now agree.
- Meteora pool data refresh; two pool reads either side of a real price move showed the bin window following the active bin, on a Gateway left running the whole time.
- Meteora refresh logging, Gateway recorded the same price move in its log about 43 minutes into uptime, matching what the route returned.
- Raydium position list on a very large wallet, the address the PR used returned in about 7 seconds where it previously timed out at 90 seconds.
- Raydium position list on a wallet with a position, a real position came back complete and identical across two calls, confirming the new filter still finds genuine positions.
- Position list tested on both connectors this PR changes. Uniswap on Arbitrum returned all 46 position records, PancakeSwap on BSC all 9, both identical across repeated calls, including one position still holding funds.
Follow-ups from reviewing the commits that landed on
developmentsince #683, before staging. Four fixes and one comment correction. Each was verified against a live mainnet node, not just unit tests.fix(meteora): open a position with the slippage that was asked for
The one that matters.
openPositionconvertedslippagePctto basis points before callinginitializePositionAndAddLiquidityByStrategy, which takes a percentage.The SDK clamps rather than rejects:
capSlippagePercentage()caps at 100, andgetSlippageMaxAmount(amount, 100)returnsU64_MAX. So the conversion did not widen the tolerance, it removed the deposit ceiling. At the template default ofslippagePct: 2the SDK received 200, clamped to 100, and every narrow-range open ran with no slippage protection at all — as did anyslippagePct >= 1.addLiquidityalways passed the percentage, and so does the chunked path added in #696, so a range of ≤70 bins was unprotected while a wider one was not. Pre-existing (introduced in8b57c5b1c), but #696 put both behaviours in one route.Nothing asserted this before;
open-position-slippage-units.test.tsnow pins the value each builder receives and that the two agree.fix(meteora): stop serving pool state frozen at the moment Gateway first saw it
Supersedes #690, whose diagnosis was right and whose fix was one layer too low. Credit to @AzothZephyr, carried as a co-author on the commit.
DLMM.create()readslbPaironce and the SDK never refreshes it again on its own.getDlmmPoolcached the instance for the life of the process and calledrefetchStates()exactly once, at creation, so a long-lived Gateway kept answering from whatever the pool looked like the first time it was touched.#690 reported the visible half —
pool-inforeturns a freshpriceandactiveBinId(getActiveBin()fetches from chain, but never writes back) next to a bins window frozen around the creation-time bin, and once the drift passes the window the bins stop containing the active bin at all. Observed live on SOL/USDC5rCf1DM8LjKTw4YqhnoLcngyZYeNnQqztScTogYHAS6after hours of uptime: window-5790..-5650against active bin-5837, re-centered by a restart.But that is not the only reader of the cached state:
getBinsAroundActiveBinlbPair.activeIdgetPoolLiquidity→pool-infobinsinitializePositionAndAddLiquidityByStrategylbPair.activeId→ instructionactive_idclmm/open-positionaddLiquidityByStrategylbPair.activeId→ instructionactive_idclmm/add-liquidityNot swap quotes, despite appearances.
swapQuotedoes readlbPair.activeIdandlbPair.vParameters, butclmm/quote-swapcallsgetBinArrayForSwapfirst, and that opens withawait this.refetchStates()— the SDK refreshes the state immediately beforeswapQuotereads it. That path was always self-healing. An earlier revision of this description listed it as broken; it was not. The only cost there is that the route now refetches twice per quote, once here and once in the SDK — waste, not a correctness problem, and worth knowing when watching RPC volume.The two position builders are the reason the refresh moves up to
getDlmmPoolrather than sitting ingetPoolInfowhere #690 put it: state older thanDLMM_STATE_TTL_MS(5s) is refetched before the instance is handed out, concurrent callers share the one refetch, and a failed refetch propagates rather than quietly returning state the caller has no way to tell is stale.refetchStates()is a single batchedgetMultipleAccounts, so this is one round trip per pool per window, not per request.This also fixes an ordering bug in #690's version, which refreshed after
getPoolInfohad already readfeeInfoandgetDynamicFee(). The latter derives fromlbPair.vParameters, the volatility accumulator — precisely what drifts with the price movement being detected — so the fixed response still reporteddynamicFeePctfrom stale state. Refreshing insidegetDlmmPoolputs every read ingetPoolInfoafter the refresh.This is sequenced with the slippage fix above, which is what makes it urgent.
getAndCapMaxActiveBinSlippagederives the on-chainactive_idtolerance from the same slippage argument:ceil(slippage / (binStep / 100)). Passing basis points gave a 2% open on a binStep-20 pool a tolerance ofceil(200 / 0.2)= 1000 bins — wide enough to absorb any amount of drift silently. With the percentage restored that tolerance isceil(2 / 0.2)= 10 bins, and the ~47 bins of drift #690 measured would start reverting on-chain. Landing the slippage fix without this one converts a silent misplacement into a failed open.Known residual, pre-existing and not addressed here: the DLMM instance is shared and mutable, so a refresh triggered by one request can move
lbPair.activeIdunder another that is mid-flight betweengetDlmmPooland its builder call. Flagged by Greptile on this PR. The SDK already does exactly this on everyclmm/quote-swap, via therefetchStates()at the top ofgetBinArrayForSwap, so it is not a new class of hazard — but bounding staleness does make it reachable on more routes. Fixing it properly means per-request instances or copy-on-read, a larger change than this PR should carry.fix(raydium): list positions without pulling the wallet's whole token ledger
getPositionsForWalletAddressasked for every token account the address owns and filtered locally. Now that the route is read-only and accepts any address, that is unbounded —getParsedTokenAccountsByOwneron an exchange-scale address returns ~200MB and the request timed out at 90s.Switching to unparsed accounts only moved the failure (84s, V8 max string length), so the filtering moves to the node: memcmp on the balance,
dataSlicedown to the mint.5Q544fKr…(very large)Checked that this does not quietly require a paid provider: the default public
api.mainnet-beta.solana.comserves the filtered query fine — it is unfilteredgetProgramAccountson the token program that providers refuse. Token-2022 deliberately omits thedataSize: 165filter, since extensions push those accounts past 165 bytes.fix(clmm): stop rejecting a correct position list, and name the real conflict
positionsOwnedcomparedpositions.lengthagainst the startingbalanceOf. With bothcontinuepaths removed, the loop runs exactlynumPositionstimes and each pass either pushes once or throws — the two can never differ, so the guard was unreachable.Re-reading
balanceOfinstead, which is what this PR originally did, turned out to be the wrong instrument. Greptile flagged one hole in it; looking properly found a worse one pointing the other way.Enumeration is
EnumerableSet-backed — confirmed against the mainnet position manager, wheretokenOfOwnerByIndex(owner, balanceOf)reverts withEnumerableSet: index out of bounds, that set's own error. It appends on add and swap-and-pops on remove, and the two cases are nothing alike:0..N-1untouched — a correct snapshot of the set as of the requestN+1 ≠ Nand threw it awaySo it converted correct answers into 500s to cover a narrow tail window, and for a wallet that opens positions while polling this route — a rebalancing bot, which is the workload — the discarded case was the common one. The check is gone.
What remains is the removal the loop already catches, which was surfacing as:
A 500 that reads like a Gateway bug and tells the caller nothing. Both revert strings (
index out of bounds,Invalid token ID) are now answered with a 409 naming what happened and that the request is worth repeating. Anything else still fails loudly.This also shrinks the same-count churn Greptile raised: it needs a removal, and a removal almost always trips the loop first, so the silent duplicate-and-miss now needs both halves to land after the loop's last read. Closing that entirely needs atomicity rather than arithmetic — one
blockTagacross every read — still deliberately not done, because a pinned read against the load-balanced public RPCs Gateway defaults to can land on a node that has not yet seen that block. Recorded in the comment at the site.Uniswap and PancakeSwap both; the changed region is byte-identical between them.
docs(clmm): say what the tick bitmap actually saves
Comment only. #695's comment claimed the bitmap turns hundreds of calls into dozens and keeps requests under the rate limit. Measured over a 401-bin window on mainnet:
True only where it is least needed — dense pools are the ones most likely to be queried, and
binCount=401against a public endpoint still 429s. The batching and backoff are what keep the request survivable; the bitmap is a win on sparse pools and a no-op on dense ones.Separately, I confirmed #695's rewrite is behaviour-preserving: running the old brute-force read and the new bitmap path side by side against live WETH/USDC 0.05% gave byte-identical
liquidityNetacross all 402 boundaries.Testing
pnpm test— 168/168 suites, 1500/1500 testspnpm typecheckclean,pnpm lint0 errorsdlmm-state-refresh.test.ts: no refetch inside the TTL, state follows the active bin once past it, one refetch serves concurrent callers, a failed refetch surfaces without wedging the pool). The bug itself was observed live by @AzothZephyr in fix(meteora): refresh cached DLMM state when active bin drifts #690, and their narrower version ran on a live mainnet instance from 2026-08-26; the broadened placement here has not had that soak. Worth a look on a live instance before staging, specifically for RPC volume on the hotquote-swappath.Closes #690.
🤖 Generated with Claude Code