Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
19a20eb
docs: design user-directed swap calldata
1kresh Aug 3, 2026
1f5c80d
docs: plan user-directed swap calldata
1kresh Aug 3, 2026
00a9ab6
feat(rfq): configure user-directed swaps
1kresh Aug 4, 2026
919c043
feat(rfq): define swap v2 wire contract
1kresh Aug 4, 2026
6f92ab3
feat(rfq): retain swap allocation identity
1kresh Aug 4, 2026
309bffc
feat(rfq): store immutable swap confirmations
1kresh Aug 4, 2026
248d695
feat(rfq): encode signed swap adapter calls
1kresh Aug 4, 2026
d68aefa
feat(rfq): validate swap adapter state
1kresh Aug 4, 2026
53228fe
feat(rfq): discover confirm and build swap calldata
1kresh Aug 4, 2026
7dafc68
feat(rfq): build eligible discount swap calls
1kresh Aug 4, 2026
2536a61
feat(rfq): expose authenticated swap calldata API
1kresh Aug 4, 2026
dbffeca
fix(rfq): bind swap calls to intended swapper
1kresh Aug 4, 2026
d7c7967
docs(rfq): document swap calldata protocol
1kresh Aug 4, 2026
ef8697f
fix(rfq): harden Router swap authorizations
1kresh Aug 4, 2026
4aecba7
fix(rfq): harden user-directed swap builds
1kresh Aug 4, 2026
708dbdd
fix(rfq): simplify Router swap call payloads
1kresh Aug 4, 2026
5be97ad
docs(rfq): align Router signature plan
1kresh Aug 4, 2026
748f0f8
docs(rfq): specify per-leg swap authorization
1kresh Aug 4, 2026
6a776fd
docs(rfq): plan per-leg swap authorization
1kresh Aug 4, 2026
de3096e
refactor(rfq): select swap authorization per leg
1kresh Aug 4, 2026
12a8578
feat(rfq): encode resolved discount swap calls
1kresh Aug 4, 2026
cad5f63
feat(rfq): preserve per-leg swap authorization
1kresh Aug 4, 2026
c3b68b9
docs(rfq): describe per-leg swap calldata
1kresh Aug 4, 2026
750bc33
test(rfq): simplify swap authorization fixtures
1kresh Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,26 @@ When an exact-input request exceeds the advertised adapter capacity, the default
quoted output at the available `maxAssets` instead of declining in every token scope; the excess input
is reflected as worse execution price and price impact. Awarded orders are planned again from current
LiquidLane state at fill time; the solver does not retain quote-time route plans.

`swapEnabled: true` additionally exposes authenticated `POST /swap` for user-directed Router
transactions. The same `x-rfq-shared-secret` header protects it. The backend first sends `DISCOVERY`
sample amounts, then `CONFIRM`s one exact point, and finally sends `BUILD` for immutable adapter
calldata. Each response call exposes `to`, opaque signed adapter `data`, and accounting metadata; the
backend maps `to`, `amountIn`, and `data` to the Router's three-field call tuple. No separate Router
authorization or signature is returned. Authorization is selected per leg: direct legs use Router-bound
signed-swap selector `0x9a4568b6`, while a discount-selected leg resolves its persisted signed discount
and returns selector `0x8fa5c671` with the Router recipient and confirmed input amount. One response may
mix both call types in confirmed order. A resolved signed discount is a replayable bearer authorization
until its signed deadlines because the existing discount ABI does not bind the outer recipient or amount.
`CONFIRM.deadline` is a requested maximum: the
solver returns the earlier local validity cap, and `BUILD` selects one exact unexpired deadline at or
before that cap for every call in the selected solver response. A retry keeps the same build
ID and economic tuple but uses a fresh transport-only `requestId`; cached calls and adapter signatures
or resolved discount payloads are reused under the new response envelope. Requests and confirmed plans are limited to 64 adapter calls.
The solver never broadcasts the transaction or transfers the user's tokens. Confirmations live only for
`swapQuoteTtlMs` and are invalidated by a solver restart. Startup validates deployed Router bytecode plus
adapter signer authorization and EIP-712 domains for configured adapters; request-local direct adapters
are checked again during confirmation and build, while discount legs use their resolved signatures.
Design, config, and roadmap:
[`docs/RFQ-PLAN.md`](docs/RFQ-PLAN.md) · example
[`config/rfq.example.yaml`](config/rfq.example.yaml).
Expand Down
17 changes: 14 additions & 3 deletions config/rfq.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# Provide secrets by env-var NAME (read at point of use, never in the parsed config):
# SOLVER_PRIVATE_KEY — the caller EOA; must be an authorized caller of the Executor
# (the Executor `setCallers` allowlist), granted out-of-band by its owner
# RFQ_BACKEND_SHARED_SECRET — shared secret authenticating the backend peer on POST /quote
# RFQ_BACKEND_SHARED_SECRET — shared secret authenticating the backend peer on POST /quote and POST /swap
# ${VAR} fields are expanded from the environment at load time. Never commit a real key or endpoint.

chain:
Expand Down Expand Up @@ -43,11 +43,22 @@ solvers:
config: {} # opaque to the framework; parsed by the named strategy

backendUrl: ${RFQ_BACKEND_URL} # RFQ backend base URL (host root; the client adds the /api/v1 prefix)
backendSharedSecretEnv: RFQ_BACKEND_SHARED_SECRET # env var NAME of the POST /quote shared secret
listenAddr: ":42073" # bind address for the quote HTTP server (POST /quote, /health, /docs)
backendSharedSecretEnv: RFQ_BACKEND_SHARED_SECRET # env var NAME of the POST /quote + POST /swap secret
listenAddr: ":42073" # bind address for POST /quote, optional POST /swap, /health, /docs
pollIntervalMs: 3000 # how often to poll the backend for awarded open orders
orderLimit: 20 # max open orders fetched per poll

# User-directed swap calldata is opt-in. Enabling it mounts authenticated POST /swap and makes
# startup fail unless Router bytecode exists and this framework signer is authorized by every
# configured adapter. BUILD returns a direct signed-swap call or the exact resolved signed-discount
# call selected for each confirmed leg; one response may mix both in confirmed order. The backend
# maps each call's to, amountIn, and data to the Router's three-field tuple; no separate Router
# authorization is returned. Plans are capped at 64 calls, and the solver never broadcasts the
# user's Router transaction.
swapEnabled: false
router: "" # deployed user-directed Router; required when swapEnabled: true
swapQuoteTtlMs: 30000 # maximum confirmation lifetime; longer requested deadlines are shortened

# RFQ contract deployment (mainnet):
executor: "0x031f569DA822A6b8D1500D74733986a9aE4cBda3" # Executor — the bot calls Executor.fill to settle
reactor: "0xC323B898d7E4105E3980082B74CC5D4602996B10" # Reactor — invoked by the Executor at fill time
Expand Down
58 changes: 56 additions & 2 deletions docs/RFQ-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ push path; orders are found exclusively by polling the backend.
inventory snapshot in `adapters[]`; the filler prices it, applies a discount, selects the best
adapter legs, and returns an `amountOut`), `GET /health`, and
the code-first OpenAPI surface (`/openapi.json`, `/openapi.yaml`, `/docs`). `/quote` is gated by an
`x-rfq-shared-secret` header (the backend peer). There is **no `/notify` endpoint**.
`x-rfq-shared-secret` header (the backend peer). When explicitly enabled, authenticated `POST /swap`
returns user-directed calldata without submitting it. There is **no `/notify` endpoint**.
- **Poller** — every `pollInterval`, `GET /orders?filler=<executor>&orderStatus=open` from the
backend, then drives each order through `queued → submitting → submitted → {filled|expired|failed}`.
- **Execution** — builds `Executor.fill(Order, protocolSig, Swap[], DiscountSwapInput[], bytes)` and
Expand All @@ -25,6 +26,50 @@ push path; orders are found exclusively by polling the backend.
leg's **adapter** address.
- **State** — in-memory only: `orders` (state machine) and `attempts`.

### User-directed swap calldata

`swapEnabled: true` adds a protocol-exact `v2` lifecycle on `POST /swap`; disabled deployments do not
register the route or advertise it in OpenAPI. The backend authenticates with the same
`x-rfq-shared-secret` used by `/quote`:

1. `DISCOVERY` supplies strictly increasing exact-input samples and an adapter inventory. The solver
performs one coherent largest-sample read and returns attainable points plus canonical shared-vault
capacity domains.
2. `CONFIRM` selects one exact discovery point. The solver re-reads current liquidity, re-runs the
configured strategy, verifies the same domains and output floor, validates framework-signer
authorization for every selected direct adapter,
and stores the ordered allocation in-memory until the earliest of the requested maximum deadline,
configured TTL, and route validity. A longer requested deadline is shortened rather than rejected.
3. `BUILD` is bound to that immutable confirmation, Router, domains, one chosen unexpired deadline, and
one build ID. It revalidates adapter state and exact capacity, then preserves authorization per leg:
direct legs use signed-swap selector `0x9a4568b6`, while discount legs resolve the exact persisted
discount ID and use selector `0x8fa5c671`. A mixed response preserves confirmed order. A retry may use
a fresh transport-only request ID; the immutable payload is byte-identical, while a second build ID or
changed economic tuple conflicts.

For a signed adapter call, recipient and caller are the configured Router. The adapter nonce is
deterministic over build ID, chain, adapter, input token, and call index. The BUILD response returns `to`,
opaque adapter `data`, and accounting metadata; the backend maps `to`, `amountIn`, and `data` to the
Router's three-field call tuple. There is no separate Router authorization signer, deadline, or signature.

For a discount adapter call, BUILD fetches the resolved signed discount, validates its identity, exact
route, current adapter minimum, output floor, nonce state, and both deadlines, then packs the unchanged
terms and signatures with the Router recipient and persisted input amount. Both signed deadlines must be
strictly later than the chosen BUILD deadline. The existing discount ABI does not sign the outer recipient
or amount and does not consume the nonce, so the resolved signed discount remains replayable bearer data
until expiry; this accepted limitation is documented in the per-leg design spec.

The solver signs calldata only: it neither performs the Router's transfer-before-call funding nor
broadcasts a transaction. The public transaction uses ordinary ERC-20 approval and zero native value;
the Router transfers each exact leg directly from the user to its adapter, invokes the returned data,
then transfers exact declared outputs to recipients. Plans contain at most 64 calls, and BUILD
independently enforces its confirmed aggregate output floor before returning. The backend selects one
solver response; a solver response may itself contain multiple adapter calls.

Discovery and confirmation records are bounded, expiring, in-memory state. A process restart
invalidates them, so the backend must repeat `DISCOVERY` and `CONFIRM`; it must never reuse an old
`BUILD` against a restarted solver.

The `/quote` request inventory (`adapters[]`) still matches the TS `solverQuoteRequestSchema`, but the
solver maps that boundary shape into the shared LiquidLane terms from
[`LIQUIDLANE-CONVENTIONS.md`](LIQUIDLANE-CONVENTIONS.md): `Inventory` is
Expand All @@ -41,7 +86,7 @@ the direct path is solid; they are sequenced last, not dropped.
A new self-contained `internal/solvers/rfq/` implementing `solver.Solver` — no framework edits
(CLAUDE.md modularity rule). The generic layer is reused as-is:

- **`Run(ctx)`** starts the RFQ **HTTP listener** (`/quote` + `/health` + OpenAPI) *and* the poll
- **`Run(ctx)`** starts the RFQ **HTTP listener** (`/quote` + optional `/swap` + `/health` + OpenAPI) *and* the poll
loop, blocking until ctx cancels. The HTTP server is an RFQ-specific concern and lives in the RFQ
package; the framework's observability server (`:9090`, metrics/health/ready) stays separate.
- **OpenAPI is code-first via Huma**: the request/response structs in `apitypes.go` carry validation
Expand Down Expand Up @@ -191,6 +236,9 @@ solvers:
reactor: "0x…"
pollIntervalMs: 3000
orderLimit: 20
swapEnabled: false # opt-in authenticated POST /swap
router: "" # required deployed Router when enabled
swapQuoteTtlMs: 30000 # maximum in-memory discovery/confirmation lifetime
solverMode: external # "external" (default) | "internal" — see below
minAmountsIn: # optional per-input-token floor (base units)
"0x…tokenIn": "1000000000000000000" # below ⇒ no quote (204); equal ⇒ still quotes
Expand Down Expand Up @@ -279,6 +327,12 @@ dropping features.
output capacity is absorbed as price impact, matching the other exact-input scopes. Cold fill
planning applies the same constraint. Unit-tested across scope gating, permissionless aggregation,
single-route capped output, webhook rejection, and fresh planning.
6. **(done) User-directed swap calldata** — opt-in authenticated `DISCOVERY`/`CONFIRM`/`BUILD`,
immutable bounded confirmation state, per-leg direct `SignedSwap` or resolved `DiscountSwap` calldata,
capped confirmation validity plus one chosen BUILD deadline, transport-only request-ID retries over
immutable cached payloads, a 64-call bound,
three-field Router calls, and fail-fast Router/static adapter validation. This path never
sends a transaction and does not alter the legacy fill poller or its private discount execution.

**Reads are multicall-batched** end to end: amount-specific strategy evaluation uses the shared
per-route fill-quote batch (`paused`, `getMaxAssets`, `getAmountOut`, `minDiscount`), while inventory
Expand Down
Loading