feat(meteora): open a price range wider than one transaction can fund - #696
Merged
Merged
Conversation
The connector enforced two different position-width limits. openPosition compared the inclusive width against a local 69, while Meteora.getPriceToBinIds compared `maxBinId - minBinId` against MAX_BINS = 70 — so the two disagreed by two bins on what a position could hold, and neither named the other. MAX_BINS was also doing a second, unrelated job: getPoolLiquidity used it to decide how many bins either side of the active bin pool-info reports. That is a question about how much of the book to show, not about what a position can hold; the two shared a constant only by coincidence, so a change to either meaning would have silently moved the other. Split them. MAX_POSITION_BIN_WIDTH is the width rule, now public and used by both sites, and POOL_LIQUIDITY_BIN_RANGE is the display range, keeping its current value of 69 either side. The comment on the old constant claimed 70 aligned with DEFAULT_BIN_PER_POSITION for single-transaction operations, which is the right idea attached to the wrong number. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WvS83dMFobrZtpcW1LRpWx
openPosition rejected any range over 69 bins as exceeding what "a Meteora DLMM position holds". That is not the constraint. The program's POSITION_MAX_LENGTH is 1400 bins; 69 is what the one-shot initializePositionAndAddLiquidityByStrategy path can create and fund inside a single transaction, bounded by DEFAULT_BIN_PER_POSITION and Solana's 10,240-byte CPI allocation limit. So a wide range was refused for being wide, when the real cost is only that it takes more than one transaction. Send those transactions. Above the single-transaction width the route now builds the work with initializeMultiplePositionAndAddLiquidityByStrategy, which sizes it from the same strategy and returns the position init plus the deposit split into chunks of DEFAULT_BIN_PER_POSITION bins. Note this is still ONE position — the SDK splits into several only past POSITION_MAX_LENGTH — so what grows is the transaction count, not the number of position NFTs. Consequences that are easy to get wrong, so they are handled explicitly: - Amounts are summed over every transaction that funded the position. Reading only the first, as the single-transaction path could, reports a fraction of what was added. - Rent is read from the position account rather than from the creating transaction. A chunked open grows the position as it funds each chunk, so the balance in the first transaction is only what it started with. - A chunk failing partway leaves the position open holding what landed. That throws an error naming the position, how many chunks funded, and every signature, so the caller can add the rest or close it — rather than being left owning an account it never saw created. This is the real cost of the feature: the open is no longer atomic, and it cannot be, since the deposit does not fit in one transaction. - Past POSITION_MAX_LENGTH the SDK returns several positions, which this response cannot describe — it carries one position address. That is refused outright rather than returning one position and orphaning the rest. quote-liquidity now reports positionCount and transactionCount from the SDK's own quoteCreatePosition, sized from the same strategy, so a caller sees what a range will cost in transactions before opening rather than learning it from a rejection. Both fields are optional and omitted by venues that always open in one transaction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WvS83dMFobrZtpcW1LRpWx
…ction Every route on a position's lifecycle already sent whatever the SDK handed back — remove-liquidity, close-position and collect-fees all loop over a Transaction[]. add-liquidity was the exception: it called the non-chunkable addLiquidityByStrategy, which returns a single transaction and assumes the position's range fits in one. That was survivable while openPosition refused anything over ~69 bins. Now that it opens wider positions, it is a hole in the lifecycle — and the error this route's sibling raises on a partially funded open tells the caller to "add the rest with add-liquidity", which would have been advice into a route that could not do it. Switch to addLiquidityByStrategyChunkable and send the chunks, with the amounts summed over them and a partial failure naming what landed, matching what openPosition does. Also correct the position-width messages in the Solana error parser. They said a DLMM position "holds at most 69 bins" — that is not the constraint. A position spans up to POSITION_MAX_LENGTH (1400) bins; 69 is roughly what one transaction can create and fund, which is a limit on the deposit, not on the position. The realloc message likewise blamed the range for being wide when the fault is growing the account that far in a single instruction, and advised narrowing the range or splitting into positions, neither of which is needed now that the deposit is chunked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WvS83dMFobrZtpcW1LRpWx
quote-liquidity passed the SDK's transactionCount straight through, and that number counts deposit chunks only — ceil(bins / DEFAULT_BIN_PER_POSITION). It says nothing about creating the position, which openPosition sends as its own transaction whenever the range is chunked, because a full chunk is already sized to fill a transaction and folding the position init in alongside it risks overflowing one. So a wide range was quoted one transaction short of what the open would send: a 200-bin range reported 3 and sent 4. The whole point of the field is to let a caller see the cost before opening, and it is the sort of off-by-one nobody notices until they are reconciling signatures. Add the create when the range is chunked. Below the threshold the position is created and funded together and the SDK's count is already right. Tests pin the number against the open's own arithmetic at the threshold, one bin past it, and for a wide range. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WvS83dMFobrZtpcW1LRpWx
…hain The connector capped a one-transaction open at 69 bins. That number came from a mainnet simulation (7dd9bf1) which verified 69 worked but never tested 70, and described the program as capping positions "at ~70 bins" — so 69 was the value known to work, not a measured ceiling. It also sat one below DEFAULT_BIN_PER_POSITION, which the SDK's own chunker passes to initializePosition verbatim, and which would be a strange constant for the program to reject. Simulated it against the Meteora SOL/USDC pool on mainnet rather than reasoning about it further. initializePosition succeeds at 68, 69 and 70 bins and fails at 71, 72 and 80 with InvalidPositionWidth (6040 / 0x1798, thrown at programs/lb_clmm/src/instructions/position/common.rs:30). The combined create-and-fund path behaves identically — succeeds at 70, fails at 71 — and its message is 819 bytes at width 70 against a 1232-byte limit, so neither transaction size nor the 10,240-byte CPI allocation limit binds before the program's own check does. So the cap is exactly DEFAULT_BIN_PER_POSITION, and 69 was leaving a bin on the table: a 70-bin range was being funded over three transactions when it fits in one. One consequence worth naming, since it reads as an off-by-one otherwise: the threshold and the deposit chunk size are now the same number. A range of 71 bins is one past what a single transaction holds but needs two chunks of 70 to cover, so the narrowest chunked open is three transactions, not two. There is no one-chunk chunked open. Parser messages updated from "about 69" to 70 now that it is measured rather than approximate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WvS83dMFobrZtpcW1LRpWx
Contributor
Meteora quote transaction counts ✅
Wide Meteora position lifecycle ✅
Add liquidity to a wide Meteora position ✅
Observation - wide Meteora partial-failure recovery and balance impact 👀
User note: Before opening a wide position, keep extra SOL beyond the liquidity amount for position rent and every planned transaction. If Gateway reports a partial open, do not retry |
rapcmia
approved these changes
Sep 15, 2026
cardosofede
approved these changes
Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Meteora
openPositionrejected any price range wider than 69 bins as exceeding what "a Meteora DLMM position holds". That is not the constraint, and the range did not need to be refused.The 69 is a transaction limit, not a position limit
Read out of the DLMM IDL's own constants:
POSITION_MAX_LENGTHDEFAULT_BIN_PER_POSITIONMAX_RESIZE_LENGTHA position spans up to 1400 bins. 69 is roughly what the one-shot
initializePositionAndAddLiquidityByStrategypath can create and fund inside a single transaction, bounded byDEFAULT_BIN_PER_POSITIONand Solana's 10,240-byte CPI allocation limit. So a wide range was being refused for being wide, when the real cost is that it takes more than one transaction.Worth noting for anyone who reads this expecting position splitting:
getPositionCountByBinCountisceil(binCount / 1400), soinitializeMultiplePositionAndAddLiquidityByStrategyreturns one position for any realistic range. What grows is the transaction count — the deposit is chunked atDEFAULT_BIN_PER_POSITIONbins per transaction bychunkBinRange.What changed
openPositionsends those transactions instead of rejecting the range. Four consequences that are easy to get wrong, handled explicitly:POSITION_MAX_LENGTHthe SDK returns several positions, which this response cannot describe — it carries onepositionAddress. Refused outright rather than returning one and orphaning the rest.quote-liquidityreportspositionCountandtransactionCount, so a caller sees what a range costs in transactions before opening rather than learning it from a rejection. Both fields optional, omitted by venues that always open in one transaction.Note the SDK's
quoteCreatePosition().transactionCountcounts deposit chunks only —ceil(bins / DEFAULT_BIN_PER_POSITION)— and says nothing about creating the position, whichopenPositionsends as its own transaction whenever the range is chunked. Passing it through unchanged quoted a wide range one transaction short of what the open sends (a 200-bin range said 3, sent 4), so the create is added back here. Below the chunking threshold the position is created and funded together and the SDK's count is already right. Tested against the open's own arithmetic at the threshold, one bin past it, and at 200 bins.add-liquiditynow chunks too. Every other route on the position lifecycle already looped over aTransaction[]—remove-liquidity,close-positionandcollect-feesall do.add-liquiditywas the exception, calling the non-chunkableaddLiquidityByStrategy. That was survivable while opens were capped at 69 bins; once wide positions became creatable it was a hole in the lifecycle, and the partial-failure error above tells the caller to "add the rest with add-liquidity" — advice into a route that could not do it.One constant for the position width. The connector enforced two:
openPositioncompared the inclusive width against a local 69, whileMeteora.getPriceToBinIdscomparedmaxBinId - minBinIdagainstMAX_BINS = 70, so they disagreed by two bins and neither named the other.MAX_BINSwas also deciding how many bins either side of the active binpool-inforeports — a question about how much of the book to show, sharing a constant with the position rule only by coincidence, so a change to either meaning would have silently moved the other. Split intoMAX_POSITION_BIN_WIDTHandPOOL_LIQUIDITY_BIN_RANGE.Corrected the position-width messages in the Solana error parser. They said a DLMM position "holds at most 69 bins" in two places — false, per the constants above. The realloc message likewise blamed the range for being wide when the fault is growing the account that far in a single instruction, and advised narrowing the range or splitting into positions, neither of which is needed now that the deposit chunks.
The single-transaction width is 70, measured
The previous cap of 69 came from a mainnet simulation (
7dd9bf1e) that verified 69 worked but never tested 70, and described the program as capping positions "at ~70 bins" — so 69 was the value known to work, not a measured ceiling. It also sat one belowDEFAULT_BIN_PER_POSITION, which the SDK's own chunker passes toinitializePositionverbatim.Rather than reason about it further, simulated it against the Meteora SOL/USDC pool (
2sf5NYcY...) on mainnet:initializePositionInvalidPositionWidthInvalidPositionWidthInvalidPositionWidthInvalidPositionWidthis 6040 / 0x1798, thrown atprograms/lb_clmm/src/instructions/position/common.rs:30. The combined path's message is 819 bytes at width 70 against a 1232-byte limit, so neither transaction size nor the 10,240-byte CPI allocation limit binds before the program's own check does.So the cap is exactly
DEFAULT_BIN_PER_POSITION, and 69 was leaving a bin on the table — a 70-bin range was being funded over three transactions when it fits in one.One consequence worth naming, since it otherwise reads as an off-by-one: the threshold and the deposit chunk size are now the same number. A 71-bin range is one past what a single transaction holds but needs two chunks of 70 to cover, so the narrowest chunked open is three transactions, not two. There is no one-chunk chunked open.
A note for anyone building on this
openPositionis no longer atomic for a wide range, and cannot be — the deposit does not fit in one transaction. Callers that assumed open-position either fully succeeded or did nothing need to treat the partial-failure error as "the position may exist" rather than "nothing happened"; retrying blindly opens a second position.This also does not compose with a build/sign/submit flow that returns a single unsigned transaction, where the capture would fire on the position creation and hand back a transaction that opens an empty position with no error. There is no such flow in
developmenttoday, so nothing here is affected, but it is the obvious trap for anyone adding one: the fix is to refuse a chunked range at the build boundary rather than return its first transaction, since an open cannot be resumed by repeating the call — each call mints a fresh position keypair.Verification
pnpm build,pnpm typecheckandpnpm lintclean. Full suite 161 suites / 1460 tests passing (developmentbaseline: 159/1452).openapi.jsonregenerated and matching, so the CI spec check passes — the two new optional fields are the only change.New tests pin narrow-range-unchanged, wide-range chunking, amounts summed across transactions, rent read from the account, the partial-chunk failure message, and the >1400-bin refusal.
🤖 Generated with Claude Code
https://claude.ai/code/session_01WvS83dMFobrZtpcW1LRpWx