Repository navigation
fix: Gateway fixes bundle (Orca, Raydium, Solana sends and balances, PancakeSwap BigInt, Uniswap SDK dedupe, Infura for Solana, CNDR token) - #735
Open
fengtality wants to merge 59 commits into
Conversation
Jupiter, 0x, Uniswap and PancakeSwap executed any cached quoteId, so a quote from one connector could be run through another's execute path, with a cached shape and signing path it was never built for. DFlow, OKX and Titan each checked a `connector` field they stored inside the quote. The quote cache now owns the check: a quote is stored under its connector and `get`/`getRequest` return null to any other, so every execute path answers 400 "Quote not found or expired". One test runs all seven execute paths against another connector's quote; DFlow's own copy of that case goes.
The route called validateAddress outside any handler, so a malformed address
came back as a raw 500, and two tests asserted that 500. It now answers 400
with the chain's own message ("Invalid Solana address format: ..."), and the
tests assert it. The non-existent-wallet test is renamed to what it checks:
the removal succeeds.
… imports - test/lifecycle: jest ignored the directory, so none of it ran. One file sent live mainnet transactions behind a MANUAL_TEST skip, one checked its fixture JSON against itself, one called getPool with a stale signature. Its pancakeswap-sol fixtures go with it. - test/helpers, test/constants, test/mocks/0x, test/mocks/orca: no importer. - null-token-handling tested `?.` and `||` on local variables; tick-data-provider asserted its own mock and the v3-sdk Pool; the raydium sdk-breaking-changes, sdk-upgrade-integration and slippage files imported only the SDK; raydium-sdk-integration and raydium-setOwner-integration asserted mocks of the class under test (setOwner's reinitialisation stays covered in raydium.test.ts); orca-tick-array-compatibility tested a SDK call Gateway never makes. - The okx/titan/dflow/jupiter schemas tests listed the keys of response types used only as Static<> return types; tsc enforces them.
…at kept it alive - Raydium.executeTransaction, prepareWallet and the private getPairKey had no caller; findDefaultPool always returned null, so quotePosition's symbol-lookup branch (and its two symbol parameters) could never run. - Orca.getPools and getRawPosition had no caller. The API-to-schema mapping test now goes through getPoolInfo, which the pool-info route uses. - isSlippageError, isInsufficientBalanceError, HardwareWalletService listDevices and verifyConnection, PoolService.getChainForConnector: no caller. The PROGRAM_IDS test restated constants. - SolanaPriorityFees.clearCache and QuoteCache clear/size existed for tests. The priority-fee suite reloads the module per test instead, and its per-network cache test now proves separate entries (two fetches, two fees) instead of passing with one global key. - pancakeswap-sol getPositionsOwned took a poolAddress the route never passes. - handlePoolError matched "Unsupported chainNetwork", which nothing throws; its test only reached it through a mock that threw that text. The mock now uses the real parser and the test asserts its "Invalid chainNetwork".
- clmm/routes: the Schema Validation block's bare 400s mostly came from a different guard (chainNetwork and walletAddress have defaults), and the rest repeated the REQUIRE_A_FIELD table; the Connector Support tests only asserted "not 404" on a registered route. - The five "Replaced routes are gone" 404s: nothing in src can register a /connectors path, and a route added back would lack an operationId, which spec/operation-ids catches. - pool-address-pin's "old connector/type form" was the enum rejection the next test covers; that test now asserts the message. - response-components' "no longer publishes" names are guarded by the Amm.../Clmm... twin-naming test. - The unknown-connector enum rejection was asserted nine times through one connectorField helper; the two create-pool files held nothing else. - connector.routes booted a second app for what app.integration's trading-types test now checks: chain, networks, names.
- universal-router quoteSwap (Uniswap, PancakeSwap): two copies of the SELL
quote left over from the removed protocols filter; a jest.doMock('ethers')
that never reached the route, imported once in beforeAll; mocks of
getOrFetchToken, which src does not have. The eth/Eth and ETH/ETH cases
fold into their siblings, which take the same branches.
- universal-router: constructor tests that only checked toBeDefined.
- router-attempted-route: a check the two exact-string tests imply.
- okx: a "known vector" that computed one HMAC twice, and a query-string
test the exact-header test covers.
- 0x executeSwap: three tests that echoed mocked quoteSwap/executeQuote
results become one that checks the composition: a firm quote for the
wallet, then executeQuote on that quote.
- Rate limits: the Solana balances route's message tests asserted text the test's own mock wrote; the Ethereum interceptor's retry/pass-through tests repeated rpc/rate-limit-connection, where its three distinct ones (four attempts, JSON-RPC body, sendTransaction not retried) now live; two more hit branches already covered; the propagation "edge cases" tested the interceptor, not the chain, and the Ethereum provider-read tests passed a mocked rejection through a one-line wrapper. A dead heliusService stub goes too. - estimate-gas and status: "different networks", "consistent format", high and zero priority fees took the success path already covered; one missing-network default test is kept of four. - config: update-config-network-files asserted fs/yaml were not called by a module that never imports them (its error case moved to config-utils, which now also proves the 500 carries the cause); four config-routes duplicates, one of them a "slippagePct special case" that does not exist. GET /config/chains moves next to the other config routes. - services: error-handler's helper, throw/catch and instanceof tests restated httpErrors and JavaScript (the status names become one table); http-client property echoes; a source grep of findPools; a numeric passphrase that is still a string. - rpc: helius and infura URL cases that take the same branch (infura's chain map becomes one table); a websocket failure case the generic one covers. - The base58 wallet tests patched the key parser, so they repeated add/invalid-key; tokens' "update existing" hit the same mocked addToken; etherscan's six constructor cases become one supported, one not; three parser fallback codes become a table.
…adroom Raydium AMM v4 deposits failed with "Exceeded desired slippage limit" (0x1e) whenever the quote side was the limiting one (e.g. 0.05 SOL / 5.2 USDC into SOL-USDC), while the base-limited case built fine. The v4 branch capped the OTHER side at the user's raw amount (or the bare quote); the program computes that side from live vault reserves, which sit slightly off the API reserves the quote used, so the cap left no room and the deposit was refused at any slippagePct. The fixed side stays exactly what the user gave; the other side's max is now the quoted amount x (1 + slippage) — the same `...AmountMax` quote-liquidity already reports. otherAmountMin is unchanged. CPMM path untouched. Verified against mainnet-beta: 0.05/5.2, 0.049956589/5.2 and 0.05/6 into 58oQChx4... all build; CPMM BCdZ9d2J... still builds.
PancakeSwap's Solana CLMM is a fork with Raydium's account layout, so a PancakeSwap pool decoded cleanly through Raydium's getClmmPoolInfo and was served as a Raydium pool. getClmmPoolInfo now reads the pool account's owner first and answers null (the not-found path) unless it is Raydium's CLMM program.
…dium's API On mainnet-beta the AMM routes took pool info (reserves, LP supply, keys) from Raydium's HTTP API, which lags the chain, and decided AMM v4 vs CPMM from the API's programId. Quotes, liquidity quotes, position info and the add/remove builds could all price against stale reserves, and a pool the API did not know could not be used at all. - getAmmPoolType reads the pool account's owner: AMM v4 or CPMM, 404 for a missing account, 400 for any other program. - getAmmPoolFromRpc returns the SDK's chain-read poolInfo, poolKeys and rpcData for either type; every AMM route builds and quotes from it. getPoolfromAPI and getPoolType are gone. - getAmmPoolInfo throws instead of answering null for a pool it cannot read. - quote-liquidity feeds computePairAmount the reserves net of the PnL the pool owes out (AMM v4) and LP supply in whole tokens (CPMM), the units the SDK expects; position info computes the wallet's share from raw reserves and raw LP supply.
A swap from native SOL by a wallet with no wSOL account created that account twice: once as the missing input account, then again inside wrapSOL, which creates it too. The second create fails, so the swap could not run until the wallet had a wSOL account. For native SOL the wrap alone creates it now.
…point The PancakeSwap Solana routes loaded the wallet's keypair with getWallet, signed in the route, simulated and sent the raw transaction themselves. That works for a local keypair only: a Ledger wallet could not open, add to, remove from, collect fees on or close a position, create a pool, or swap, and the priority fee and compute budget were set by hand instead of the way every other Solana connector sets them. Every route now builds the transaction unsigned and hands it to Solana.sendAndConfirmTransactionForWallet, which simulates, sizes the compute budget, adds the priority fee and signs for the wallet's type, then re-fetches it with getConfirmedTransactionData like the other connectors. The builders keep a compute-unit limit for the simulation only and no longer take a priority fee; PancakeswapSol's unused setOwner, prepareWallet and signTransaction go.
…wap direction swap_v2 needs, as remaining accounts, the pool's first initialized tick array in the swap direction followed by the next initialized ones that way. The builder passed only the array holding the current tick, and skipped even that when it was uninitialized, so a swap failed whenever the current array was empty or the swap crossed into the next one. The arrays are now found from the pool's tick_array_bitmap and its bitmap extension (up to three, in the order the swap crosses them), and the extension account is passed ahead of them. A swap in a direction with no initialized array fails before sending, saying the pool has no liquidity that way.
… owner getAssociatedTokenAddressSync throws TokenOwnerOffCurveError when the owner is not on the ed25519 curve, which is every program-derived address. The PancakeSwap Solana builders, Orca's position token account derivations and Solana.wrapSOL / unwrapSOL (with the unwrap route) passed allowOwnerOffCurve false, so none of them could act for a wallet that is a PDA. They pass true now; for an on-curve wallet the derived address is the same.
…easing liquidity decrease_liquidity_v2 pays a pool's active rewards into the wallet's reward token accounts and fails AccountNotInitialized when one is missing, so remove, collect-fees and close could not run for a wallet without them. buildRewardAccountSetup creates each idempotently ahead of the decrease.
…ailure Sending signed bytes that have already landed (an identical transaction built on an unchanged blockhash, or a retry after a lost confirmation) makes the RPC answer "This transaction has already been processed". The send path logged it as a failed send and returned unconfirmed with no signature, so the caller saw a failure for a transaction that was on chain. The landed transaction's signature is the first one in the wire bytes; the send now fetches that transaction and reports it as it stands: confirmed, or unconfirmed if it landed with an error. If the RPC cannot find it, that is an error naming the signature.
… tests run the real hook - intent-is-not-dropped tested a copy of app.ts's x-connectors hook, and the copy had already drifted from production's message. The hook is now enforceConnectorFields in schema-keywords, installed by app.ts and by the test. Its "accepts" cases asserted only "not 400"; they now show the request reached the handler. - chain-network-guard matched a source string to keep liquidity routes from parsing chainNetwork themselves. It now sends every route of the CLMM and AMM plugins a Solana connector with an Ethereum selector and expects the refusal. That found the pool-swap routes (quote-swap, execute-swap) looking up a pool for meteora on "mainnet" and answering 404; they now refuse the pair with the registry's 400 first. - learns-pool-and-tokens asserted only that quotes record nothing. It now proves a fill records the pinned pool, and that a failed record does not fail the fill. - GET /pools' 400 test sent a chainNetwork the schema rejects, so the route's own mapping of a listPools failure never ran. - spec/addressing skipped routes whose chainNetwork has no enum; it now finds chainNetwork by name and requires it on every pools/tokens write.
- positionsOwned (Raydium, Meteora, Orca): "returns all positions" and "empty array" asserted the mocked connector result back; each keeps its invalid-wallet 400, and the kept behaviours stay. - Seven Orca "defaults network/wallet" tests exercised one schema default; one test now proves it through collect-fees: mainnet-beta and the configured wallet. - Orca: a copy-pasted poolInfo block, a duplicate not-found, three identical quotePosition rejections (now one, asserting 500), the pool-lookup fallback that pool-address-pin covers, a deployment check the first swap-quote test already makes, and binCount-above-max checks that trading/clmm/pool-info-bin-count owns. - Raydium: pool-not-found tests whose mock threw the 404 itself, a compute-unit constant, setOwner call-order counters, a base-shape echo and the binCount duplicate. - Meteora: an AMM pool-info fixture asserted back, three fetch-pools parameter tests (now one passing all four), an empty-result echo, a generic 500, a weaker copy of the price-impact test; the 50% removal case joins the delegation table.
- estimate-gas (both chains): the fee is the one number these routes compute, and no test checked it; the first test now asserts (fee/CU x CU + 5000 lamports) in SOL and gas price x 300,000 in ETH. - config-utils: the network-over-chain precedence test only checked toBeDefined; it now gives the chain a conflicting nodeURL and asserts the network's wins. - config-routes-v2 GET: the mocked getConfig threw the 404s and returned the configs the tests then asserted. The merge and 404 are tested for real in config-utils; the route tests now check what the route does — pass the namespace, answer with the result, pass a 404 through. - logger: the half of updateLoggerToStdout's test that proves the stdout transport is removed was commented out; it now counts console transports. - http-client: "removes trailing slash" asserted toBeDefined; it now checks the URL fetch receives. - rpc-provider-simple: asserted its own fixture and a stale provider list; it now checks rpcProvider comes from the chain namespace, defaults to "url", and nodeURL from each network's namespace.
- Ethereum's `chain` getter, "for backward compatibility", returned the network and had no reader. - rpc-connection-interceptor re-exported the Solana wrapper under its old name, createRateLimitAwareConnection; nothing imports it. - getConnector took a `_chain` it ignored, "kept for compatibility"; its one caller, the Ethereum poll route, no longer passes it.
parseTokenAmount went through Number.toFixed: (0.1).toFixed(18) is "0.100000000000000006", so every 0x quote for 0.1 of an 18-decimal token asked for 6 wei more than requested. It now converts through Decimal, rounding down. The quoteSwap/executeQuote tests mocked formatTokenAmount to return the amounts they asserted, and mocked getOrFetchToken and a TokenService no path uses. They now check the params sent to 0x by side, Gateway's amounts, the transaction the wallet sends and what the confirmation handler receives; the rewritten quote test is what found the rounding.
- Uniswap and PancakeSwap AMM quotes build a real v2 Pair from reserves and assert hand-computed outputs and each SDK's slippage rule; the "token not found" test now reaches the real guard instead of a mocked rejection. - The universal-router tests assert an exact V2 output; the no-route case now fails because every pool read reverts, not on a TypeError. - Jupiter and DFlow quotes assert a router fraction of 0.001 comes back as 0.1 percent, replacing the price-impact source scan (its unit tests stay). - create-pool asserts the fee-tier message; pool-info's bins test keeps only the call arguments, not the mock's output.
…and Orca add Raydium CLMM open-position and Orca add-liquidity threw a raw 500 for a malformed wallet or position address (new PublicKey / kit's address()); both now answer 400 before touching the chain, and their tests assert the exact status and message instead of "any status >= 400". Orca add's null-instance test, which passed only because a null instance (never returned by getInstance) crashed the route, now checks a failed position read: a 500 with its cause and nothing sent.
- Error tests that accepted "any status >= 400" assert the exact status and
message; three that passed only because a null Orca instance crashed the
route, which getInstance never returns, are deleted.
- Orca create-pool's tickSpacing case was rejected by the schema, not the
connector; it now sends binStep values the schema admits and hits the
connector's guard. removeLiquidity sends 0% for the same reason.
- Meteora execute-swap mocked SDK methods src no longer calls; it now checks
pool.swap for SELL and swapExactOut for BUY, each direction's tokens.
- pancakeswap-sol open-position encoding tested Borsh; it now decodes
Gateway's own builder output, so the {some: false} bug would fail it.
positionsOwned gives the NFT filter fungible accounts to drop.
- Raydium: the pool fee is read from a nonzero config; add-liquidity
asserts the base-limited amounts; Orca pool info and quote-swap assert
the fee and per-mint decimal scaling, not keys.
- position-rent's three distinct cases move next to liquidityWithoutRent's
other tests in account-lifecycle.
Simulation runs the transaction without the compute-budget instructions and reports only what the caller's instructions used, but the prepared transaction carries two of them (set limit, set price) at 150 units each. A percentage margin cannot cover a fixed cost: a plain SOL transfer simulates at ~150 units, gets a limit of ceil(150 x 1.1) = 165, then spends 450, so it lands on chain and fails with "Computational budget exceeded" after the user has signed. Every expensive instruction hid this behind its own margin; only the cheapest transactions hit it. Both the local-keypair send and the external-signer (Ledger) path now add the 450 units the budget instructions cost on top of the 10% margin.
getClmmPoolInfo logged a failed AMM config read and answered feePct: undefined. The config read now runs after the ownership/decode check and throws "could not read Raydium CLMM config <addr> for pool <pool>: <cause>" (or ": account not found"), which the pool-info route maps to a 500 with that message like any other non-HTTP failure. Pools that aren't Raydium's still answer null (404).
… SPL getTokenProgramForMint answered TOKEN_PROGRAM_ID whenever the mint read failed or returned nothing, so an RPC hiccup on a Token-2022 mint built a transaction against the wrong program. It now returns the owner only when it is SPL or Token-2022, and throws on a failed read, a missing account, or any other owner. The open-position encoding test served its pool account for every read, mints included; it now answers mint reads with an SPL-owned account.
When Orca's API had no pool (or the lookup failed), pool info answered tvlUsdc: 0, a real-looking figure for a pool nobody measured. It is now null, and OrcaPoolInfoSchema admits null. The API mapping also parses the string the API sends into a number, and a missing figure there is null rather than undefined. Nothing downstream requires the figure: the unified pool-info response doesn't carry tvlUsdc, and no in-process caller reads it.
new PublicKey(walletAddress) ran unguarded after the Solana and Orca instances loaded, so a bad wallet was a 500 "Invalid public key input". Wallet and position are now both parsed up front and a malformed one is 400 "Invalid <wallet|position> address: <value>", before any chain read. The connector's own "at least one amount" guard is gone: its only caller, the unified /trading/clmm/add route, rejects that case first with its own message, so the connector's could never fire.
…not found" fetchPoolInfo caught every error and answered null, so a rate-limited RPC, a failed fee() call or a connector's own read error all read as "pool not found". Now: - null only when the connector says it has no such pool; - an unsupported connector, or a connector without pool info for the type, is a 400; - any failure reading the pool is a 500 "Failed to fetch pool info for <pool> from <connector> <type>: <cause>". Callers: POST /pools, POST /pools/save/:address and GET /pools/find/:address (via fetchDetailedPoolInfo) already pass HTTP errors through, so the 500 reaches the client with its cause. Their null branches now answer 404 "Pool <addr> not found in <connector> <type> on <network>" instead of a 400 "Unable to fetch pool-info", whose handlePoolError case is gone.
smart-order-router and universal-router-sdk@4 depend on @uniswap/v4-sdk@1.27.0, which pins @uniswap/v3-sdk@3.27.0. Gateway and router-sdk use v4-sdk@2.3.0 and v3-sdk@3.31.0, so the lockfile had two copies of each and router-sdk's instanceof pool checks could fail with 'Unexpected pool type in route'. Override v4-sdk to 2.3.0 so one copy of each is installed.
Through the QuickNode Solana RPC, a transaction sent with skipPreflight is accepted (the RPC answers its signature) but never reaches a leader, so every send timed out at blockhash expiry. Rebroadcasting the same bytes every 2 s (15 resends) did not help; the same send with preflight on landed in about a second, and the same code through the public RPC lands either way. Every broadcast now uses one SEND_OPTIONS with preflight on at 'confirmed'. A transaction that preflight refuses was not broadcast, so the send now throws its parsed program error (slippage, balance, or simulation failed) instead of reporting an unconfirmed send with no signature.
Gateway sends with maxRetries 0, so the RPC hands the transaction to the leader once and a dropped send is never retried. While the send waits to confirm (WebSocket or polling), the same signed bytes are now sent again every REBROADCAST_INTERVAL_MS (2 s), with the same send options. The loop stops when the confirmation ends, when the block height passes the lastValidBlockHeight the confirmation uses, or when the RPC says the transaction was already processed. Nothing is re-signed and the blockhash is not changed, so the transaction can land only once. POST /chains/solana/submit (#721) sends through the same _sendAndConfirmRawTransaction, so it gets the rebroadcast too.
…ight Stopping the rebroadcast awaited the loop, so a slow getBlockHeight or resend held back a send that had already confirmed. Stopping now only sets the flag and wakes the timer; the loop catches its own errors and schedules nothing after it is stopped.
A resend that returns after the stop scheduled one more timer before the loop saw the flag. The loop condition now checks it first.
…test/fixes-integration
…t/fixes-integration # Conflicts: # src/chains/solana/solana.ts
…/fixes-integration
…o test/fixes-integration
…t/fixes-integration
This was referenced Oct 20, 2026
[High impact] The PR appears safe to merge, with no new actionable issues found. SummaryBundles fixes for Solana sends and balances, Orca and Raydium pool reads, PancakeSwap swaps, router quotes, and Infura support.
Reviews (3) · Last reviewed commit: "Merge branch 'feat/add-cndr-token' into ..." · Reviewed by Greptile |
The rate-limit interceptor had its own redactUrl that removed only query keys and user information. A 429 on an Infura URL (/v3/<key>) put the key in the error message, which public routes such as /chains/solana/balances return. The interceptor now uses the shared redactUrl from services/logger, which also redacts user information.
…ld up pool-info reads the pool on chain and the Orca API in parallel. The API request had no deadline, so a stalled API held up the chain answer until the connection failed. getPoolInfo now aborts after ORCA_API_TIMEOUT_MS (5 s), body included, and pool-info answers with tvlUsdc and yieldOverTvl null.
Contributor
Build and tests
Solana
Raydium
PancakeSwap-Solana
Meteora
Orca
PancakeSwap EVM (BSC)
Found while testing this PR's changes to these routes that are pre-existing on development branch ❗
|
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.
Summary
This PR bundles six fix PRs and one token-list addition into one. Each fix branch is merged with its own commits, so every commit message still gives that fix's full reason. It replaces #723, #724, #725, #726, #731 and #733, which are closed. Their descriptions have the details.
Closes #713. Closes #684.
@uniswap/v3-sdkgave "Unexpected pool type in route". Onepnpmoverride now dedupes them.feat/add-cndr-tokenESqMjA15hbH5NnHqjXo4S1A4CU4hPuKjkvi2mJ3oEihC, 6 decimals) to the default Solana mainnet token list.skipPreflight: truesends. Sends now run preflight and are rebroadcast every 2 s until they confirm or expire.Review fixes (added in this PR)
ca384c41cInfura keys in rate-limit errors. The rate-limit interceptor had its own URL redactor, which missed Infura's/v3/<key>path keys. A 429 put the key in errors that public routes return. The interceptor now uses the shared redactor fromservices/logger, which also redacts user information. This also affects the Ethereum Infura provider.d9d74c461Orca pool-info held up by a stalled Orca API. The Orca API lookup now has a 5 s deadline that also covers reading the response body. When the deadline passes, pool-info answers from the chain withtvlUsdcandyieldOverTvlnull.Conflict resolved
The only conflict was in
Solana._sendAndConfirmRawTransaction, between #723 and #733.SendTransactionError, so it must report the transaction that landed before anything treats the error as a refusal.Testing (merged branch)
pnpm buildpasses.pnpm generate:openapileavesopenapi.jsonunchanged.pnpm linthas 0 errors.pnpm test -w 3passes 173 suites (1364 tests), run from a cleanconf/as CI does.🤖 Generated with Claude Code