10 Commits
Author SHA1 Message Date
zeekayandHanzo Dev d0d8a84e03 Being busy said the chain was down
Nothing could be read, so it answered that the chain could not be reached. Under
load that is this deployment running out of its own time budget with the chain
answering normally the whole while, and the two send a reader to different
places. A timeout says so and names what to do about it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-09 12:35:38 -07:00
zeekayandHanzo Dev 996677759a One request the edge permits was a thousand questions it did not
A price is a fan-out. The edge limits requests — thirty a second from one
address — and each may name fifty tokens, read at two sizes against two
numéraires across every arm. Nothing bounded what came out the other side, and
what comes out lands on public endpoints we hold no account with; being refused
there takes quoting down for everybody, on every chain.

So the budget is sixteen questions in flight against one chain for the whole
process, rather than eight per request multiplied by however many requests
arrive. It is taken around a single reading and not around a whole price,
because a price against the hub waits for the hub's own price, and a slot held
across that wait deadlocks the first time every slot is held by something
waiting on the hub.

Measured: one request of 50 cold Ethereum tokens went from 10.1s to 8.6s, and
four chains of 40 at once from 10.7s to 9.3s — and returned the same counts on
a repeat, where the same burst before this returned a different set of prices
each time.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-09 12:31:14 -07:00
zeekayandHanzo Dev f7a7c12d9a A pool with nothing left in it answers, and a rounding error prices in millions
The first cut read each market once, at a hundred dollars. That is right for a
pool with depth and nonsense for one without: a hundred dollars into an
abandoned pool buys a rounding error, and the implied price of a rounding error
is enormous. Measured over the fifty tokens Ethereum lists first, half of what
came back was more than one percent off an independent source and AUCTION — a
token worth $3.44 — read $889,434,176.

So the pools are asked twice, at ten dollars and at a hundred, and a price that
does not survive ten times the size is not returned at all. Twenty-five of the
twenty-seven that now come back agree with that source within two percent. The
two that do not are real pools here sitting off the wider market, and that is
this venue's price: /v1/trade/quote for the same size implies $0.036806 for ASM
where /v1/trade/price says $0.036834, so the screen and the swap panel agree,
which is the thing that has to be true.

The gate is on the best reading at each size, not on each numéraire separately.
Gating per numéraire refuses the cheapest route for being thin and then quotes a
dearer one that was not — ANT came back at $3.72 against a market of $0.07 that
way, because the route that knew better had been thrown out.

Both sizes are asked at once, so the second one costs calls and not latency:
fifty cold tokens on Ethereum in 10.1s, the same fifty warm in 36ms.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-09 12:20:28 -07:00
zeekayandHanzo Dev 10a1a551c3 A listing that stops at two hundred is a screen missing two hundred markets
The limit defaulted to a page size, and Ethereum lists 403. A client that did
not know to ask for more got two thirds of the chain with nothing to say so.
There is no paging here to need one: the largest chain lists 403 and all seven
together list 935, so the limit is a ceiling on a request and the default is
everything.

And a chain this deployment cannot quote now answers the way /v1/trade/venues
already answers about it, rather than with an empty list a screen would draw as
a market with nothing in it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-09 12:01:55 -07:00
zeekayandHanzo Dev 4c591bfc94 A chain with no indexer still knows its own tokens and what they are worth
Explore on Ethereum drew nothing at all — not an empty table, no request made
— because enumerating a chain went to an indexer that only carries ours, and
the hosted API that would answer costs a contract. Two paths close that without
asking anybody for one.

/v1/trade/tokens serves a list the binary carries: 935 tokens over the seven
chains this gateway prices, identity only, checked at build time rather than
per request. No logo, and the struct has nowhere to put one — every public list
gives logos as URLs on four hosts belonging to somebody else, fetched by every
viewer's browser on every market screen.

/v1/trade/price is a reading of the same pools /v1/trade/quote reads: spend a
fixed hundred dollars of a numéraire, see what comes back, put the pool's fee
back in. Not one whole token — that is a fifty-cent trade in one market and a
hundred-thousand-dollar one in the next, and the second moves the pool it is
measuring. Not the raw quote either: a pool hands back the mid less its tier,
which is $1.0031 for a $1.00 token and $3018.51 for a $3000 one two hops deep.
Two numéraires per chain, the dollar and the wrapped native token, and the
cheaper reading wins. Measured against an independent source on seven chains,
every price inside half a percent.

A token no pool holds comes back WITHOUT priceUSD, never with a zero: a screen
can draw a dash and it cannot un-draw a wrong number. A reading stands for a
minute; an unreadable chain for ten seconds, because the pools have not changed
— our access to them has. Fifty per request, eight in flight, and thirty-two
readers of one cold price produce one fan-out.

Two things this needed that were wrong on their own account. A fee had two
units — the V2 arm answered 30 where the V3 arm answered 3000 for the same
three tenths of a percent — so feeFromBPS converts once and nothing else means
anything else by that number. And the arm reading our own precompiles answered
"no liquidity" to an endpoint that never replied, which is enough on its own to
report a chain nobody could reach as a pair nobody holds.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-09 11:52:57 -07:00
zeekayandHanzo Dev 5b9ac470d9 The deadline is honoured on every arm, not only the V2 one
SwapRouter02 dropped the deadline from exactInputSingle's tuple and takes it
through multicall(uint256,bytes[]) instead, which is how Uniswap's own
interface sends one. Without that wrap a deadline handed to /v1/trade/swap was
accepted and silently dropped on every V3 route while the V2 route honoured it
— one field meaning two things depending on which pool happened to win the
quote.

amountOutMinimum bounds the price; the deadline bounds the time. A signed swap
that sits in a mempool and lands an hour later still passes its floor, at a
price nobody would choose now. Unset means twenty minutes out, because zero is
a deadline in 1970 and every swap would revert as too old.

Measured against 96369's live SwapRouter02, both directions:
  deadline ahead of block.timestamp -> 997001483075056712, the quoted amount
  deadline behind it                -> execution reverted: Transaction too old

Worth knowing for anything that tests this: 96369 seals a block when there is a
transaction, so block.timestamp ran 4709 seconds behind wall clock while this
was measured. A deadline computed from wall clock is generous here rather than
tight — the safe direction, but `now - 60` will not read as expired.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-09 01:17:20 -07:00
zeekayandHanzo Dev e0fde794da The venue that quotes is the venue that builds, and every path answers
/v1/trade/swap asked the hosted provider registry for a price. This deployment
registers none on purpose, so it answered 500 while /v1/trade/quote priced the
same pair from the pools a line away — and then encoded whatever came back for
the PoolManager precompile at 0x9010 regardless of where the price came from. A
router on Ethereum quoted and a precompile on our chain addressed, in one
response. 0x9010 answers 0x on 96369, measured with the venue's own quoter
selector, so the calldata named a contract that is not there.

Venue has a Swap method now. Each one builds against the contract it read: the
V3 arm against the SwapRouter02 beside its quoter, the V2 arm against the same
Router02 that answered getAmountsOut. A venue that prices a market it cannot
settle returns nothing, and the surface says so naming it.

The selector was wrong in the way that spends money. calldata_lxrouter.go sent
04e45aaf under a signature it called ExactInputSingle(address,address,uint256,
uint256,uint160,bytes32); that hashes to f48f227e. 04e45aaf is SwapRouter02's
exactInputSingle((address,address,uint24,address,uint256,uint256,uint160)) — a
seven-field tuple. A router handed the six-field form reads amountIn where the
fee tier belongs and amountOutMinimum where the recipient belongs. Those
builders were reachable only from their own tests; the padding and decoding
survive in abi.go.

Proved end to end: the gateway's own calldata replayed through eth_call against
96369's real SwapRouter02, from the account holding the allowance, returns
997001483075056712 — the number the quote gave. The tier travels on
route[0].fee because a V3 quote asks four tiers and keeps one, and a swap that
does not name the same pool executes at a different price. amountOutMinimum is
99.5% of the quote by default, because a router handed the quoted amount as its
floor reverts on the movement the caller's own trade causes.

Permit2 is at one address everywhere it exists and does not exist everywhere:
9152 bytes on Ethereum, nothing on 96369. Both paths check first, so our chain
gets a sentence instead of calldata addressed to an empty account.

Nine more paths are gone because they could not work here. Five answered 500
"no providers available" — tokens, pools, pool/, positions, stats, price,
prices — because enumerating a chain is an index read and this reads one pool
by address, right now. api-explore.lux.cloud already answers all of it: 16
pools, $39.9M of TVL, real symbols and decimals for 96369. /v1/route is the
same read in a hat and returned {"routes":[]} forever, which a caller reads as
"no path exists" rather than "I cannot answer". The order paths were an
in-memory map behind two replicas, so an order placed on one pod was a 404 on
the other about half the time and every order died on restart — the D-Chain
venue is the order book and it is deployed beside this. The position builders
all addressed 0x9010.

Eight paths remain and all eight were exercised against the live chain.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-09 01:01:50 -07:00
zeekayandHanzo Dev 8cc08f3b69 One trading surface, at one name, describing itself truthfully
Three things claimed to be this repo's trading API and no two agreed.

pkg/trading carried its own Venue interface, its own V4/native/V2/V3 and broker
venues, its own constant-product math and its own eth_call client — a copy of
what pkg/gateway already had — and nothing mounted it: grep for the import
outside that directory returns empty. api/openapi.yaml documented it anyway, so
the one published contract in the repo described the one package no binary
served. Meanwhile the binary answered at /v1/* and again at /trading/* in
Uniswap's shapes: two contracts for one API, reachable by both names, free to
drift. Deleted, both of them, rather than picked between and left beside.

Everything answers under /v1/trade now. api.lux.cloud carries the chain at
/v1/chain, the C-chain at the root and other services besides, so /v1/quote was
a generic name claimed on a shared host; one prefix is one namespace with one
owner, and the edge rule that publishes it collapses from a nine-clause
allowlist to a prefix. Server.routes() is the surface written once, and
TestTheDocumentIsTheSurface reads api/openapi.yaml and holds the two to each
other in both directions.

/v1/trade/venues is new. It is the question a screen asks before it asks
anything else — which chains this deployment can price, and from what — grouped
by chain, because a venue is a contract on a chain and the same name means a
different pool on each. Without it a client learns a chain is unquotable by
quoting on it and reading an error.

The allowance is read rather than assumed. approval/check and permit2/check
answered allowance 0 and needsApproval on every request, under a comment saying
the gateway had no way to reach a chain; it has one per chain, one field away —
the same endpoint the pool is quoted from. Measured against 96369: LUSD to the
position manager reads 6000000000000000000000000, which is what eth_call
returns for that pair, and the approve tx now rides along only when it is
needed. Handed back beside "you already approved" it is a transaction somebody
sends, and the user pays gas for a state change that changes nothing before
every swap. Permit2 reads the expiration too, because a grant that has run out
is not a grant.

Nothing here invents a number any more. /v1/history/prices and /v1/history/tvl
returned a random walk seeded from hardcoded base prices — LUX at 2.47, WBTC at
96420, 12.5M of TVL on a chain whose pools were seeded hours ago — and both
were routed to the public internet, where a chart drawn from them is
indistinguishable from a chart of the market. History belongs to the indexer,
which reads the chain. /v1/tokens/search answered 501 on a TODO, a route lying
about the surface in the other direction. Gone, with a test that keeps them
gone.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-09 00:40:44 -07:00
zeekay aa2519c830 docs: add Trading API to OpenAPI spec and LLM.md
- OpenAPI 3.0.3 spec now covers all 7 /v1/trade/* endpoints
  with full request/response schemas
- LLM.md documents pkg/trading architecture, venue interface,
  calldata encoding, chain IDs, and consumer usage pattern
2026-03-29 18:11:11 -07:00
zeekay 62e32d1c39 Fix CI workflows to remove non-existent dependencies 2025-08-24 16:16:12 -05:00