Repository navigation
Sync / Development to Staging v2.17 - #698
Conversation
Adds a Meteora DAMM v2 integration under /connectors/meteora/amm/*, mirroring the Raydium AMM connector and backed by @meteora-ag/cp-amm-sdk. Endpoints: pool-info, position-info, quote-swap, execute-swap, quote-liquidity, add-liquidity, remove-liquidity, and create-pool (with a generic CreatePool schema added to amm-schema.ts). DAMM v2's custom features are mapped onto the AMM interface (NFT positions, sqrt-price accounting, config-driven pool creation, base/dynamic fees, Token-2022, locks/vesting) and documented — with proposed enhancements — in docs/connectors/meteora-damm-v2.md. create-pool requires an explicit configAddress because many permissionless configs are launch configs with ~99% starting fees. Unit tests mirror the Raydium AMM suite (pool-info, quote-swap BUY/SELL, quote-liquidity, create-pool guard). typecheck + lint clean; full Meteora suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY
create-pool now resolves the initial price in priority order: explicit initialPrice, then explicit quoteTokenAmount ratio, else the live market price fetched from the unified swap router (/trading/swap/quote → Jupiter on Solana). Only baseTokenAmount is required; the quote side is derived. If the base token has no market to price against, it throws a clear error asking for initialPrice/quoteTokenAmount rather than guessing. This prevents opening a pool off-market and being immediately arbitraged/sniped (as observed in live testing: a pool seeded at 0.001 was repriced to ~0.0039 by MEV bots within one slot; the router quote returns 0.00385, i.e. the correct on-market seed price). The seed price is returned as `price` in CreatePoolResponse. quoteTokenAmount is now optional; initialPrice added to the request schema. Doc updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY
…ed /trading/amm/create-pool Adds pool creation across AMM connectors, matching the Meteora DAMM v2 create-pool: - Raydium: cpmm.createPool (CP-Swap), single-tx, dynamic fee-config from the Raydium API, mainnet/devnet program selection, market-price seeding (anti-snipe). - Uniswap: V2 router.addLiquidity auto-creates the pair (fixed 0.30% fee), pair address read from the factory; ETH/WETH via addLiquidityETH; allowance checks; market-price seeding. - Unified POST /trading/amm/create-pool dispatches on connector (meteora|raydium|uniswap), carrying per-connector optionals (configAddress / feeConfigIndex+openTime / gasPrice+maxGas). All three share the generic CreatePoolResponse. Seed price priority everywhere: initialPrice -> quoteTokenAmount ratio -> live market price from the unified swap router. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY
…/* for all 7 operations
Standardizes Meteora/Raydium/Uniswap AMM route files to export a uniform network-based
function per operation (getPoolInfo, getPositionInfo, quoteSwap, executeSwap, quoteLiquidity,
addLiquidity, removeLiquidity), each returning the shared amm-schema type — mirroring how the
unified CLMM routes consume connectors.
Adds unified cross-connector routes under /trading/amm (pool-info, position-info, quote-swap,
execute-swap, quote-liquidity, add-liquidity, remove-liquidity) that dispatch on `connector`,
alongside the existing create-pool. Uniswap V2 wrappers derive the quote token from the pair
(token0/token1); Uniswap create-pool validation errors now return 400 (not 500).
Updates the unified swap router (trading/swap/{quote,execute}) to the new connector signatures.
Tests: adds create-pool unit tests for Raydium (CPMM) and Uniswap (V2) and a unified-dispatch
test; existing AMM + swap tests pass (54 total).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY
…ess and drop quoteToken
CLMM swaps are pool-addressed, so the counter ("quote") token is fully determined by
poolAddress + baseToken. Standardizes the exported quoteSwap/executeSwap wrappers for
Meteora/Raydium/Uniswap CLMM to the signature (network, [walletAddress,] poolAddress,
baseToken, side, amount, slippagePct?) — matching the AMM connectors — and derives the
counter token from the pool via a per-connector resolveCounterToken helper
(dlmmPool.tokenX/Y, poolInfo.mintA/B, pool-info base/quote).
Updates the unified swap router (trading/swap/{quote,execute}) CLMM call sites accordingly,
and the Meteora/Raydium execute-swap route handlers + the Uniswap execute route (which now
resolves the pool via findDefaultPool before calling the poolAddress-required function).
An unknown base token not in the pool now returns 400 (was 404). Tests updated to
argument-based token mocks; all CLMM + trading suites pass (69).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY
…3 + unified /trading/clmm/create-pool Adds pool creation to all three CLMM connectors. create-pool CREATES + INITIALIZES a pool at an initial price (no position seeded — concentrated-liquidity positions need a range, opened separately via open-position). Initial price priority: initialPrice -> live market price from the unified swap router (anti-off-market default). Reuses the shared CreatePoolResponse. - Meteora DLMM: DLMM.createCustomizablePermissionlessLbPair2 (Token-2022 aware); activeId derived from initialPrice via the SDK's own getPricePerLamport/getBinIdFromPrice math; canonical mint ordering with price inversion; binStep + feeBps required. - Raydium CLMM: raydiumSDK.clmm.createPool with an ammConfig from api.getClmmConfigs() (ammConfigIndex), initialPrice as Decimal; SDK handles mint ordering + price inversion. - Uniswap V3: NonfungiblePositionManager.createAndInitializePoolIfNecessary (added factory getPool, pool slot0, and NFT-manager ABIs); initialPrice -> sqrtPriceX96 with decimal + token0/token1 orientation handling; fee tier param (100/500/3000/10000). Unified POST /trading/clmm/create-pool dispatches on connector, carrying per-connector extras (binStep/feeBps, ammConfigIndex, fee/gasPrice/maxGas). Tests: per-connector validation + unified dispatch; full suite green (104). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY
…p/executeSwap Extends the CLMM swap standardization to the remaining pool-addressed connectors: quoteSwap/ executeSwap now require poolAddress and drop quoteToken (derived from the pool via a per-connector resolveCounterToken) — orca via getWhirlpool tokenMintA/B, pancakeswap via getPancakeswapPoolInfo, pancakeswap-sol via getClmmPoolInfo. Swap-router call sites updated. Router-only connectors (0x/dflow/jupiter/okx/titan) keep quoteToken by design (no pool). All Gateway pool-based DEX swap functions now share one signature. tsc clean; 235 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY
…l for orca/pancakeswap/pancakeswap-sol
Finishes standardizing every pool-based Gateway DEX so all share one interface.
Standardization:
- PancakeSwap AMM: standardized the 7 ops to network-based signatures (getPoolInfo, getPositionInfo,
quoteSwap, executeSwap, quoteLiquidity, addLiquidity, removeLiquidity) mirroring uniswap AMM,
and wired pancakeswap into the unified /trading/amm/* dispatch (+ swap-router AMM branches).
create-pool (create + initialize at price; no position seeded):
- Orca (Whirlpools): client.createPool with tickSpacing + initialTick (PriceMath), PoolUtil.orderMints.
- PancakeSwap AMM (V2 fork): router.addLiquidity auto-create; CLMM (V3 fork):
NonfungiblePositionManager.createAndInitializePoolIfNecessary, fee tiers 100/500/2500/10000
(+ V3 factory/pool/NFT-manager ABIs).
- pancakeswap-sol CLMM: manual create_pool instruction from the bundled IDL (discriminator + PDA
seeds decoded from IDL bytes), sqrt_price_x64 from priceToSqrtPriceX64, explicit ammConfig.
- Unified /trading/{amm,clmm}/create-pool dispatch extended to all six pool-based connectors.
Router-only connectors (0x/dflow/jupiter/okx/titan) are excluded by design (no pool). All connector
+ trading tests pass (511); tsc/lint/build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY
…s, add positions-owned) DAMM v2 positions are NFTs, not fungible LP; a wallet may hold several per pool, each an independent asset differing in size, lock state, and fees. The prior AMM routes aggregated on read but silently acted on the largest position on write, so "remove 100%" was not a true exit when several existed. Make the Meteora AMM routes position-addressed (mirroring CLMM): - remove-liquidity now REQUIRES positionAddress; percentage applies to that position (found via the owner-filtered getUserPositions, which also proves ownership + pool membership). - add-liquidity takes an OPTIONAL positionAddress: provide to add to that position, omit to open a NEW position NFT (never silently the largest). - position-info returns the pool aggregate PLUS a positions[] breakdown (positionAddress, lpTokenAmount, base/quote per NFT) — the discovery path. - new positions-owned route lists all of a wallet's DAMM v2 positions across pools (getPositionsByUser grouped by pool). Unified /trading/amm/* carry positionAddress (required for meteora on remove, optional on add) and expose positions-owned; fungible-LP AMMs (raydium, uniswap, pancakeswap) ignore positionAddress and reject positions-owned with a clear error, since a fungible LP balance has no enumerable positions. Shared PositionInfoSchema gains an optional positions[] (PositionDetail). Adds unified-dispatch tests (meteora remove without positionAddress -> 400; fungible-LP positions-owned -> 400). Updates the DAMM v2 connector doc. typecheck/lint/build clean; 84 meteora+trading tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1YS88ZAY2rXVLmgvyN4oY
…e impact into pool seed price Quoting the full seed amount returned the average execution price of that trade (impact + routing fees included), opening the pool below market for large seeds. Probe with a small fraction instead to approximate the marginal price. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eSwap The CLMM execute-swap route encoded the quote's *average* execution price as sqrtPriceLimitX96. The pool's marginal price crosses the trade's own average partway through any swap, so the router stops consuming input at the limit and the partial output fails the amountOutMinimum check — reverting "Too little received". This is near-guaranteed for any swap with more than ~a tick of price impact and wastes the reverted tx's gas. Verified on mainnet against the USDM1/USDC 0.01% pool: an 886-token SELL reverted twice with the limit set (decoded limit == the quote's average execution price, −6.5 bps from spot), while the identical calldata with sqrtPriceLimitX96=0 fills completely at the quoted amount. Pass 0 (no limit) instead — slippage protection already comes from amountOutMinimum / amountInMaximum, the same convention the Universal Router path uses. Also drop the now-unused encodeSqrtRatioX96 and stray mathjs imports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNCRsj5xiEafcCYebDdUmz
feat(meteora): add DAMM v2 (cp-amm) AMM connector
… PRs pull_request[closed] only fires for PRs merged via GitHub, so direct pushes and locally-merged pushes to development never rebuilt hummingbot/gateway:development. Push triggers cover both cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(orca): migrate connector to current Whirlpools SDK
…imit fix(uniswap): CLMM executeSwap reverts "Too little received" — sqrtPriceLimitX96 set to average execution price
ci: build docker images on push to main/development
…ard; orca position-info contract Gateway-side changes for the gateway#678 retry-ownership work (see docs/retry-architecture.md, included here as the canonical cross-repo design): - solana-error-parser: map Orca Whirlpool 6018 TokenMinSubceeded to SLIPPAGE_EXCEEDED, and attribute custom program errors to the program on the "failed: custom program error" log line instead of the first "invoke" line — simulation-shaped errors open with ComputeBudget, so the DEX-specific error tables were never consulted (the actual #678 MATH_OVERFLOW misreport mechanism). Regression-tested with a full simulation-shaped log. - solana: reject transactions whose compute-estimation simulation returned an error, in both send paths — stale-state failures become a typed 400 before broadcast (zero fees) instead of a broadcast failure. - orca: getPositionInfo returns null ONLY when fetchMaybePosition reports the account does not exist; transient errors now propagate. Callers treat null as "position closed", so a swallowed RPC blip could abandon a live funded position while reporting success. Deliberately NOT included: the in-route close retry loop from 040e99e. Gateway stays a stateless transaction oracle — one request, one attempt, typed errors; retry ownership lives in the Hummingbot connector/executor (see the doc, §6). Validated live on mainnet: forced-failure cascade (fault-injected minimums) had every doomed close rejected pre-broadcast at zero fee cost across 33 attempts, with 6018 correctly surfaced as SLIPPAGE_EXCEEDED. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HahKfEY9rvKnZijrzUAFSq
getTransaction (commitment 'confirmed') returns null both for a transaction awaiting confirmation and for one the cluster has never seen, so /poll reported txStatus 0 (pending) for dropped transactions forever — pollers had no signal to stop waiting on a transaction that can never land once its blockhash expires. The poll route now consults getSignatureStatuses (with history search) when txData is null: a signature the cluster has seen stays UNCONFIRMED (0); an unknown signature returns the new NOT_FOUND (-2), as does a malformed signature. -2 avoids colliding with the Ethereum poll's existing 2/3 mempool heuristics. Transient RPC errors still report UNCONFIRMED so callers keep polling rather than giving up on an unknown outcome. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the design-evolution narrative (proposals, verdicts, review logs, deployment diaries) with a clean description: the ownership principle, the sixteen issues found across the four repos, and the architecture as it now stands — layered ownership, close-vs-open asymmetry, the close lifecycle, terminal semantics, the two topologies with the orphan lifecycle, and the bounded transaction-status polling contract (including the new NOT_FOUND poll status). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…reverts as FAILED
The two poll routes spoke different dialects: Solana used a local enum
(-1/0/1), Ethereum used raw numbers including 2 ('likely to be processed')
and 3 ('likely stuck') that no consumer understood, reported not-found as -1
after blocking the request for three 1-second in-route retries, and — via
'typeof receipt.status === number ? 1 : -1' — reported REVERTED transactions
(status 0, which is a number) as CONFIRMED, so a reverted swap polled as
filled.
Both routes now share TransactionStatusCode in chain-schema:
NOT_FOUND (-2) / FAILED (-1) / PENDING (0) / CONFIRMED (1).
Ethereum: not-found returns -2 immediately (no in-route sleeps — the caller
owns pacing and the not-found deadline), mempool is plain PENDING (gas-price
heuristics dropped), and receipt status 0 maps to FAILED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Updates the retry-architecture doc for the two follow-up changes: the connector's retryable-code opt-in and inner budget are gone (the executor's CLOSING re-entry with max_retries=0 per attempt is the only close retry loop), and both chains' poll routes now share one TransactionStatusCode contract — including the Ethereum findings (2/3 heuristics, in-route retry sleeps, reverts reported as confirmed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CLMM positions-owned route prepared a wallet from Gateway's own wallet store just to read positions, so any address that was never added to Gateway (a browser wallet, any visitor's address) failed with "Wallet not found" instead of returning its positions. Meteora and Orca already enumerate on-chain from the bare address. Raydium now does the same: enumerate the owner's NFTs across the SPL Token and Token-2022 programs, batch-check which ones have a personal position account on the CLMM program, and read those. The AMM position-info route had the same bug — it prepared a wallet only to get a public key for a balance read — and now just parses the address. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nfigured With apiKeys.coingecko set, the service targets pro-api.coingecko.com/api/v3/onchain with x-cg-pro-api-key (same /networks paths, pro rate limits); keyless keeps the public GeckoTerminal host. Verified live: pool discovery for META returned 55 pools through the pro endpoint.
…imate CLMM execute-swap returned `quote.estimatedAmountIn` / `estimatedAmountOut` as `amountIn` / `amountOut`, and derived `baseTokenBalanceChange` / `quoteTokenBalanceChange` from them. The receipt was already in hand — used a couple of lines earlier to log gas — but never read, so a pre-trade estimate was reported as a post-trade fact. The estimate and the fill diverge exactly when a caller needs to know: the pool moves under the trade, or the trade is sandwiched. A mainnet USDM1/USDC buy on 2026-09-08 reported 1321.771489 USDC while the pool actually took 1323.079017 — a 1.31 USDC gap on a trade whose whole edge was 0.63, invisible from the API and only found by reconciling wallet balances by hand. Because a clean fill and a sandwiched one produced identical responses, a caller doing P&L from `quoteTokenBalanceChange` would book a profit that never existed. Read the amounts from the pool's own `Swap` event instead. Its two amounts are signed from the pool's perspective, so the input and output sides are identifiable without resolving token0/token1 ordering, and filtering the log by pool address keeps a multi-pool transaction from being misread. The quote is kept only as a last resort, when no matching Swap event is present. That should not happen for a settled single-pool swap, so it logs a warning rather than passing silently — but it must not throw, since this code has already been careful not to lose the hash of a transaction that landed (see the PENDING branch above it). The same construction appears in the uniswap AMM and both pancakeswap execute paths. Those are left alone here: V2 and PancakeSwap V3 emit different Swap shapes, and I have only verified this decode against a real Uniswap V3 fill. Tests cover the executed amounts, the derived balance changes, a foreign pool's Swap event in the same transaction, and the no-event fallback. The first three fail against the previous behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WvS83dMFobrZtpcW1LRpWx
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
positions-owned answers "what does this wallet hold", and it was wrong in two ways that both produced a silently short list. It skipped positions with zero liquidity. The NFT is still owned and still counted by balanceOf, it can hold uncollected tokensOwed after a decrease, and it can be increased again or burned — so dropping it hides real inventory. Wallet 0xfC17747C89E93E2deeAdA88419e857a907A20313 owns four v3 positions on Ethereum mainnet (390655, 465081, 622939, 638201), all drained but unburned: balanceOf returns 4 and the route returned an empty array. Callers that want only active liquidity can filter on it themselves. It also caught per-position read failures, logged a warning and returned the rest. An RPC hiccup on one position was indistinguishable from that position not existing, and callers size new exposure against that list. Both that catch and the "pool not found" branch now throw with the position index and reason, and a final check rejects the result if the resolved count does not match balanceOf — which catches the wallet's balance changing mid-enumeration, since tokenOfOwnerByIndex is index-based. Same two bugs, same fix, in the pancakeswap CLMM connector, which is the same v3 enumeration code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KScEEixyH7K3JJiaajGxEv
The rewritten position discovery derived every personal-position PDA with the mainnet CLMM_PROGRAM_ID, so on devnet it produced addresses that do not exist, filtered out every candidate NFT and reported an empty wallet. The enumeration it replaced tried both program ids explicitly, so this was a regression rather than a pre-existing gap. Caught by greptile on the PR. getClmmPosition had the same hardcoded id, and getPositionsForWalletAddress calls it through getPositionInfo — so fixing only discovery would still have dropped every devnet position one step later. Both now go through a clmmProgramId getter that picks by network, matching how the connector already chooses its SDK cluster. The test mock defined DEVNET_PROGRAM_ID.CLMM_PROGRAM_ID as a copy of mainnet's, which is not what the SDK ships and meant no test could observe the network being ignored. It now carries the real devi51mZ… program, and a case asserts the derivation uses the right one on each network. That case fails against the previous behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KScEEixyH7K3JJiaajGxEv
The previous commit treated any configured apiKeys.coingecko as a Pro key and sent it to pro-api.coingecko.com under x-cg-pro-api-key. Demo and Pro keys are not interchangeable: each is accepted only on its own host, under its own header. A Demo key therefore went from working (public GeckoTerminal host) to failing every call. The key itself does not say which plan it belongs to — both are CG-prefixed — but CoinGecko does. Asked on the wrong host it replies with the root URL to use instead (error 10010 for a Pro key on the Demo host, 10011 for the reverse), so that reply resolves the plan rather than a guess. Requests now go through one wrapper that, on exactly that error, rebuilds the client for the host CoinGecko named and retries once. Anything else — an invalid key, a missing pool — is raised, not degraded into a keyless request. apiKeys.coingeckoPlan pins the plan to skip the probe. Left blank it starts on Pro and corrects itself once, so an existing Pro deployment is unaffected. Keyless still uses the public GeckoTerminal host. Verified against a live Pro key: resolves to pro-api and returns data. Tests cover keyless, both explicit plans, the Demo correction, and an invalid key raising instead of switching. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KScEEixyH7K3JJiaajGxEv
Gateway builds its Ethereum providers with `throttleLimit: 1`, on the
reasoning that ethers should not retry 429s so the rate-limit interceptor
can be the single retry layer. That setting is also what stopped the
interceptor from ever seeing them.
In ethers' `_fetchData`, an HTTP 429 sets `tryAgain` and re-enters the fetch
loop, whose bound is `throttleLimit`. At 1 the loop exits immediately and the
request ends at the final `throwError("failed response", ...)`, which carries
only requestBody, requestMethod and url. Status, headers and body are gone,
so nothing reaches `is429Error` to match on and the read fails outright.
Measured against a node limited to 125 requests/second, 401 concurrent
eth_calls produced 185 failures and `is429Error` recognised none of them.
`throttleCallback` is consulted before that retry. Returning false clears
`tryAgain`, so the 429 leaves through the non-2xx path instead and throws
"bad response" with `status: 429` intact. Ethers still does not retry — the
interceptor stays the single retry layer — but the error is now diagnosable.
Same test after the change: 401/401 succeed, the interceptor backing off and
retrying as it was always meant to.
Both settings now come from one `rateLimitAwareConnection()` helper, so the
two cannot drift apart at the six provider construction sites.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014hLnD76Zfr76sBBeAURMJE
computeV3BinDistribution, shared by uniswap and pancakeswap, read every bin
boundary as `poolContract.ticks(tick).catch(() => ({ liquidityNet: 0 }))`.
`ticks` is a public mapping getter: an uninitialized tick returns zeros and it
cannot revert for an in-range int24, so that catch never caught an absent
tick. The only thing it caught was a transport failure, which it then recorded
as "no liquidity change at this boundary".
The result is not a missing field but a confidently wrong shape. A
binCount=400 request fires 401 concurrent eth_calls, enough to draw 429s from
a rate-limited node, and each one silently erased a real liquidity step.
Measured on Uniswap V3 WETH/SPY, the endpoint reported 88-100% of spot
liquidity out to +-10%. Read from the pool's own tick bitmap, the truth is 33%
at -10% and 7.5% at +10% - a profile falling twelvefold reported as flat, with
nothing in the response to tell the two apart.
Rejections now propagate, and the walk no longer provokes them: it reads the
tick bitmap first and then reads ticks() only at boundaries that carry
liquidity, which for a 401-bin window is a couple of word reads plus a few
dozen calls rather than 401. A boundary the bitmap reports uninitialized has
liquidityNet zero by definition, so skipping it loses nothing. The remaining
reads go out in bounded batches and retry transport failures a few times
before giving up, after which the error still propagates.
Post-fix the endpoint reproduces the raw tick walk exactly at every width
tested, and that walk is independently confirmed by integrating to
pool.liquidity() at spot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014hLnD76Zfr76sBBeAURMJE
…ions feat(meteora): open a price range wider than one transaction can fund
…ko-pro-test-mocks fix: executed swap amounts, complete CLMM position listings, raydium position discovery, CoinGecko Demo/Pro endpoints, and two unmocked test suites
|
| 'Taker the quote is priced for. Required by routers that quote per-wallet or return wallet-specific calldata.', | ||
| }), | ||
| ), | ||
| approximateIfNoExactOut: Type.Optional( | ||
| Type.Boolean({ | ||
| description: |
There was a problem hiding this comment.
Executable quotes misdirect proceeds
The unified quote endpoint lets walletAddress be omitted or differ from the wallet later supplied to /execute-quote. Uniswap embeds the quote-time recipient in its cached calldata, using 0x0000000000000000000000000000000000000001 when no wallet is provided, while execution does not compare the cached wallet with the execution wallet. A funded execution can therefore spend the executing wallet's input while sending the output to the placeholder or another quote-time recipient. Require the executing wallet when creating an executable quote and reject execution when it does not match the cached request.
…ames The trading routes shared one default wallet: a module-level constant read from the Solana config, reaching Ethereum only when that config threw, baked into every route's schema as `default:`. Two things followed from that, and neither was confined to execute-swap. Fastify injects schema defaults before the handler runs, so an EVM request that omitted walletAddress arrived already carrying a Solana address, and the handler had no way to tell it apart from one the caller had typed. A BSC swap failed in address validation naming a Solana wallet with nothing to do with the trade (#701). Nothing was lost - it stops before a wallet is opened or anything is signed - but the field is documented as optional and was not usable on any EVM network. And the constant was evaluated once at import, so /wallet/setDefault wrote the chain config at runtime and the routes kept injecting the old wallet until Gateway restarted. That one hit Solana too. The report found this on /trading/router/execute-swap; it was every trading route that takes a wallet - 15 of them, the three router routes plus AMM and CLMM add/remove/open/close/collect-fees/create-pool/positions-owned/position-info. walletAddressField now carries no default and the wallet is resolved in the handler, against the chain already parsed from chainNetwork. An unset default throws a bad request naming the chain instead of passing an empty string down to a connector. The per-chain lookup already existed as a private helper in chains/chain.routes.ts, one directory over from the routes that needed it. It moves to chains/default-wallet.ts and both callers share it, so there is one answer to "what is the default wallet for this chain" rather than two that disagreed. This is the same hazard the repo already documented for `connector` in uniswap/router-routes/quoteSwap.ts - "AJV injects defaults before the handler, so one here would hand resolveSwapConnector the first router in the registry" - down to the same detail that on Ethereum the value being injected is a Solana one. It was fixed there with `defaulted: false` and left standing here. Covered by default-wallet-per-chain.test.ts, including a pin that the schema carries no `default`, which is the part that made the wrong wallet indistinguishable from a deliberate one. Closes #701 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Routes changed, so the published document has to follow: walletAddress loses its `default` and its place in `required` on all 15 trading routes, and picks up the description saying it falls back to the chain's configured wallet. Worth noting what the old document said. The generator substitutes a placeholder for real addresses, and every one of these rendered as `"default": "<solana-wallet-address>"` - including on the EVM routes. The wrong chain's wallet was not only injected at runtime, it was advertised as the default in the API docs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(trading): resolve the default wallet from the chain the request names
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>
fix: slippage units, unbounded position enumeration, and two misleading guards
Before submitting this PR, please make sure:
A description of the changes proposed in the pull request:
Tests performed by the developer:
Tips for QA testing: