feat(gateway): adopt the unified route surface, and record what the executors actually did - #221
feat(gateway): adopt the unified route surface, and record what the executors actually did#221fengtality wants to merge 46 commits into
Conversation
… listing, DB-aware stop, resolve
API-side of the gateway#678 retry-ownership work (canonical design:
docs/retry-architecture.md in the companion gateway PR). Executors created
via the API run in-process with no controller, so the API owns the
"react to a stranded position" role:
- Stop on a terminal executor returns already_terminated with close_type,
position_address, orphaned_position, and hold_reason instead of the 404
dead end #678 hit (terminal executors are popped from memory within one
tick, so "not in memory" almost always means "already terminated").
404 is reserved for ids the DB has never seen.
- Completion flags stranded exposure in the persisted final state:
an involuntary hold (POSITION_HOLD with hold_reason set — an LP close
that exhausted its retries) or a legacy FAILED-with-position gets
orphaned_position: true and an error-level log. Voluntary holds never
match (a successful close clears position_address first).
- GET /executors/positions/orphaned lists recovery candidates
(SQL-filtered to lp_executor; involuntary holds, FAILED-with-position,
and SYSTEM_CLEANUP restarts flagged needs_onchain_reconciliation).
- POST /executors/{id}/resolve-orphan marks a candidate recovered after
the position is closed externally, silencing listings and warnings.
- bots/controllers lp_rebalancer mirror: halt + skip accounting for
executors that ended with a live position (re-creating one would mint a
second position on top of the stranded one).
Validated live on mainnet: forced close-failure cascade terminated as the
involuntary hold, surfaced in the orphan listing with hold_reason,
re-stop returned already_terminated, and resolve-orphan cleared it after
a direct gateway close recovered all funds + rent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HahKfEY9rvKnZijrzUAFSq
GET /gateway/clmm/pool-info accepts bin_count and forwards it to Gateway's unified trading/clmm/pool-info, so the per-tick liquidity distribution is reachable for orca, raydium, uniswap and pancakeswap (Meteora always returns its own bins). The response model already carried bins. Requests with bin_count > 0 skip the direct-Raydium-API shortcut: that API returns no bin distribution, and only Gateway computes it from on-chain ticks. Also wraps pre-existing long lines and drops an unused import in routers/gateway_clmm.py — the flake8 pre-commit hook lints the whole file and would not otherwise accept a commit touching it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…her connector /gateway/clmm/pool-info special-cased Raydium: it skipped Gateway entirely, called api-v3.raydium.io directly, and reshaped that response to look like Gateway's. That divergence cost real data — the transform hardcoded active_bin_id to None, bin_step to 1, and bins to [] — and it meant Raydium could not answer bin_count at all, since only Gateway computes the tick distribution. Raydium now takes the same path as meteora/orca/uniswap/pancakeswap. The Raydium API helpers and their aiohttp import go with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An lp_executor that exhausts its close retries terminates as an involuntary
POSITION_HOLD with the position still on-chain. Recovering it means closing that
position by address — which this API could not do.
Two things blocked it:
- /gateway/clmm/close and /collect-fees read the position's pool only from the
gateway_clmm_positions table, and 404'd when it was absent. An lp_executor
opens its position straight from the bot to Gateway, so it is never in that
table: on a live deployment the table was empty and every orphan 404'd.
Both endpoints now accept pool_address on the request, resolving
database-first and erroring with 400 (a bad request, not a missing position)
naming pool_address as the fix.
Gateway's close needs only position_address; pool_address is used to snapshot
pending fees before the close so they can be reported.
- /executors/positions/orphaned reported connector_name and trading_pair but
not the DEX or the pool, so a caller had nothing to build a close from. Note
connector_name holds the *network* for an lp_executor ("solana-mainnet-beta").
The DEX ("orca/clmm") and pool live in the executor's stored config; both are
now surfaced as lp_provider and pool_address.
Also wraps two pre-existing over-length lines in gateway_trading.py, which the
whole-file flake8 hook fails on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj
The CLMM work on this branch depends on Gateway changes that ship in hummingbot/gateway#679 and are not in the `latest` tag, so a container started from the default image cannot serve the endpoints this branch calls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj
Gateway's /trading/clmm/positions-owned takes no pool filter — its handler reads only connector, chainNetwork and walletAddress — so the pool_address this API required, forwarded, and documented as a filter never filtered anything: every caller always got the wallet's full position list labeled as one pool's. Remove the parameter end to end (request model, router, gateway_client) so the contract says what actually happens; each returned row carries its own pool_address for callers that want one pool. Includes flake8 fixes in gateway_transaction_poller.py that the pre-commit hook now enforces on the touched file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…routes Audit fixes, most severe first: - close: read the confirmed transaction data Gateway returns — the removed base/quote amounts and positionRentRefunded — record them on the CLOSE event and surface them in a new CLMMClosePositionResponse. The rent tracked as locked at open was never reconciled as refunded. - open/add/remove: prefer Gateway's confirmed on-chain amounts (baseTokenAmountAdded/Removed) over the requested amounts when persisting and responding; requested amounts remain the submitted-not-confirmed fallback. Also fixes REMOVE_LIQUIDITY events never persisting: the event payload carried a "percentage" key GatewayCLMMEvent has no column for, so create_event raised into the log-and-continue handler on every call. - new endpoints mirroring Gateway routes hapi never exposed: POST /gateway/clmm/quote-position (pre-trade deposit split), POST /gateway/clmm/create-pool (CLMM pools; AMM had this, CLMM did not), GET /gateway/clmm/position-info (single position by address). - positions_owned/position-info: pass through rewardTokenAddress / rewardAmount (farm rewards; populated by pancakeswap-sol today) instead of dropping them. - amm create-pool: expose openTime (Raydium CPMM) and slippagePct (Uniswap seeding) which Gateway accepts. - open: reject extra_params keys other than strategyType with a 400 — Gateway's unified open silently ignores everything else. - drop dead surface: dynamicFeePct/minBinId/maxBinId on pool-info (Gateway's declared response schema strips them before serialization; nothing consumes them) and the camelCase pageSize field (renamed page_size; no consumers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…st swap quote - CLMM and AMM create-pool drop their per-connector named fields; connector params ride extra_params under Gateway's own names (the clmm open contract), spread into the payload, with unknown keys rejected loudly — Gateway destructures a fixed set and silently ignores the rest. Meteora's required configAddress is enforced at the router. - CLMMPositionInfo drops reward_token_address/reward_amount: no Gateway connector populates them (the only assignments are commented out) and the schema fields are being removed from Gateway's trading responses. - CLMMPoolInfoRequest documents bin_count, mirroring Gateway's binCount. - SwapQuoteResponse mirrors what /trading/swap/quote actually returns: gains min_amount_out/max_amount_in/price_impact_pct/pool_address/ route_path, slippage_pct reflects Gateway's applied value, and the phantom gas_estimate (never returned by Gateway) and deprecated expected_amount are gone — nothing consumed either. - Fix the stale module docstring claiming AMM support was removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…ding routes
- Stop forcing slippage 1.0 everywhere: all request models default
slippage_pct=None and the routers omit the key when unset, so Gateway
applies the connector's configured slippagePct (the schema-default
shadowing this used to cause was removed Gateway-side). The swap DB
record and quote response echo the applied/requested value or None —
SwapQuoteResponse.slippage_pct is now Optional and no longer backfills
a fabricated 1.0 (also fixes the falsy-`or` that swallowed slippagePct=0).
- extra_params convention extended to every surface with connector-specific
params, matching the executor stack's LPExecutorConfig.extra_params:
swap quote/execute gain approximateIfNoExactOut (Solana routers; query
values stringified for aiohttp), clmm add gains strategyType (same
contract as open), each guarded by loud unknown-key rejection since
Gateway silently drops unrecognized keys.
- CLMM remove exposes the standard slippage_pct field (Orca-only today).
- create-pool guards pinned to what Gateway actually destructures:
clmm {binStep, feeBps, ammConfigIndex}; amm {configAddress,
ammConfigIndex} + first-class seeding slippage_pct. The phantom keys
(fee/tickSpacing/ammConfig/gasPrice/maxGas/feeConfigIndex/openTime)
passed the guard and were silently ignored by Gateway.
- Fix /clmm/open crash: clmm_pool_info was called without its required
pool_address since the pool-info signature change (TypeError → 500 on
every open).
- Contract tests updated and extended for all of the above.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
- Pending CLMM open no longer 500s with an orphaned on-chain position: Gateway's OpenPositionResponse carries positionAddress only inside the confirmed-only data object (the schema strips everything else, so the old top-level fallback keys could never arrive). A submitted-not- confirmed open now returns 200 with position_address=None and the signature to poll; the poller's discovery sweep records the position once it lands. A confirmed response without an address is still a loud 500, and the confirmed path now reports status "confirmed". - extra_params validation hardened via a shared routers/gateway_extras helper: unknown keys, keys sent to a connector that ignores them, and wrong-typed values (incl. null, and bool-vs-int subclass traps) all 400 locally instead of being silently dropped or reaching Gateway as the string "None". - ROUTER_CONNECTORS gains dflow/okx/titan so bare names route to /router instead of misrouting to /clmm and 404ing. - Swap DB record: price is quote-per-base for BOTH sides (BUY was inverted), the pending fallback keeps tokenIn/tokenOut denominations (BUY no longer stores a base amount in the quote-denominated input column), side is normalized to uppercase, and the dead poolAddress read (never in the execute response schema) is an explicit None. - clmm_fetch_pools speaks each connector's real schema: meteora page/includeUnverified + "field:direction" sortBy; orca sortBy/sortDirection/verifiedOnly, with page>0 rejected loudly for orca instead of a silent no-op that echoed the requested page. - AMM quote/execute swap uppercase side like the unified path. - Honesty fixes: swap amount documented as base-denominated for BUY (ExactOut), poll docstring documents txStatus -2 NOT_FOUND as terminal, activeBinId is not meteora-only, and swap listings report a recorded slippage of 0 as 0 instead of null. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…ng failed txs as submitted Follow-ups from the re-audit of cd3cab7: - The position discovery sweep now covers every Solana CLMM connector whose open can return submitted-not-confirmed (meteora, raydium, pancakeswap-sol, plus orca for externally-created positions) — previously meteora-only, so the pending-open path's "the poller records it once it lands" contract was false for raydium/pancakeswap-sol and those positions were permanently orphaned from the DB. - get_transaction_status_from_response (both routers) maps Gateway's negative statuses to FAILED instead of folding them into SUBMITTED: a failed EVM swap (status -1, zeroed amounts) is now recorded and returned as failed, and the swap execute response reports confirmed/submitted/failed honestly instead of a hardcoded "submitted". - The CLMM open pending branch rejects the EVM late-revert shape (data present without a position address, or negative status) with a loud 500 instead of returning 200 "submitted" for a tx that definitively failed on-chain. - gas_fee falsy-zero in swap listings fixed the same way as slippage_pct one line above it (a recorded 0 must not report as null). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…d residuals
Poller (the load-bearing cluster):
- _check_transaction_status classifies on txStatus ALONE: Gateway returns
txStatus 0 WITH an error message for transient poll failures ("poll
again, don't give up") — the error field no longer promotes a pending
tx to FAILED (one RPC hiccup used to permanently fail in-flight swaps
and close events). -2 NOT_FOUND is DROPPED (terminal after a 180s
blockhash grace) instead of polled for an hour and mislabeled timeout;
0 is an explicit PENDING distinct from None (no information).
- The age timeout fires only after a successful poll says pending —
never while Gateway is unreachable (an outage used to mass-FAIL
everything over an hour old, including confirmed txs). One
availability gate per cycle replaces per-call pings.
- Position close detection: a single position-info 404/500 no longer
closes a position — 3 consecutive misses required (mirrors the
lp_executor gate); the router refresh no longer closes on absence from
one positions-owned read; discovery skips reopening positions closed
within a 300s grace so a lagging listing can't flap CLOSED->OPEN.
- Failed txs record their gas fee; fee of exactly 0 survives.
Fee bookkeeping (double-count / phantom-fee cluster):
- close/collect endpoints mutate the position ONLY when Gateway
confirmed inline; submitted txs are booked once by the poller's
confirm path (which now books close fees before closing); failed txs
mutate nothing. Previously every pending collect double-counted and
every failed close permanently inflated *_fee_collected.
- ADD_LIQUIDITY raises the PnL baseline (initial amounts) on confirm —
pnl_summary no longer counts added capital as profit.
Honesty and contract:
- add/remove/close/collect responses report confirmed/submitted/failed
instead of hardcoded "submitted"; writes on unrecorded positions log
loudly instead of silently dropping the event.
- Swap summary: quote-denominated volume per quote token (was summing
the base leg across mixed pairs while claiming quote); status filter
case-insensitive; tz-aware time filters; 10k-row cap logged.
- CLMM remove renames percentage -> percentage_to_remove (matches AMM
and Gateway; position.percentage still means range width).
- close/collect honor an explicit request wallet (same precedence as
open/add) and drop the required-but-unused pool_address 400.
- get_native_gas_token single-sourced in gateway_client (three drifted
copies produced MATIC/None/UNKNOWN for one chain); status mapping
single-sourced in gateway_extras.
- Wallet placeholder check matches Gateway's real "<chain-wallet-address>"
template; unreachable Gateway raises 503 instead of "No wallet
configured" 400 or "'error' in None" crashes; hardware wallet
addresses included in discovery/balance sweeps; deprecated /pools
maps Solana routers to solana; position-info 503s on connection error.
- Dead code removed per convention: unused request models, legacy
_poll_open_positions wrapper, poll_transaction_once, unused repo
helpers. DISCOVERED event type documented everywhere event types are
enumerated.
Accepted residuals documented in place, NOT fixed (by decision):
pending-tx amounts/price never backfilled from txData; discovery-time
entry price/synthetic history for pending opens; int-only extra_params
strictness; legacy lowercase side rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…and gas fields A fee or gas value of exactly 0 was stored as None (unknown) on close and collect events, contradicting the is-not-None convention adopted everywhere else and leaving pending-fee columns stale on the poller's confirm path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…_api_keys A None result from the client (connection error mid-batch) was filtered out as if it succeeded, so the endpoint reported keys updated that never reached Gateway. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…ools; uppercase side on read Gateway folded /trading/amm/quote-swap and execute-swap into the unified /trading/swap route (connector as name/type, pool resolved internally), so the hapi proxies, models, and client methods go with them — swaps always go through /gateway/swap regardless of connector type. /gateway/clmm/pools takes a network parameter instead of hardcoding mainnet-beta (the last endpoint on the surface without one), and the swap repository serves side uppercase so legacy lowercase rows cannot leak into strict consumers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…or codes The hardcoded ROUTER_CONNECTORS roster silently misrouted every connector Gateway added after it was written (a bare name fell through to /clmm and 404'd). Connector trading types now come from Gateway's own config/connectors listing, cached per client, preferring router then clmm then amm; an unknown name raises instead of guessing. Gateway's machine-readable error code (TRANSACTION_TIMEOUT, SLIPPAGE_EXCEEDED, ...) was flattened into prose before GatewayError was raised, leaving callers unable to tell retryable from terminal failures. It now rides GatewayError.code. Also: the swap execute response no longer lower-cases its status (every read surface reports uppercase), and swaps file under the base venue name so 'jupiter' and 'jupiter/router' land in one history bucket. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
The one client verb with no path/payload test; also pins that an omitted slippage stays omitted and an explicit 0 is sent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…ng proved missing
Two threads, both from testing this stack against Solana mainnet.
Routes. Gateway moved the trading type into the path and constrained `connector` to a
bare name. Four paths here were stale and would have 404'd: clmm/quote-position ->
quote-liquidity, amm/{add,remove}-liquidity -> {add,remove}, and per-connector
fetch-pools -> trading/clmm/fetch-pools, which also moved connector from a path
segment to a query parameter. normalize_swap_connector became resolve_swap_route,
returning (name, type) so the type can select the route. test_gateway_paths_exist.py
asserts every path literal against Gateway's OpenAPI spec, vendored so the check runs
in CI and so adopting a Gateway change is a reviewable diff; it found all four.
Recording. Each of these was found by executing a real transaction and reconciling the
stored row against the chain:
- CLMM positions labelled tokens with the last 8 characters of the mint and called it
a symbol, so a position read "11111112-ZwyTDt1v" and no trading_pair filter could
match "SOL-USDC". Now resolved against Gateway's token list, falling back to the
full address, which at least identifies the token.
- Swap execute never recorded gas: the field existed and the poller could fill it, but
the poller only revisits *pending* swaps, so a swap confirmed inline — the normal
case — had its gas recorded by nobody.
- slippage_pct stored the requested value, so omitting it (the connector default)
recorded null while Gateway reported what it actually applied.
- position_rent_refunded was parsed at close, logged, returned to the caller, and
discarded; there was no column. Adding it lets a close be reconciled against the
rent locked at open.
- add_liquidity raised the PnL baseline without raising the held amounts, and
remove_liquidity booked neither. Either way the row reported a gain or loss of
exactly the amount transacted, for up to the 5 minutes until the position poller
corrected it. Both now move together, and an add re-weights entry_price so capital
added later is valued at the price it entered at.
- AMM writes persisted nothing at all: no event, no position, no gas. They now record
to gateway_amm_events for every connector, with the pool price, so a fungible-LP
position has a cost basis. Meteora DAMM v2 positions are NFTs and additionally get
rows in gateway_amm_positions; the address is reconciled from positions-owned until
Gateway returns it on open, and that workaround is marked for deletion.
- AMM responses returned Gateway's raw TransactionStatus enum where the swap and CLMM
surfaces return CONFIRMED/SUBMITTED/FAILED, so callers saw "1" instead of a status.
…elds we read Gateway's spec now names its schemas as components, so they can be generated rather than transcribed. models/gateway_generated.py is that mirror, vendored so it imports without a build step and adopting a Gateway change reads as a diff. `make gateway-models` regenerates it; a test fails if the committed copy is not what the vendored spec produces. The generated models replace nothing in models/gateway_trading.py, which turns out not to be a transcription: it is this service's own API, deliberately reframed (trading_pair + side over Gateway's token flow, a compound chain-network, Decimal, a string status vocabulary). Only 6 of its 32 models pass Gateway's shape through unchanged. What the mirror is good for is checking those 6 — and it now does, along with every camelCase key the client writes or reads. Both halves failed silently before: a renamed field is a .get() that returns None, not an error. Requests stay hand-written. Gateway declares 24 of 28 request bodies inline rather than as components, and every read is a GET whose fields live in the spec as parameters, so no generated request model would cover them. The wire-key check does. Also fixes clmm/create-pool, which raised a ValidationError on every successful create: it splatted Gateway's numeric status into a model declaring a string, missing the mapping every other write path applies.
Gateway now publishes 23 of 28 request bodies as named components, up from 4, so the generated models carry the shapes callers actually send: connector, chainNetwork and the connector-specific extras this client passes through extra_params. Every trading POST GatewayClient builds now has a model whose field set matches the keys it writes. Spec and models refreshed together; the contract checks pass unchanged.
Gateway's GET routes now publish a component matching their query, and 28 pre-refactor bases that were holding those names lost their $id. The generated models drop from 110 classes to 93, and the ones a read would reach for — ClmmQuoteSwapRequest, ClmmFetchPoolsRequest — now carry connector and chainNetwork rather than the per-connector network they had before. Contract checks pass unchanged.
The client hand-wrote every request as a camelCase dict. The 21 unified /trading methods
now build their payload from the model generated off Gateway's spec, so a field Gateway
renames fails here rather than going out wrong — the same move hummingbot's client made,
and the same reason: this is the layer where a rename is otherwise silent.
Regenerated with --ignore-enum-constraints. Gateway constrains `connector` and `network`
by enum so its docs can offer dropdowns, and generating those as Python enums would bake
a venue and network roster into this service — a connector added to Gateway after the
last spec refresh would be rejected before the request left the process, which is exactly
what resolve_swap_route exists to avoid. It also removes the only classes the generator
had to number (Connector9, Connector15, ...), renamed by any unrelated route insertion.
93 classes down to 80.
Three conversions, all in one place:
_body dumps in python mode and widens Decimal to float. mode="json" renders it as
a string, and Gateway declares these fields `type: number`.
_query stringifies, which is all a URL carries.
_wire_str keeps a whole number whole. Gateway types every numeric field as `number`,
so pydantic holds a page index as a float and str() would render it "2.0" —
the contract tests caught that on fetch-pools.
Both drop None rather than sending null, replacing the `if x is not None` ladders: an
absent parameter gets Gateway's default, an explicit null does not.
quote_swap and execute_swap now reject a pool_address given for a router connector.
Routers route across pools rather than executing against one, so RouterQuoteSwapRequest
has no poolAddress — and pydantic drops an unknown keyword in silence, which would have
read as though the pin applied.
Connector-specific params (Meteora's strategyType, configAddress, ammConfigIndex) are
merged after the model, not through it: they are named by the connector rather than the
route, so no route schema declares them.
test_gateway_models_match_spec gains a check that every keyword passed to a model is a
field of it. Pydantic catches a misspelled *required* field, since the real one then goes
missing, but drops a misspelled optional one — the request would go out without the
slippage the caller asked for. Mutation-checked: renaming slippagePct at all 10 call
sites fails it with all 10 named.
positions_owned mapped Gateway's fee amounts through a truthiness check, so a position with nothing uncollected came back with base_fee_amount=None — indistinguishable from Gateway not having reported the field at all. The single-position read on the same model already used `is not None` and returned 0, so the two routes disagreed about the same position. Live: condor's CLMM position listing printed "Uncollected fees — base: None quote: None" for a position the position-info route reported as 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
Gateway folded close into remove at 100%: the position account is closed in the same transaction, and positionRentRefunded now arrives on the remove response. This records it, the way the CLMM close path already does. The refund needs its own field because the rent was never liquidity — subtracting the removed amounts does not account for it, and on a small position the rent is the larger number. It arrives only on a full removal: a partial one leaves the account open and refunds nothing, and fungible-LP AMMs have no account to close, so its absence there is a fact rather than a gap. Also drops the two AMM open/close entries from the passthrough table in test_gateway_models_match_spec, whose schemas Gateway no longer publishes. That test is what caught the spec change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…eway gives Three things, all on the DAMM v2 add path. positionRent was discarded. Gateway reports it separately precisely because rent is not liquidity — the chain returns it when the position account closes — and the position table has had a position_rent column all along, filled by the CLMM path and never by this one. So the inflated figure from GW-20 was booked with nothing alongside it to back it out. Now recorded, and the close already records the refund, so the two can be compared: a refund short of what was locked means an account was left behind. position_to_dict exposed neither rent field, so /gateway/amm/positions/search would have kept them invisible. The CLMM dict has always returned both. _resolve_new_position_address is deleted. It diffed on-chain positions against tracked ones to guess which one an add had just created, because the response carried no address (GW-6) — its own docstring said to delete it the moment that field existed. It does: Gateway generates the NFT keypair, so it is the only thing that can attribute a position to a transaction, and the diff gave up whenever two were new. The route now reads data["positionAddress"]. get_open_position_addresses went with it, its only caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr
…ol survives
`base, quote = trading_pair.split("-")` assumes no symbol contains a hyphen. Symbols now
come off the chain rather than from a curated list, so they are whatever the mint says:
the first pool Gateway's chain-learning recorded had a base of `DOGE-1`, making the pair
`DOGE-1-SOL`. That unpacks three values into two, and the ValueError escaped as the HTTP
message — `too many values to unpack (expected 2)` — which names neither the pair nor the
problem.
One helper rather than an rsplit at each site. utils/trading_pair.py splits from the right
and raises InvalidTradingPair, a ValueError, which the routers already map to a 400.
Splitting from the right is correct rather than merely forgiving: the quote asset is the
last segment, so `DOGE-1-SOL` reads as `DOGE-1` over `SOL`, which is what it means. A
hyphen in the *quote* symbol stays genuinely ambiguous.
Eight sites. The two in gateway_swap are the ones that returned the 400. The two in
orders_recorder sat inside a broad `except` that logged a warning, so a hyphenated pair
became a silently missing fee rather than an error. The four `len(parts) == 2` guards —
executors ×2, executor_ws_manager, executor_service — were skipping unrealized PnL without
saying so; they keep their tolerance, since one unreadable pair should not fail a whole
listing, but it now applies only to pairs that really are unreadable.
A structural test asserts no bare split("-") on a trading pair survives under routers,
services, utils or models. It found executor_service.py:1072, which reading by hand had
missed. bots/controllers is out of scope: those are strategy templates, shipped separately.
Verified by test rather than live — the hyphenated token is no longer in the local token
list and none of the 42 highest-TVL mints across Orca and Meteora has one, so the original
400 could not be reproduced. The tests assert against the exact recorded string.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
… wallet address Gateway's generator now writes the template placeholders for walletAddress defaults and the template port for servers[0].url, so the spec is the same on any machine. Before that it carried whoever generated it: a real trading wallet, 21 times, vendored here and baked into two lines of the generated models. Nothing else moves. The models diff is exactly the two lines that held the address. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
baaec0f added position_rent and position_rent_refunded to both position repositories, having checked the columns existed — but at database/models.py:289, inside GatewayCLMMPosition. The AMM model has neither, and the two failures look nothing alike. Reading one raises AttributeError, which reached callers as `500 'GatewayAMMPosition' object has no attribute 'position_rent'` on every POST /gateway/amm/positions/search. Writing one raises TypeError inside GatewayAMMPosition(**position_data), where the broad except around the booking logs it and carries on — so a confirmed DAMM v2 open was simply never recorded, with nothing to see. close_position(position_rent_refunded=...) would have failed the same way on the first close, which is what made GW-20 and GW-21 unverifiable end to end. The columns belong on the AMM table rather than being dropped from the code: a DAMM v2 position is an NFT with its own account, so it locks rent exactly as a CLMM position does, and that is the accounting GW-20 exists to get right. create_all only creates missing tables, so a model gaining a column reaches an existing database only through _run_migrations. Both tables get entries — the CLMM pair had none either, so any database predating those columns is missing them too. The suite had no test touching the AMM position_to_dict, which is why 139 passed over a live 500. There is one now, and it is structural rather than a case: every attribute those two methods read or assign must be a real column on the model they are given, and every rent column must have a migration. Removing the columns again fails three of its six. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
Gateway prefixed its response components so an AMM shape and its CLMM twin no longer share a name, and gave every operation an operationId and an error model. Refreshes the vendored spec and the generated models, and moves the four passthrough pins that named the old components: PoolInfo, QuotePositionResponse, QuoteLiquidityResponse and CreatePoolResponse. The pins are what caught it — regenerating alone would have left them comparing against components the spec no longer defines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
Gateway collapsed three addressing conventions into one: everything outside
/chains/{chain}/ takes a single chainNetwork, so the six calls that sent chain and network
separately now send the pair joined. /chains/{chain}/balances and /chains/{chain}/poll are
unchanged, since the chain is in their path, and the wallet calls keep `chain` alone — a
keypair works on every network of its chain.
Also refreshes the vendored spec and models.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
`SwapExecuteResponse.amount` is the request echoed back, and its description said "Amount swapped". On a live BUY of 1000 DOGE-1 that delivered 951.682904159 it answered 1000. This is not specific to thin tokens or to BUYs — any swap fills at something other than the requested amount whenever slippage moves it, and this field reported the request every time, so an executor reconciling its position against it was reconciling against its own intent. None of it was unavailable: the same call computes input_amount, output_amount and price and writes them to the swap history two functions away. Learning what a swap did meant executing it, discarding the answer, and searching the history by transaction hash. The three fields are optional and stay None until the transaction confirms — a submitted swap has only placeholders, and publishing those would restate the request as the result, which is the defect they exist to end. `amount` now says it is the request. Spec and generated models refreshed for the gateway change to /trading/clmm/remove's slippagePct, which uniswap and pancakeswap now honour. GW-35. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
`/gateway/clmm/close` had no way to say what slippage the withdrawal would accept, so the LP executor's ramp had nothing to send. The field is optional and omitting it keeps the previous behaviour exactly — the connector's configured value. Enforced by orca, uniswap and pancakeswap; the other CLMM connectors close with no minimum-amount check, and the description says so rather than implying otherwise. Spec and generated models refreshed for the same Gateway change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
Gateway has had /trading/router/execute-quote all along and nothing downstream exposed it: not this API, not the client, not condor. So every swap on record went through the one-step execute, which re-prices at execution and discards the quote the caller saw — which is the entire value of a held quote on dflow, titan and 0x, and the case the route was built for. `quote_id` now reaches the caller on /swap/quote (routers return one; pool-scoped connectors do not, because they price against the pool at execution), and /swap/execute-quote commits to it. A pool-scoped connector is rejected with a 400 rather than quietly re-priced, which would hand back a swap at a price nobody was shown. Both execute paths book through one function. They differ in how the transaction was produced and not at all in what has to be recorded afterwards, and two copies of that accounting would drift. GW-27. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
Every row in both event tables read CONFIRMED with no error_message. Not because nothing had ever failed — a close that reverted at slot 440494812, costing 0.000011772 SOL, left no row at all — but because a failure could not be written. The recording code runs only when Gateway returns; a transaction that lands and reverts makes Gateway raise, and control skips every create_event call to land in an except that persists nothing. Only failures carrying a transaction id are recorded. A pre-flight simulation failure never got a signature and cost nothing, so inventing an identifier for it would put a row in the table that no lookup by hash could ever match. Gateway names the id in the message either way it can, and it was reaching a log line and nowhere else. A failed CLMM open still writes no row: gateway_clmm_events keys every row to a position, and an open that reverted created none. wallet_address is bound before the try so the recorder cannot NameError over the top of Gateway's own error when the wallet lookup is what failed. Recording never masks the original failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
A BUY is an ExactOut order, and many thin tokens have no ExactOut route,
so Gateway falls back to quoting the sell leg and quoting that input
forward — paying the pool fee and crossing the spread twice. It flags
that on the response as `approximation`, and this dropped the flag, so a
quote whose amount_out was ~2.5% short of what was asked for looked
identical to an exact one.
The input half was already wired end to end: a caller could switch the
behaviour off via extra_params={'approximateIfNoExactOut': false} but
could not find out whether it had happened. Measured at a near-constant
~2.5% across eleven pools spanning $17 to $1,963 of liquidity, and it is
reached for only on the thin, high-fee pools where it hurts most. The
caller is not overcharged; the order is silently resized, which is what
matters to a strategy that asked for a specific quantity.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
…enamed
Five tests imported GatewayLp from hummingbot.connector.gateway.gateway_lp
and had been failing on ModuleNotFoundError. The connector is now Gateway,
and the rename carries a design change worth pinning: it is addressed by
NETWORK ("solana-mainnet-beta") rather than by DEX-and-type
("meteora/clmm"), with the DEX and trading type travelling as arguments —
which is what removed the KeyError this file is named for.
Two of the tests read the source text of _create_trading_connector and
asserted on substrings of it, including a "'/' in connector_name" branch
that no longer exists. They now call the method. Added the other side of
the branch: the Gateway path is reached by a name being ABSENT from
_conn_settings, so an empty _conn_settings would send every exchange down
it too, and nothing was holding that line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
…ejects Money now crosses the wire as decimal strings and the routes reject undeclared keys. The generated money fields are unchanged — Decimal count holds at 140, because the generator maps `string` + `format: decimal` to the same Decimal as before — they simply stop receiving float noise. Rejecting undeclared keys immediately caught this client doing what its own comment warns about. quote_swap and execute_swap raise a clear ValueError when a pool_address is passed for a router, because "the router model has no poolAddress, and pydantic drops an unknown keyword silently — which would look like the pin applied". Both then passed `poolAddress=pool_address or None` unconditionally, router included. The guard covered the truthy case; the None sailed past it into the very silent drop the comment describes. It is now only passed to the models that declare it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
gateway_clmm_positions.position_rent is written by the OPEN route and position_rent_refunded by CLOSE. An executor holds its position through the wheel, talking to Gateway directly, so neither route runs: the poller discovers the position and files it with both columns NULL, and the close leaves no refund behind either. The table answered a rent question correctly for the hand-driven path and not at all for the recommended one — on ~0.0100572 SOL per Orca position, more than the liquidity in a small one. The executor knew both figures the whole time; nothing was asking it. Locked rent is now recorded from the control loop as soon as the position row exists, not at completion, so a position held for days is answerable while it is held. The refund is recorded at completion, because that is when the close confirms — and a successful close clears position_address from custom_info first, so the address is remembered while the executor is live or there is nothing left to file the refund under. Zero is never stored. The executor defaults both figures to 0.0, so zero means "never measured" far more often than "measured and empty": an open position has no refund yet, and an EVM CLMM has no rent at all. A stored 0.0 claims an observation that nothing downstream can tell from the real thing, which is exactly GW-18's defect. NULL is the honest answer. Only NULL columns are filled, so a figure a route read off its own transaction is never replaced, and the write is idempotent. The control loop now iterates a snapshot of _active_executors: recording awaits a database round trip, and create_executor runs in a request task that can add to the dict meanwhile. Iterating it live raises "dictionary changed size during iteration" — which the loop's own broad except swallows into a log line, silently skipping completion handling for every executor after the one that raced. Residual: a position created outside the API entirely has no executor to ask, and its locked rent is in an open transaction nothing kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
…loyed Three aggregates summed ExecutorRecord.filled_amount_quote and called the result volume — the performance report's volume_total_quote, its per-type volume_quote, and the active-executor summary's total_volume_quote. For an executor that places orders that is right, because the amount it filled IS its volume. For an LP executor it is the capital it put up, and putting up capital trades nothing. The wheel now derives an LP position's real volume from the fees it earned and reports it as executor_info.volume_traded_quote. This stores that figure on the row and sums it instead. The migration backfills existing rows from filled_amount_quote for every executor type EXCEPT lp_executor, so an order-placing executor's history stays intact — the two are the same number for it by definition. Historical LP rows keep 0: their real volume is unrecoverable, because the fees were never stored, and copying the deposit across would re-enter the exact number this change removes, now looking deliberate. A migration entry can now carry several statements. create_all only creates missing TABLES, so a model gaining a column reaches a real deployment only through that list — and this one needs the backfill beside the ALTER. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
…iven ones
gateway_swaps is written by hummingbot-api's own /gateway/swap/* routes. An
executor holds its connector through the wheel and talks to Gateway
directly, so hummingbot-api never saw the call and had nothing to record.
Two MARKET swaps ran through order_executor on 2026-08-21 at 00:13 and
00:15 UTC, both CONFIRMED on chain and both reconciling exactly against the
wallet, and neither reached the table:
newest row in gateway_swaps 2026-08-20T18:37:24 (a hand-driven sell)
executor swaps 2026-08-21T00:13, 00:15
SOL-USDC rows 9, newest 2026-08-20T01:35 — all by hand
The table was not wrong, it was silently partial. /gateway/swaps/search and
the swap summary described only swaps made by hand, with no marker saying
so, so a caller reading "9 SOL-USDC swaps" had no way to learn that the
most recent ones were missing — on the path that is actually recommended.
Same shape as the rent fix: ask the executor at completion rather than wait
for a route that will never be called. Keyed on the transaction hash the
wheel now reports, because order_id is internal and appears nowhere on
chain. The recorded slippage_pct is the LIVE tolerance, which is not the
configured one when earlier attempts failed and widened it.
A swap with no realized amounts is reported rather than recorded: a row of
zeroes would read as a swap that moved nothing, when the amounts are simply
unknown. Non-Gateway executors carry no hash and fall straight back out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
The executor derives its volume, the row stores it, and the aggregates sum it — and ExecutorResponse did not declare the field, so FastAPI filtered it out at the boundary and no caller ever saw it. The same shape as GW-33: a value computed correctly all the way to the last step, then dropped in silence. The three volume descriptions said "total filled volume", which is the phrasing that made depositing capital look like trading in the first place. They now say what the number is: volume GENERATED, with an LP position's deposit excluded and its real volume derived from the fees it earned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
GET /controllers/generic/lp_rebalancer/config/template returned 404 "Controller configuration class for 'lp_rebalancer' not found", while every other generic controller returned 200. Without a template nothing can build a config, so GET /controllers/ listed the controller and it was then unusable — the worse of the two failure modes, because it advertises itself first. Its __init__.py imported `from controllers.generic.lp_rebalancer...`. That absolute path is the layout inside a bot container; hummingbot-api mounts the same tree one level deeper and imports it as bots.controllers.*, where a top-level `controllers` package does not exist. lp_rebalancer is the tree's only package-style controller, which is why the damage was total rather than partial: load_controller_config_class tries `...lp_rebalancer` and then `...lp_rebalancer.lp_rebalancer`, and the second has to import the parent package first — so the broken __init__ ran either way and both candidates failed. A relative import resolves under both layouts. The accompanying test is the lint rule: any absolute `from controllers.…` under bots/controllers/ is this bug, and today no file has one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
Two defects on the same endpoint, both found on UMBRA-USDC/meteora.
**The default was `volume`.** On a token whose DLMM pools are all idle every
row ties at volume_24h = 0.00, so the order is arbitrary and liquidity is
never consulted. Of 73 pools, the one holding 15.34K ranked 68th and the one
holding $1.07 ranked 47th — so reading top-down, an agent picked the $1.07
pool and separately reported the deep one as "not found". It was at row 68.
Volume ranks pools by how much OTHERS traded; the LP question is how much
depth is there. It is also the field most likely to be uniformly zero, and a
sort key that collapses to noise is worse than one that merely ranks
differently than you wanted.
**The documented keys did not all work.** "volume, tvl, feetvlratio, etc."
advertised two that 400'd. Probed against the live upstreams:
meteora tvl, volume_24h, fee_tvl_ratio_24h OK
fees_24h, apr, liquidity, volume 400
orca tvl, volume, fees, rewards, yieldovertvl OK
liquidity 400
So feetvlratio was real under another name, and this router's own _24h
suffixing turned `fees` into `fees_24h`, which Meteora rejects outright. Both
reached the DEX, came back a bare 400 and surfaced as an opaque hapi 500 —
reading as a server fault rather than a wrong field name. Keys are now
translated per connector and anything else is refused here, naming the ones
that work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
…emoved
Deploying the controller produced, once, in the bot's own log file:
ERROR - Error adding controller:
parse_provider() got an unexpected keyword argument 'default_trading_type'
and then the bot came up looking healthy with no controller at all — status
"stopped", controller "N/A", four cheerful INFO lines about the network
connecting. strategy_v2_base catches the failure and carries on, so nothing
said the strategy was empty.
The wheel dropped that argument on purpose in 5406f6e26: "the trading type is
never defaulted — Gateway rejects a guessed one with a 400, so an untyped
provider must fail here rather than mid-operation". This caller was not
updated with it. The default was doing nothing anyway: lp_provider defaults to
"orca/clmm" and the deployed value was "meteora/clmm", both already typed.
The copy in the hummingbot repo had been updated and this one had not — and
this is the copy that runs, because hummingbot-api bind-mounts
bots/controllers over /home/hummingbot/controllers, shadowing the image's.
The test constructs every package-style controller. Importing was never enough
to catch this: the module imported fine and the config class resolved fine.
Only construction runs __init__, where a caller and a signature meet.
bots/archived/ joins bots/instances/ in .gitignore. hummingbot-api moves a
stopped bot's whole working directory there, credentials included —
conf/connectors/*.yml carries encrypted API keys and conf/.password_verification
the password check — so `git add -A` sweeps the lot without that line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
A bot's conf/ holds credentials whatever state the bot is in: conf/connectors/ *.yml carries encrypted API keys and conf/.password_verification the password check. bots/instances/ was ignored and bots/archived/ was not, so 61 files from six archived bots — including XRPL and Gate.io connector configs — were tracked, and are on the public remote. Naming parent directories one at a time is how that gap opened. The rule is now `conf/`, which matches at any depth and covers a layout that does not exist yet. bots/conf/ and bots/archived/ are untracked here with `rm --cached`, so the files stay on disk and simply stop being version-controlled. This does NOT remove them from history — already-pushed commits still carry them. Rotating the affected keys is the step that does not depend on a history rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK
Greptile SummaryThis PR adopts Gateway's unified trading routes and expands persistence for executor swaps, LP rent, failed transactions, and generated volume. It also regenerates Gateway models, improves pool sorting and connector validation, and repairs the LP rebalancer's package loading and terminal-position handling.
Confidence Score: 4/5The PR should not merge until unified write requests preserve exact decimal amounts instead of rounding them through binary floats. The new shared body serializer can alter transaction amounts before every affected swap or liquidity request reaches Gateway, causing execution and persisted records to differ from the caller's exact value. Files Needing Attention: services/gateway_client.py
|
| Filename | Overview |
|---|---|
| services/gateway_client.py | Introduces unified route and payload handling, but Decimal write values are narrowed to binary floats before transmission. |
| services/executor_service.py | Adds lifecycle recording for executor swaps, LP rent/refunds, generated volume, and terminal failures with explicit retry and cleanup bookkeeping. |
| database/connection.py | Adds existing-database migrations for rent fields and executor traded volume, including historical non-LP backfill. |
| routers/gateway_swap.py | Unifies quote, execute, and execute-quote flows and centralizes transaction recording and fill reporting. |
| routers/gateway_clmm.py | Migrates CLMM operations to unified Gateway contracts and adds connector-specific pool-sort translation and validation. |
| bots/controllers/generic/lp_rebalancer/lp_rebalancer.py | Updates provider parsing and prevents new LP exposure when a terminated executor still reports an on-chain position. |
| models/gateway_generated.py | Adds generated Pydantic models corresponding to the vendored unified Gateway OpenAPI schema. |
Sequence Diagram
sequenceDiagram
participant Caller
participant API as Hummingbot API
participant Gateway
participant Chain
participant DB
Caller->>API: Quote / execute / LP request
API->>Gateway: Unified /trading request
Gateway->>Chain: Submit transaction
Chain-->>Gateway: Signature, status, amounts, gas/rent
Gateway-->>API: Unified response
API->>DB: Record swap, position event, rent, or failure
API-->>Caller: Execution result
Reviews (1): Last reviewed commit: "chore: untrack every bot conf directory,..." | Re-trigger Greptile
| return { | ||
| key: (float(value) if isinstance(value, Decimal) else value) | ||
| for key, value in request.model_dump(by_alias=True, exclude_none=True).items() |
There was a problem hiding this comment.
There was a problem hiding this comment.
Checked this one carefully — the observation is accurate but the conclusion doesn't hold, and I don't think it should block the merge.
float(Decimal) does narrow to IEEE-754. But the receiving field is a JSON number: gateway declares all 41 request-side decimal fields as type: number (verified against the vendored openapi.json on this branch). A JSON number is parsed by Fastify/AJV into a JS double, so the precision is bounded by the receiver, not by this conversion.
Sending the full-precision literal instead changes nothing:
sent by _body {"amount": 0.12345678901234568}
sent as raw literal {"amount": 0.12345678901234567890123}
parsed from _body 0.12345678901234568
parsed from raw literal 0.12345678901234568
identical after parse True
float() yields the nearest double; JSON.parse yields the nearest double. Same value either way — the conversion is lossless relative to what the receiver can represent. And the alternative the docstring rules out — sending a string — would now be rejected outright, because this PR also turns on additionalProperties/type strictness on gateway's side (a string in a type: number field is a 400, not a silently-coerced number).
The real fix is to change the request schema to string + format: decimal and parse at each of those 41 boundaries in gateway, which is deliberately not in this PR. The response half is done here — 98 response money fields now cross the wire as decimal strings, which is where the precision actually mattered, because those are figures the chain produced rather than figures a caller chose. The request half is tracked as a follow-up.
Worth noting the practical exposure too: a request amount is a value the caller picked, usually with few significant digits, and it is echoed back through the now-exact response before anything settles against it.
There was a problem hiding this comment.
That's a thorough and correct analysis. The precision boundary is the receiver's double, not the sender's representation — float() and a raw literal serialize to the same IEEE-754 value, and JSON.parse collapses them identically. The docstring's rationale for avoiding mode="json" (which would stringify Decimal) is also sound given gateway's strict type: number enforcement added in this PR.
The asymmetry between request and response sides is the right framing: response values originate on-chain and must survive the round-trip exactly, which is why the 98 response fields are converted to decimal strings. Request values are caller-chosen, low-precision inputs that the caller holds and can verify against the echoed response. The schema change to string + format: decimal on the request side is the correct long-term fix and fits naturally as a follow-up once the response half is stable.
Comment withdrawn — no issue here.
Summary
hummingbot-api's half of the Gateway route unification, plus the recording gaps found by running the LP and swap executors against Solana mainnet and then looking for the evidence. Supersedes #217, which was opened from a branch that has since been replaced.
Requires hummingbot/gateway#683 and hummingbot/hummingbot#8431.
The database said everything succeeded
Every row in both position event tables read
CONFIRMEDwith noerror_message— not because nothing had failed, but because a failure could not be written. The recording code runs only when Gateway returns; a transaction that lands and reverts makes Gateway raise, and control skips everycreate_eventto reach anexceptthat persists nothing. A close that reverted at slot 440494812, costing 0.000011772 SOL, left no row at all.Only failures carrying a transaction id are recorded: a pre-flight simulation failure never got a signature and cost nothing, while a landed revert has one and paid gas.
The recommended workflow recorded nothing
gateway_clmm_positions.position_rentis written by the OPEN route andposition_rent_refundedby CLOSE. An executor holds its position through the wheel and talks to Gateway directly, so neither route runs — the poller discovers the position and files it with both columns NULL. That is ~0.0100572 SOL per Orca position, more than the liquidity in a small one, unrecorded on the path that is actually recommended.The same architecture hid every executor swap:
gateway_swapsis written by the/gateway/swap/*routes, so two CONFIRMED MARKET swaps reconciling exactly against the wallet reached no row. The table was not wrong, it was silently partial —/gateway/swaps/searchdescribed only hand-driven swaps, with no marker saying so.Both are now recorded from the executor at completion. Zero is never stored: the executor defaults its rent figures to
0.0, and a stored0.0claims an observation where NULL is the truth.Pool discovery buried the only usable pool
list_poolsdefaulted tosort_key="volume". On a token whose DLMM pools are all idle every row ties atvolume_24h = 0.00, so the order is arbitrary and liquidity is never consulted. Of 73 UMBRA-USDC pools, the one holding 15.34K ranked 68th and one holding $1.07 ranked 47th — so an agent reading top-down took the $1.07 pool and reported the deep one as "not found".Default is now
tvl. The documented keys were also wrong; probing the live upstreams:So
feetvlratiowas real under another name, and this router's own_24hsuffixing manufacturedfees_24h, which Meteora rejects. Both surfaced as an opaque 500. Keys are translated per connector; anything else is refused here, naming the ones that work.lp_rebalancerwas listed and unusableIts
__init__.pyused an absolutefrom controllers.generic...import — the layout inside a bot container, not thebots.controllers.*one hummingbot-api mounts — so/config/template404'd while/controllers/still advertised it. It also calledparse_provider(..., default_trading_type="clmm")after the wheel deliberately removed that argument, andstrategy_v2_basecatches the resultingTypeErrorand carries on: the bot came up healthy with no controller at all, reporting "stopped" and "N/A".Both are fixed, with a test that constructs every package-style controller — importing was never enough, since the module imported fine and the config class resolved fine.
Companion PRs
/tradingroutes, signing-route gate, decimal strings on the wirechainNetworkon tokens/pools, volume from feesexecute_quote, and the spec refreshed for bothbin_countonget_pool_infoValidation
279 tests pass (2 pre-existing failures unrelated to this branch:
test_controller_config_class_loadingparametrises over anema_trend_v1controller the repo does not ship). flake8 and isort clean.Verified against a deployed container: the config template returns 200, the controller constructs, pool listings rank by depth, and the volume aggregate excludes LP deposits — 41.09 of phantom volume removed across the live executors table.
🤖 Generated with Claude Code
https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK