Skip to content

feat(gateway): adopt the unified route surface, and record what the executors actually did - #221

Open
fengtality wants to merge 46 commits into
mainfrom
feat/unified-trading-routes-client
Open

feat(gateway): adopt the unified route surface, and record what the executors actually did#221
fengtality wants to merge 46 commits into
mainfrom
feat/unified-trading-routes-client

Conversation

@fengtality

@fengtality fengtality commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

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.

Note on history: this branch was rewritten to remove bots/archived/ from every commit — six archived bot instances had been committed, carrying conf/connectors/*.yml (encrypted API keys) and conf/.password_verification. conf/ is now ignored at any depth. The branch's tip content is unchanged; only the removed paths differ. The rewrite is scoped to this branch's own 46 commits, so history shared with main is untouched.

The database said everything succeeded

Every row in both position event tables read CONFIRMED with no error_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 every create_event to reach an except that 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_rent is written by the OPEN route and position_rent_refunded by 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_swaps is 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/search described 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 stored 0.0 claims an observation where NULL is the truth.

Pool discovery buried the only usable pool

list_pools defaulted to sort_key="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 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:

meteora   tvl, volume_24h, fee_tvl_ratio_24h   OK
          fees_24h, apr, liquidity, volume     400
orca      tvl, volume, fees, rewards, yieldovertvl   OK

So feetvlratio was real under another name, and this router's own _24h suffixing manufactured fees_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_rebalancer was listed and unusable

Its __init__.py used an absolute from controllers.generic... import — the layout inside a bot container, not the bots.controllers.* one hummingbot-api mounts — so /config/template 404'd while /controllers/ still advertised it. It also called parse_provider(..., default_trading_type="clmm") after the wheel deliberately removed that argument, and strategy_v2_base catches the resulting TypeError and 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

Validation

279 tests pass (2 pre-existing failures unrelated to this branch: test_controller_config_class_loading parametrises over an ema_trend_v1 controller 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

fengtality and others added 30 commits August 12, 2026 18:22
… 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
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
fengtality and others added 16 commits August 20, 2026 10:18
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-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown

Greptile Summary

This 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.

  • Routes swap, AMM, and CLMM operations through the unified Gateway surface.
  • Records executor-produced swaps, LP account rent/refunds, failures with transaction IDs, and actual traded volume.
  • Adds database models, repositories, and migrations supporting the new persisted data.
  • Defaults pool discovery to TVL and validates connector-specific sorting and extra parameters.
  • Fixes LP rebalancer imports, provider parsing, and orphaned-position behavior.

Confidence Score: 4/5

The 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

Important Files Changed

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
Loading

Reviews (1): Last reviewed commit: "chore: untrack every bot conf directory,..." | Re-trigger Greptile

Comment on lines +92 to +94
return {
key: (float(value) if isinstance(value, Decimal) else value)
for key, value in request.model_dump(by_alias=True, exclude_none=True).items()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Decimal amounts lose precision

When a transaction amount exceeds IEEE-754 precision, _body converts its Decimal to a binary float before transmission, causing Gateway to execute and record a rounded value instead of the caller's exact swap or liquidity amount.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants