Skip to content

feat(trading): unify the route surface, and fix what validating it surfaced - #683

Merged
fengtality merged 81 commits into
developmentfrom
feat/unified-trading-routes
Aug 26, 2026
Merged

fengtality merged 81 commits into
developmentfrom
feat/unified-trading-routes

Conversation

@fengtality

@fengtality fengtality commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Route unification for /trading, plus the defects found while validating it against Solana mainnet. Supersedes #679, which was opened from a branch that has since been replaced; the work continues here against feat/unified-trading-routes.

The spec went from 182 paths to 54: the trading type is now a path segment and the connector a parameter, and openapi.json is generated from the route table by pnpm generate:openapi without a running server.

Was Is
/connectors/{dex}/{type}/* (128 paths) removed
/trading/swap/{quote,execute} /trading/router/{quote-swap,execute-quote,execute-swap}
/connectors/{dex}/{amm,clmm}/quote-swap /trading/{amm,clmm}/quote-swap
/trading/amm/{add,remove}-liquidity /trading/amm/{add,remove}
/chains/{solana,ethereum}/* (14 paths) /chains/{chain}/* (6) + 2 EVM-only

Security: every route that signs is gated

isSensitivePath enumerated paths, and /chains/* was not among them — so unauthenticated POST /chains/ethereum/approve could drain ERC-20 allowances (GHSA-r4q2-79mv-2355). The gate now derives from what a route does, with a spec-driven test asserting that every route returning a signature it was not given is gated. Enumerating is what let this open in the first place.

Money survives the wire

Response money fields were typed number, so a JSON float carried every amount: 0.1 + 0.2 arrives as 0.30000000000000004, and a lamport amount past 2^53 arrives rounded. DecimalNumber declares string + format: decimal in the spec while keeping the TypeScript type number, so Fastify serializes at the boundary and no connector arithmetic changes — typing the schemas as strings outright produced 721 type errors, because these schemas are the domain types the connectors compute with. Both Python generators map it to the same Decimal as before.

A request that would be misread is now rejected

Fastify's AJV defaulted to removeAdditional: true, so a key a route never read was stripped in silence. Turning strictness on immediately caught five payloads in this repo's own tests: a legacy fee beside feeBps (twice), ammConfig where the schema says ammConfigIndex, protocols on a router quote that filters nothing, and accountIndex/name on add-hardware — so a caller naming their Ledger lost the name silently.

Reported amounts are the money that moved

  • Position rent is not liquidity. A DAMM v2 open recorded rent as deposited liquidity at 2.86x its real size; a Raydium close backed out one rent and booked the other three as liquidity at 4.1x. accountLifecycleSol reports every opened/closed account's lamports, with the rent share separated.
  • Native SOL amounts netted the transaction fee out and reported it separately.
  • pancakeswap-sol reported fees and rent as a hardcoded 0 on close and on read. Fees are now computed from tick-array growth, verified to the unit against a simulated collect on three live mainnet positions; the open's { some: false } Borsh encoding (which encodes as Some(TRUE)) is fixed, so the slippage maxes are ceilings again.
  • priceImpactPct was a fraction in a field documented as a percentage — 100x low. Converted at the router boundary; Meteora's hardcoded 0 now computed. Five connectors verified to agree.

A close can say how much slippage it will accept

/trading/clmm/close takes slippagePct, which is what lets an executor widen its tolerance across retries rather than repeating an identical request (see the hummingbot PR). uniswap and pancakeswap stop hardcoding 1%.

Companion PRs

Validation

tsc clean; 1440 tests across 158 suites pass. Validated live on Solana mainnet: all three swap types, the full CLMM lifecycle on Meteora and Orca across all three range shapes, the Raydium AMM round trip, a Meteora DAMM v2 open, and fee collection against a position that had fees.

The pancakeswap-sol fee math was verified against the program itself: for three live positions, computed fees equalled a simulated collect's transfers to the unit (16224872/1195910 in-range; 60792125/13625723 out-of-range; 0/0 where nothing moved).

🤖 Generated with Claude Code

https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK

fengtality and others added 30 commits August 12, 2026 18:20
…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>
Found in live testing: /poll parsed only JSON.stringify(meta.err), which
carries the error code but names no program — so extractProgramId never
matched, every program-specific code fell through to the generic map, and a
confirmed-but-failed Orca transaction reported 'UNKNOWN (0x1782)' instead of
SLIPPAGE_EXCEEDED. Same misreporting as gateway#678, on the async path.

The err object is now parsed together with meta.logMessages, whose 'Program X
failed: custom program error' line is what the parser attributes on. Errors
raised by programs with no registered table (e.g. a third-party router that
CPIs into Whirlpool) correctly stay generic rather than being misattributed
to the DEX they called.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Swap

GET /trading/clmm/pool-info could never return `bins`: its querystring schema
had no binCount and it called every connector as (fastify, network,
poolAddress), dropping the parameter that orca/raydium/uniswap already
support. Since hummingbot-api and condor read pool info through the unified
route, the bin distribution was unreachable outside the per-connector routes.

- Unified route accepts binCount and forwards it. Meteora is called without
  it, as it always returns its own bins.
- PancakeSwap CLMM gains binCount. The V3 tick walk moves to a shared
  clmm-v3-utils helper: the two SDKs disagree on numeric type (@uniswap/v3-sdk
  is JSBI, @pancakeswap/v3-sdk is native bigint), so the helper works in bigint
  and each connector adapts its own SDK rather than importing the other's.
- Fixes PancakeSwap pool-info token amounts, which were pool.liquidity (V3
  virtual liquidity in sqrt-price space) scaled by each token's decimals —
  reporting the same meaningless figure for both sides. Now ERC20 balanceOf on
  the pool contract, the same fix Uniswap already carries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
binance.llamarpc.com is dead — it answers nothing, so every BSC read failed
(pancakeswap pool-info reported 'Pool not found' for pools that exist).
bsc-dataseed.bnbchain.org is BNB Chain's official public endpoint and
returns chainId 0x38.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eth.llamarpc.com was unreachable — gateway logged 'Unable to fetch block
number' on every startup and all mainnet reads failed. eth-mainnet.g.alchemy.com/public
returns chainId 0x1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The audit that #678 triggered found defects on two read paths the LP flow
depends on but that the issue never named — the transaction-status contract
the poller reads, and the pool-info contract the dashboard and agents read.
Both belong here: they share #678's root cause, a caller unable to tell a
definitive answer from a transient one, or unable to ask for what it needs.

Adds the CLMM pool-info issues (binCount unreachable through the unified
route, PancakeSwap missing bins and reporting virtual liquidity as token
amounts, Raydium bypassing Gateway, two dead default RPCs) and a section
describing the request chain, the per-connector cost of binCount, and the
bin output shape. Notes the fifth repository now in the family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
add.ts rejects a deposit with neither amount positive at the route level;
open let the same body run into connector code before failing. Apply the
identical guard (single-sided opens stay valid). The unsupported-connector
test gains an amount so it still exercises connector routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
rewardTokenAddress/rewardAmount had no producer — the only assignments
(pancakeswap-sol) are commented out — so every consumer saw permanently
absent optionals. Removed from the schema; hummingbot-api drops its
passthrough in step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…nge amounts

binCount on the unified /trading/clmm/pool-info now works for every
connector except Meteora (which always returns its bins): pancakeswap-sol
was the one connector ignoring it. New computeBinDistribution mirrors the
raydium/orca walk against the fork's identical TickArrayState layout —
tick-array PDAs fetched in one getMultipleAccountsInfo, liquidity_net
(i128) propagated outward from the active bin.

Verifying the bins exposed a real connector bug: getAmountsFromLiquidity
had its out-of-range branches swapped (price below a range put the
liquidity in token1 instead of token0, and vice versa), so bins — and
out-of-range position-info and quote-position amounts — came back
inverted. In-range amounts were always correct, which is why it went
unnoticed. Verified live on the SOL-USDC pool: below-price bins now hold
quote, above-price bins hold base, matching raydium and orca exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…hemas

/trading/clmm/create-pool shrinks to three connector params, each meaning
the same thing everywhere:
- binStep: bin/tick granularity (Meteora DLMM bin step; Orca tick spacing)
- feeBps: base fee in basis points (Meteora base fee; Uniswap/PancakeSwap
  V3 tier mapped x100 to the protocol's hundredths-of-a-bip units, with a
  bps-worded required guard)
- ammConfigIndex: Raydium-family fee-config index — Raydium API list
  index, and pancakeswap-sol amm_config PDA index (["amm_config", index],
  big-endian with little-endian fallback, validated on-chain) replacing
  the raw ammConfig address parameter

Dropped: fee, tickSpacing, ammConfig, gasPrice, maxGas.

The request and response are now canonical in clmm-schema.ts; the unified
route composes its body from ClmmCreatePoolRequest. The response drops
the AMM-inherited baseTokenAmountAdded/quoteTokenAmountAdded — every
connector hardcoded them to 0 because CLMM create-pool initializes an
empty pool — leaving data = { fee }.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…ig-index name

- gasPrice/maxGas removed from the unified /trading/amm/create-pool and from
  the uniswap/pancakeswap connector routes (add/remove-liquidity, create-pool
  request schemas): gas is configured at the network level, and the EVM
  connectors were the only ones exposing per-request overrides. Transactions
  now use prepareGasOptions(undefined, <route gas limit>).
- feeConfigIndex renamed ammConfigIndex on the unified AMM create-pool,
  matching the CLMM create-pool vocabulary for the Raydium-family fee-config
  index. configAddress stays: DAMM v2 configs are permissionless accounts
  with no index derivation.
- The unified AMM create-pool body is now composed from the canonical
  CreatePoolRequest (amm-schema.ts), like its CLMM counterpart.
- slippagePct on every unified AMM route now declares default 1 (with the
  same description/examples as the CLMM routes) — swagger previously
  rendered the bare maximum (100) as the example, which read as a 100%
  default slippage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…NoExactOut only

/trading/swap/quote and /trading/swap/execute now expose exactly two
optional knobs beyond the core fields: slippagePct and
approximateIfNoExactOut (default true; for BUY orders when a router has
no ExactOut route, approximate via a sell-leg ExactIn quote instead of
failing). approximateIfNoExactOut is threaded through the unified
dispatch and supported by all four Solana routers — jupiter, dflow, okx
and titan — each keeping its refuse-with-explanation path when a caller
passes false. EVM routers quote ExactOut natively and ignore it.

Removed from request surfaces, per the config-over-request principle:
- 0x gasPrice/maxGas (gas is network-level; tx gas limit comes from the
  0x quote's own estimate)
- jupiter restrictIntermediateTokens/onlyDirectRoutes (routing policy,
  connector config) and priorityLevel/maxLamports (Solana priority fees,
  connector config — the gas knobs of Solana)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…l the schema

openTime was Raydium CPMM's scheduled trading-start (startTime) — a
fair-launch nicety only one connector reads, not something needed to
create a pool; the connector defaults it to open-immediately. The
composite now orders required fields first (connector, chainNetwork,
walletAddress, canonical create fields) with the optional per-protocol
fee-config selectors last.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
The Meteora connector's addLiquidity always needed a DLMM distribution
shape and fell back to the connector-config default when the unified
route didn't pass one — so a position opened as one shape through
/trading/clmm/open silently accreted liquidity in a different shape on
/trading/clmm/add. Same optional Meteora-only parameter as open; other
connectors ignore it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
- expose slippagePct on /trading/clmm/remove (orca-only; was hardcoded 1)
- pass slippagePct through to pancakeswap-sol in /trading/clmm/quote-position
- drop slippagePct||1 coercion for uniswap/0x router quotes (honors explicit 0,
  falls back to connector config)
- enum-constrain connector on every unified route so unknown connectors 400 at
  the schema; refresh stale connector lists in descriptions
- default percentageToRemove to 100 on /trading/amm/remove-liquidity, matching CLMM
- walletAddress uniformly required-with-default
- hoist parseChainNetwork/defaultWallet/connector+chainNetwork fields into
  src/trading/common.ts (malformed chainNetwork now 400s everywhere)
- remove legacy tradingRoutes alias; app registers tradingSwapRoutes directly

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
- new rethrowRouteError in trading/common.ts: errors with an HTTP statusCode
  (connector badRequest/notFound, chain errors) propagate untouched; anything
  else becomes a 500 that keeps the underlying message instead of a generic
  label
- applied to all 21 unified swap/clmm/amm route handlers (tx routes previously
  swallowed the cause; query routes rethrew raw; swap routes had their own
  wrapper)
- open.ts: consolidate the mid-file import block at the top — the eslint hook
  had sorted the ../common import below the schema const that uses it at module
  evaluation, breaking require-order-sensitive loads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…everywhere

- unified routes drop the schema-level slippagePct default (Fastify injected it
  before the handler, shadowing connector defaults): omitted slippage now falls
  through to each connector's configured slippagePct, fallback 1
- orca: replace hardcoded '= 1' slippage defaults with
  OrcaConfig.config.slippagePct ?? 1 across quoteSwap/executeSwap/quotePosition/
  openPosition/addLiquidity/removeLiquidity; remove-request schema default now
  config-driven too
- meteora/orca CLMM remove-liquidity: rename liquidityPct -> percentageToRemove,
  matching every other connector, the unified routes, and meteora's own AMM
  route. The old name let clients sending percentageToRemove (hummingbot's
  gateway_http_client does) silently remove 100% via the schema default.
- jupiter swap test: assert the current execute-swap surface (slippagePct +
  approximateIfNoExactOut) instead of the removed priority-fee fields

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
Snapshot was ~318 commits stale — still carried liquidityPct on the
meteora/orca CLMM remove routes and the removed schema-level slippagePct
defaults on the unified trading routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
A single not.objectContaining({a, b}) passes when either key is missing;
check each removed priority-fee field on the actual call body instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…oute

/trading/swap/quote|execute already dispatches by connector/type
(jupiter/router, raydium/amm, meteora/clmm, ...) and resolves the pool
internally, so the pool-scoped /trading/amm/quote-swap and execute-swap
duplicated it with a narrower surface. Wire the missing meteora/amm
branch into the unified route and delete the pool-scoped pair; callers
(order executor included) always go through /trading/swap regardless of
connector type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…ed slippagePct

Three defect clusters:

- pancakeswap-sol (G3): all six clmm routes now call the shared
  solana.throwIfLandedWithError before returning PENDING, so a transaction
  that landed on-chain and failed throws 400 TRANSACTION_FAILED instead of
  being reported as status 0 forever. collectFees no longer removes 1% of
  the position's liquidity and reports the removed amounts as "fees" — the
  program (a Raydium CLMM fork) collects owed fees via decrease_liquidity_v2,
  so the route now sends a zero-liquidity decrease that collects the real
  fees without touching liquidity.

- Solana route confirmation (G4): retire the `txData !== null` == confirmed
  pattern. New Solana.getConfirmedTransactionData(signature) does the
  route-level re-fetch with retry (a just-confirmed tx whose data lags RPC
  visibility is no longer misreported as PENDING) and throws the shared
  landed-but-failed error on meta.err; every meteora/raydium/orca route and
  the jupiter/dflow/okx/titan executeQuote paths use it. handleConfirmation
  now takes raw txData (no confirmed flag), checks meta.err itself, and
  re-fetches with retry when no data is passed. throwIfLandedWithError is
  public, accepts optional txData, and throws the new 400 TRANSACTION_FAILED
  (error-handler) instead of mislabeling a landed tx as SIMULATION_FAILED.

- slippagePct echo (D4): the four swap-execute response schemas
  (chain/router/amm/clmm) gain an optional data.slippagePct, populated by
  every executeSwap/executeQuote implementation (Solana amm/clmm/router and
  uniswap/pancakeswap/0x) with the slippage actually applied — the request
  value when given, else the connector's configured default. quote-cache
  gains getRequest() so 0x can recover the applied value at execution.

Tests: new confirmation-helpers suite pins the retry/meta.err/slippage
contract; new pancakeswap-sol collectFees suite pins zero-liquidity collect
and the loud landed-but-failed 400; meteora/okx suites extended for the
slippagePct echo and serializer survival.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
The unified swap route resolves a pool from the configured pool list by
token pair, which a pool that is not in that list cannot be — a freshly
created pool, or one on an unlisted token. Folding the pool-scoped AMM
swap routes into it therefore left those pools unreachable. An optional
poolAddress restores the pin on the one surface: amm/clmm providers trade
against it directly, routers reject it since they choose their own route,
and the not-found message now says the pin exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…emoves

The orca remove-liquidity tests sent 'percentage' and the unified CLMM
route tests sent 'network' — both were renamed (percentageToRemove,
chainNetwork) and Fastify silently dropped the stale keys, so every case
ran against schema defaults instead of the values under test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
The spec still published /trading/amm/quote-swap and execute-swap, which
were folded into the unified swap route, and carried neither the
poolAddress pin nor the applied-slippage echo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
collectFees removed 1% of the position and reported the withdrawn
principal as fees — mutating the position on a read-shaped verb and
mis-stating the amounts. The Raydium CLMM program transfers owed fees on
any decrease_liquidity, so a zero-liquidity decrease collects them and
leaves the principal intact, matching the pancakeswap-sol fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
@greptile-apps

greptile-apps Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

Too many files changed for review (539 files, 100 file limit).

Two cases omit walletAddress on purpose, to exercise the schema default. That
default is read from conf/chains/solana.yml at module load — and conf/ is
gitignored, so a developer machine supplies a real address while CI falls back
to the template's literal '<solana-wallet-address>'. That is not base58, so
`new PublicKey(...)` throws and the route 500s:

    Expected: 200
    Received: 500
    Failed to collect fees: Expected base58-encoded address string of
    length in the range [32, 44]. Actual length: 7

So both tests passed locally, on every machine that happened to have a wallet
configured, and failed on a clean checkout. They now mock getSolanaChainConfig
to a known address, which is also what they meant to assert: that the route
falls back to the CONFIGURED wallet, not to whatever the machine happens to
hold.

Verified by reproducing CI exactly — setting conf/chains/solana.yml to the
template placeholder locally reproduces the 500, and passes with this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
@rapcmia rapcmia moved this to Backlog in Pull Request Board Aug 21, 2026
@rapcmia rapcmia added this to the v2.17 milestone Aug 21, 2026
@rapcmia rapcmia self-assigned this Aug 21, 2026
fengtality and others added 5 commits August 21, 2026 15:28
…ady does

The unified trading layer records an unknown token after a swap so the list
grows from what is actually traded. executeSwap did; executeQuote did not. A
caller doing quote-swap then execute-quote — the two-step path, and the one the
order executor uses when it holds a quote — swapped without Gateway ever
learning either side, while the one-step path learned both.

executeQuote cannot read the pair from its request: it is given a quote id, not
tokens. It takes them from the response instead, which is better placed than
executeSwap's source. `data` is present only when the swap CONFIRMED and names
both sides by address, so a quote that was never executed teaches nothing, a
failed swap teaches nothing, and there is no symbol to resolve.

The per-connector executeSwap/executeQuote files are deliberately untouched.
They are implementations the unified routes dispatch to, so putting the call
there would run it once per layer and duplicate the work on every swap. The
pool-scoped path needs nothing either: pool-swap-routes goes through
ensurePoolSaved, which already saves both tokens before the pool.

ensureTokenSaved is unchanged and still does the careful things — reads
name/symbol/decimals from the chain via fetchTokenFromChain rather than the
DUMMY_xxxx placeholder getToken invents for pricing, and refuses to write a
token whose on-chain symbol is already held by a different address, so wrapped
SOL cannot repoint the SOL every pool pairs against.
A quote is a question; a fill is a fact. Recording from a question fills the
lists with every pool and token someone priced and walked away from, and makes
the caller pay an RPC read for a pool they did not use.

The two sides had drifted apart, and in opposite directions. A pool-scoped
quote-swap recorded the pool and both its tokens. A router quote-swap recorded
its two tokens and no pool. Pool info -- a read -- recorded a pool. Meanwhile
none of the routes that actually put funds into a pool recorded anything: clmm
open, amm add and amm remove all learned nothing, so a pool could be LPed into
and stay unnamed unless someone had also quoted a swap against it.

Now one rule, both sides: learn on a route that moves funds, nowhere else.

  removed   clmm/amm quote-swap, clmm pool-info, amm pool-info, router quote-swap
  kept      pool execute-swap, router execute-swap, router execute-quote
  added     clmm open, amm add, amm remove

The clmm position routes (add, remove, close, collect-fees) are deliberately
absent: they are given a position address, not a pool, so learning from one
would cost a lookup -- and it is redundant, because a position exists only
because open created it, and open now records.

learnPool moves out of pool-swap-routes into its own module so the routes that
need it do not import a swap file to get it. Its doc comment carries the rule
above, so the next person adding a route has somewhere to read which side of the
line they are on.

The test that asserted a CLMM quote records its pinned pool is rewritten rather
than kept alongside a new one: it asserted the old contract, and the new
contract is that neither pool nor token is recorded from a quote. Execute-path
recording is still uncovered here -- test/trading has no execute mocks at all --
so that gap predates this change and is unchanged by it.
POST /tokens/save/:address named the token from GeckoTerminal and checked
whether it already existed BY SYMBOL. Both were wrong in ways that mattered.

Name, symbol and decimals all live on-chain, so an indexer adds nothing for them
and can only be missing, stale or rate-limited -- and it is likeliest to be
missing for exactly the token someone is saving by hand, the one too new to be
listed. The by-symbol check was wrong in both directions: a new address whose
symbol was already taken reported "already exists" and silently saved nothing,
and a token whose symbol had changed upstream was saved a second time under the
new one.

Both are fixed by routing through ensureTokenSaved, which is already the single
writer the swap paths use, rather than keeping a second implementation here. It
checks by address, refuses to write a token whose on-chain symbol belongs to a
different address -- so wrapped SOL reporting its symbol as SOL cannot repoint
the SOL every pool pairs against -- and checksums EVM addresses, which is why
the ethers import is gone.

ensureTokenSaved takes an optional fallback so this route keeps GeckoTerminal
for the case the chain genuinely cannot answer: a mint with no Token-2022
extension and no Metaplex account, or an ERC-20 that never implemented
name()/symbol(). Swap paths pass none -- a third party does not belong in the
hot path, and a token nothing on-chain can name is not one to learn mid-trade.

A token that nothing can name now 404s with what to do about it, instead of the
route throwing from inside the fetch helper.
…the venue

POST /pools/save/:address always asked GeckoTerminal which DEX an address
belongs to. The pool's own facts never came from there — fetchDetailedPoolInfo
resolves connector and type from GeckoTerminal and then fetches base, quote and
fee from the connector. So the indexer answers one question: "which venue is
this, amm or clmm".

A caller holding an LP provider config already knows the answer. `connector` and
`type` are now optional query params: supply both and the route goes straight to
that connector's pool-info, skipping the lookup entirely; omit both and nothing
changes. Supplying one without the other is rejected rather than half-honoured.

Also deletes autoSaveTokenIfMissing, which was a private second implementation
of ensureTokenSaved with a weaker source and fewer guards: it named tokens from
GeckoTerminal rather than the chain, checked existence by address but had no
protection against a symbol collision, so wrapped SOL reporting its symbol as
SOL could repoint the SOL every pool in the list pairs against. Both paths now
go through the one writer, with GeckoTerminal kept as its fallback for a mint
with no on-chain metadata.

ensureTokenSaved cannot say whether it added or found, which this route's
tokensAdded needs, so existence is checked first — against the same list it is
about to read, so the extra call costs nothing.

The two paths now converge: whichever way connector, type and the pool's facts
were obtained, the tokens are saved and the pool is built and filed by one piece
of code rather than two.
75db018 added the optional `connector` and `type` query params to
POST /pools and reworded its description, but left openapi.json as it
was. The CI drift check regenerates the spec from the route table and
fails when the committed copy differs, so the build went red on a
generated file rather than on any test.

Regenerated with `pnpm generate:openapi`; the diff is exactly the two
parameters and the description the route already declares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013mZ45yyfusXHF5AhWc8Ztt
…ook up

hummingbot calls POST /restart after every config write -- update_config does it
unconditionally, and the token and pool commands call it directly -- against a
route the spec never mentioned. A generated client had no method for it and a
reader had no way to learn it existed.

Two things kept it out. It carried no tag, and `hideUntagged` drops those. Fixing
that alone changed nothing, because of the second: @fastify/swagger documents a
route through an `onRoute` hook that exists only once its plugin has loaded during
`ready()`. Every other route arrives via `app.register`, so it lands in the plugin
tree after swagger; `/restart` was added straight onto the instance, before the
hook existed. Registering it through `register` puts it on the same footing while
keeping it off the docs server.

The description says what the 200 means. The reply is sent before the process
exits, so it is "restart accepted", not "restart finished" -- and the exit is code
0, which a supervisor set to revive only on failure will not act on. Under Docker
that is the difference between a restart policy of `always` and the default.

Verified against a running Gateway: POST /restart returned 200 in 11ms, the
container's exit code was 0 and its restart count went 0 -> 1, so it was Docker's
policy that brought Gateway back, not the detached spawn the handler attempts.

Spec goes from 54 paths to 55. tsc and eslint clean; 257 spec tests pass,
including the both-directions check that every route has a chosen operationId.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fengtality added a commit to hummingbot/hummingbot that referenced this pull request Aug 25, 2026
…t for nothing

Four defects found by running the unified route surface against a live Gateway
rather than against its spec.

**Every uniswap and pancakeswap swap left already rejected.** `_query` and `_body`
dropped None but kept a generated model's default, so every router call carried
`approximateIfNoExactOut=true`. It applies to the Solana routers only, and Gateway
answers 400 to any connector it does not apply to -- quote_swap and execute_swap
alike, so real EVM swaps failed, not just quotes. A model's default IS Gateway's
default, so sending it back says nothing while risking exactly this; both
serializers now exclude unset fields. Confirmed by the same request twice: without
the parameter, `price 2458.90797`; with it, 400.

**A 30s timeout in the middle of the EVM router's range.** Two pancakeswap BSC
quotes measured 27.9s and 31.4s against Gateway's configured RPC, so the same
quote timed out or not depending on the RPC's mood. `gateway_api_timeout` joins
the other Gateway settings, defaulting to 60; an existing conf_client.yml without
the key keeps working.

**AmmGatewayDataFeed still described the pre-unification world.** It required a
dex ("jupiter/router"), derived the chain and network from it, and then quoted
whatever the network's default swap provider was -- so a feed configured for
orca/clmm silently reported the router's price under an orca/clmm label. It now
takes the network ("solana-mainnet-beta") and quotes that network's configured
swapProvider, the way MarketDataProvider already does, and reports which dex
priced it. The provider is resolved once: quote_swap re-reads the network config
per call, and against a live Gateway that was two config reads for one pair before
a lock made it one for twelve.

**Gateway restarted for changes that were already live.** Adding a token or a pool
bounced the process. Gateway reads both lists off disk per request -- verified live:
a token added is returned by the next lookup, a pool added is listed and priced by
pool-info immediately. The bounce was not merely wasted, since Gateway exits 0 and
a container whose restart policy is not `always` does not come back.

Config keeps its restart, because it needs one: Gateway reads config once, at
startup. After setting `jupiter.slippagePct` to 5, `GET /config` answered 5 while
quotes went on applying 1 -- Gateway reporting a tolerance it was not trading at.

Spec refreshed from hummingbot/gateway#683 (54 -> 55 paths, POST /restart now
published). `make gateway-models` is unchanged: the generator reads only
components/schemas, and /restart adds none.

Verified against a live Gateway on 15888: 47/47 read-only client methods, 41/41
signing and mutating requests conform to the spec, 13/13 CLI command paths.
2255 unit tests pass; flake8 and isort clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fengtality
fengtality merged commit 72fa706 into development Aug 26, 2026
5 checks passed
@fengtality
fengtality deleted the feat/unified-trading-routes branch August 26, 2026 22:08
@rapcmia rapcmia moved this from Under Review to Development 2.17.0 in Pull Request Board Sep 1, 2026
@rapcmia rapcmia mentioned this pull request Sep 17, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Development 2.18.0

Development

Successfully merging this pull request may close these issues.

4 participants