Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
119 changes: 92 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ are listed under [Solvers](#solvers).
- **`internal/solvers/<name>/`** — one self-contained package per integration; all protocol-specific
logic lives here.
- **`internal/{config,chain,signer,txmanager}`** — solver-agnostic infra: two-stage config, vault /
Multicall3 reads, a pluggable signer, and a nonce-serialized transaction broadcaster with independent
receipt waits, shared across solvers.
Multicall3 reads, a pluggable signer, and a nonce-serialized transaction broadcaster that shares
one unresolved signed lifecycle across solvers.
- **`api/`** — committed codegen: contract `bindings/` (abigen) and protocol API clients, each
refreshable from upstream.

Expand All @@ -30,9 +30,10 @@ the relevant protocol API on each tick; no database.
## Solvers

Solvers are listed in config under `solvers:` — one or more, **at most one entry per solver type**.
Every solver in the process shares the chain client, signer, and the single nonce-serialized
`txManager`, so multiple solvers on one EOA never race on nonces. Each entry's `config` block is typed
and validated by its own solver. Adding a solver touches **no** framework code — see the recipe in
Every solver shares the chain client and signer. Transaction-sending solvers also share the single
nonce-serialized `txManager`, so multiple solvers on one EOA never race on nonces. Solvers whose
settlement is submitted externally do not start it. Each entry's `config` block is typed and validated
by its own solver. Adding a solver touches **no** framework code — see the recipe in
[`CLAUDE.md`](./CLAUDE.md).

| `solver.name` | Integration | Docs | Example config |
Expand Down Expand Up @@ -118,8 +119,10 @@ the input, redeems it through LiquidLane, and fills the output via
`LiquidLaneLifiExecutor`. Capacity reserved by already-submitted fills is deducted from both later fill
decisions and standing quotes until those transactions complete. Each token pair advertises the full currently
available capacity even when several pairs share one vault; accepting a fill reserves its shared `CapacityID`
and immediately refreshes every affected quote. A fill remains pending until the shared tx manager reaches
the configured confirmation depth; only then is its reservation released and quote refresh requested. Orders
and immediately refreshes every affected quote. The reservation remains until the shared tx manager returns a
terminal result. Receipted fills, reverts, and cancellations wait for the configured confirmation depth;
pre-sign or definitive broadcast failures end earlier and release the reservation without a receipt.
Orders
that the built-in strategy proves fillable without, but blocked by, pending reservations enter a bounded FIFO
without blocking later deliveries. The worker retries them after every reservation release and returns a still-
blocked order to the tail. During startup/reconnect recovery, quote publication remains suspended until each
Expand Down Expand Up @@ -184,7 +187,9 @@ requires that block; direct routes are authorization-filtered from each snapshot
routes remain usable. In internal
mode `adapters` is optional: a non-empty list scopes quotes and direct fills, while fill-time signed-discount
recovery may use any adapter advertised by the backend. Without a list the solver quotes and fills
discount-only. Every fill is simulated again immediately before submission.
discount-only. Every fill is simulated again immediately before submission. The wall-clock anchor for
a fill is captured before reading chain time, so RPC and planning latency consume the order's remaining
validity instead of extending it.

The quote path is stateless and uses a refreshed on-chain inventory snapshot so it stays within Uniswap's
response deadline. Each request is priced once for its concrete amount: the strategy returns one
Expand All @@ -194,15 +199,20 @@ accounting in both quote and fill decisions and skips gas-state and Chainlink re
prices and pays actual transaction gas, so that cost is then subsidized by the solver. Uniswap deliberately
makes indicative and hard RFQ requests
indistinguishable, so the solver echoes `quoteId` but does not guess the phase. As soon as a polled order is
admitted to the fill queue, quote publication pauses until planning either rejects it or atomically hands
capacity ownership to an accepted transaction reservation. Every posted order gets a fresh route plan from
the current chain state and is simulated before sending. The reservation remains effective while txmanager waits
for the configured confirmations. On completion the quote snapshot is invalidated before capacity is
admitted to the fill queue, quote publication and `GET /ready` pause. They remain paused during planning and,
once the submission occupies the shared nonce lane, while it holds that queued or admitted lifecycle,
including receipt confirmation. The fill's capacity reservation still protects already-awarded orders for
the same period; it does not reopen quoting. Every posted order gets a fresh route plan from the current chain
state and is simulated before sending. On completion the quote snapshot is invalidated before capacity is
released, and that capacity is not advertised again until a fresh post-fill chain snapshot is published.
A quote is returned only if its snapshot epoch and every blocking condition are unchanged after the strategy
finishes. Quoting fails closed during startup warmup, stale or unknown exclusive-order delivery, fill
planning, an active Uniswap `blockUntilTimestamp`, or the configured local fade breaker. `GET /ready`
exposes that state and also returns not-ready when the latest snapshot has no quotable inventory;
planning, a queued or admitted txmanager lifecycle, an unavailable nonce lane, an active Uniswap
`blockUntilTimestamp`, or the configured local fade breaker. A claimed order is requeued before chain reads,
signed-discount resolution, calldata construction, or preflight while the nonce lane is paused. A txmanager
result that failed before admission does not count toward the local fill breaker and is reported as
`uniswapx_fills_total{outcome="not-admitted"}` rather than a failed fill. `GET /ready` exposes that state and
also returns not-ready when the latest snapshot has no quotable inventory;
`GET /health` and its probe-friendly alias `GET /healthz` remain liveness-only.

Every valid exclusive order assigned to the executor is tracked through `decayStartTime`. After that
Expand Down Expand Up @@ -261,15 +271,39 @@ The solvers split protocol plumbing (reads, signing, submission — fixed) from
This is the seam for customizing a solver without forking. Contract and trust model:
[`docs/strategy-plan.md`](docs/strategy-plan.md).

The shared `txManager` fee-bumps pending transactions on `replacementIntervalMs`. After
`pendingTimeoutMs`, it cancels only the lowest unresolved nonce; a higher nonce whose timer fires while a lower
nonce remains unresolved waits another `pendingTimeoutMs`. The required `maxFeeGwei` is the absolute ceiling;
normal sends reserve one fee bump inside that ceiling so cancellation still has headroom. During shutdown the
manager stays alive while solvers finish accepted work. The finite hard-stop budget is each solver's bounded
preparation phase (for LI.FI, one `orderServer.httpTimeout` for quote expiry plus one for admitted-inbox drain) plus
`pendingTimeoutMs + replacementIntervalMs`. This is a best-effort drain window: once it expires, the manager
stops even if later transactions remain pending. It bounds local shutdown, not RPC latency, mining,
cancellation of every nonce, or mempool eviction.
When used, the shared `txManager` owns one unresolved signed nonce lifecycle at a time. Later
submissions are neither accepted nor signed until the active lifecycle has a terminal receipt. Every
`replacementIntervalMs` it attempts a replacement using fresh fees and at least a 12.5% bump over the
previous attempt; if fresh fees are unavailable, it bumps the cached fees. At `pendingTimeoutMs` (or
the request's earlier deadline), replacements switch to a same-nonce cancellation.

The transaction lane is ready for new external commitments only while it has no queued or admitted
lifecycle and nonce ownership is certain. While the lane is occupied or conflicted, UniswapX and RFQ
decline new quotes, LI.FI retires its active standing curves, and 3F stops posting new offers.
Reconciliation and already-accepted work continue. A normal submission that races a nonce conflict waits
without signing until exact-hash reconciliation restores the lane, its request deadline expires, or shutdown
begins; non-blocking admission declines immediately. This lets the process recover without abandoning an
immutable order that has already been accepted from an upstream protocol.

During graceful shutdown the manager remains alive while solvers stop external commitments and drain
already-accepted work. The solver drain is bounded by its preparation timeout plus `pendingTimeoutMs`
and `replacementIntervalMs`. When manager shutdown begins, new admission stops and it requests
same-nonce cancellation when nonce ownership is not conflicted. It keeps draining exact signed attempts
for at most `shutdownTimeoutMs`; if no terminal receipt is available by then, callers receive a
shutdown-deadline error and the process exits instead of hanging indefinitely. Configure the
orchestrator's SIGTERM grace to cover the sum of those bounds.

The required `maxFeeGwei` is the global EIP-1559 fee cap, including cancellation. Normal transactions
stay one 12.5% bump below it so cancellation has headroom, and the initial send reserves another bump
inside its normal cap for a replacement. A solver-supplied request cap applies to the original call
and its replacements; cancellation may exceed that request cap but never `maxFeeGwei`. A positive
`tipGwei` is the only mandatory priority-fee floor. A higher node suggestion is advisory and is clamped
to the fee cap's available headroom instead of blocking an otherwise valid send. Startup rejects a
positive floor that leaves no base-fee headroom after both reserved bumps, and runtime submission fails
when the current base fee leaves insufficient room for that floor. With `tipGwei: 0` (or the field omitted), txmanager
instead uses the median p75 priority reward from the latest five blocks, likewise clamped to available
headroom. Invalid or unavailable `eth_feeHistory` fails new submissions closed; setting a positive floor
provides the operator-controlled fallback.

## Requirements

Expand Down Expand Up @@ -302,11 +336,42 @@ command list (`run`, `version`). Debug logging is off by default; enable it with
Config is YAML with a two-stage decode: the framework reads `solver.name` to select the
implementation and hands the opaque `solver.config` block to that solver to type. Each solver has its
own fully annotated example under `config/` (see the *Example config* column above) — every field,
including the shared `chain`/`signer`/`txManager`/`observability` blocks, is documented inline there.
including the applicable shared `chain`/`signer`/`txManager`/`observability` blocks, is documented
inline there.

The `chain` block takes a primary `rpcUrl` plus optional `rpcFallbackUrls` — HTTP(S) endpoints tried
in order when the primary is unavailable. LiquidLane state reads always use RPC `latest`; an archive
node is not required. **Never commit a real key or live config** — keys are
supplied via env/file behind the `Signer` interface; `*.local.*` and `.env` are gitignored.
in order for reads when the primary is unavailable. Signed broadcasts and both startup nonce reads
are pinned to `writeRpcUrl`, or the primary `rpcUrl` when it is omitted, and never fall over across
endpoints. Receipt confirmation does not rely on endpoint affinity: it requires a stable head and proves
that the receipt block belongs to that head by following hash-addressed parent headers. Each request keeps
normal read fallback behavior. A non-final endpoint's JSON-RPC `null` receipt or header result falls through
to the next read endpoint; the final endpoint's `null` remains the ordinary not-found result. An unavailable
or incoherent multi-read snapshot is retried on a later poll. An explicit write endpoint must report the same
chain ID as the read endpoint.

For transaction-sending solvers, startup fails closed when the write endpoint's pending nonce differs
from its latest mined nonce because `txManager` cannot recover an unknown signed lifecycle. The EOA
must be exclusive to this process: standard nonce reads cannot reveal a future transaction queued
beyond a gap. Before upgrading from a build that allowed several unresolved signed nonces, drain that
EOA's write-endpoint pool. After an unclean exit, nonce equality alone cannot rule out a private
submission hidden by its relay. The packaged Docker Compose deployment restarts automatically with
`unless-stopped`, so it can resume and reuse that nonce before the hidden submission becomes visible. If
the old attempt later consumes the nonce, `txManager` pauses admissions and readiness and remains
fail-closed for operator investigation; automatic restart does not recover the lost in-memory ownership.
For controlled maintenance, stop the service and reconcile outstanding private submissions before bringing
the EOA back.

At runtime, a post-signing `nonce too low` makes `txManager` check every exact signed attempt. During a
replacement of an already tracked lifecycle, a receipt proven canonical against a stable head resolves
ownership immediately. The lane remains non-ready only because that owned lifecycle is still active until
its confirmation depth is reached, not because ownership is uncertain. An initial-broadcast collision, or a
replacement with no owned canonical receipt, keeps new transactions and readiness paused until terminal
reconciliation or operator action; a later receipt reorg restores that pause. The calldata is not re-signed
at another nonce solely from that response. LiquidLane state reads always use RPC `latest`; an archive node
is not required.

**Never commit a real key or live config** — keys are supplied via env/file behind the `Signer`
interface; `*.local.*` and `.env` are gitignored.

## Code generation

Expand Down
Loading