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
39 changes: 16 additions & 23 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ MORPHO_GRAPHQL_URL ?= https://api.morpho.org/graphql
# Contracts whose ABIs are vendored via refresh-abi. ABIS come from the rfq Foundry build; the
# CORE_MIRROR_ABIS (the 3F ThreeFAdapter, LiquidLane adapter, adapter factory, universal delegator,
# and vault/ERC4626 interfaces) come from the core-mirror build, since they aren't in rfq/out.
ABIS := IRequest IVaultController IWhitelist Executor Reactor
ABIS := IRequest IVaultController IWhitelist Executor Reactor LiquidLaneLifiExecutor
CORE_MIRROR_ABIS := ThreeFAdapter LiquidLaneAdapter IAdapterFactory IVaultV2 IERC4626
# api/abi/UniversalDelegator.json is hand-vendored to a minimal {limitOf} ABI (the full contract has
# an overloaded deallocateAll that abigen rejects, and the solver only reads limitOf) — like Multicall3.
Expand All @@ -54,16 +54,19 @@ CORE_MIRROR_ABIS := ThreeFAdapter LiquidLaneAdapter IAdapterFactory IVaultV2 IER
BINDINGS_V2 := ThreeFAdapter:3f/adapter IRequest:3f/request \
IVaultController:3f/vaultcontroller IWhitelist:3f/whitelist \
LiquidLaneAdapter:liquidlane/adapter Executor:rfq/executor Reactor:rfq/reactor \
LiquidLaneLifiExecutor:lifi/executor \
ILifiInputSettler:lifi/inputsettler \
IAdapterFactory:adapterfactory UniversalDelegator:delegator IVaultV2:vaultv2 IERC4626:erc4626 \
SymbioticOevSolver:oev/callback RedStoneExecutor:oev/executor Morpho:oev/morpho \
AdaptiveCurveIrm:oev/irm MorphoOracle:oev/oracle \
AggregatorV3:oev/aggregator \
AggregatorV3:chainlink/aggregator \
ERC20:erc20 Multicall3:multicall3
# The OEV contracts (Morpho + its AdaptiveCurve IRM + market oracle, RedStone
# Executor, SymbioticOevSolver) plus a minimal ERC20 (decimals() only) aren't in our Foundry build, so their
# ABIs are hand-vendored under api/abi/ (not in ABIS/CORE_MIRROR_ABIS/refresh-abi). RedStoneExecutor avoids
# the rfq Executor name clash; solver ERC-20 reads (asset/balanceOf) reuse erc4626, the generic
# chain.Decimals reader uses erc20.
# Executor, SymbioticOevSolver), the LI.FI input settler ABI, plus a minimal ERC20
# (decimals() only) aren't in our default Foundry build, so their ABIs are hand-vendored under
# api/abi/ (not in ABIS/CORE_MIRROR_ABIS/refresh-abi). RedStoneExecutor avoids the rfq Executor
# name clash; solver ERC-20 reads (asset/balanceOf) reuse erc4626, the generic chain.Decimals reader
# uses erc20.
# Multicall3 is v2 like everything else — api/abi/Multicall3.json is hand-vendored (not a Foundry contract),
# so it's in BINDINGS_V2 but not ABIS. The chain.Multicall transport packs/unpacks aggregate3 and does its
# own eth_call.
Expand Down Expand Up @@ -132,7 +135,7 @@ refresh-morpho-graphql-schema: ## Re-pull the live Morpho GraphQL schema SDL (MO

.PHONY: bindings
bindings: ## Generate Go bindings from vendored ABIs (grouped per integration; package = leaf dir)
@for pair in $(BINDINGS_V2); do \
@set -e; for pair in $(BINDINGS_V2); do \
c="$${pair%%:*}"; rel="$${pair##*:}"; pkg="$${rel##*/}"; \
abi="api/abi/$$c.json"; \
if [[ ! -f "$$abi" ]]; then echo "missing $$abi (run make refresh-abi)"; exit 1; fi; \
Expand All @@ -146,10 +149,7 @@ bindings: ## Generate Go bindings from vendored ABIs (grouped per integration; p
# backend's OpenAPI 3.1 spec; we use it for the 3F (3.0) and LI.FI order-server specs too for one toolchain.
# $(OPENAPI_GENERATOR_VERSION) is the floor — 5.4.0/7.0.1 fail on the 3.1 spec. The generated package is
# stdlib-only (no go.mod change); the recipes strip the generator's non-package cruft, keeping just the Go
# client. $(4) is optional extra generator flags — used only by the LI.FI recipe to pass
# --skip-validate-spec (its spec is labelled OpenAPI 3.0.0 but uses 3.1 JSON-Schema constructs — prefixItems /
# propertyNames — and has dangling oneOf $refs; the generator handles them fine but its strict validator
# rejects them). 3f/rfq keep validation on.
# client. $(4) is available for source-specific generator flags; current specs generate with validation on.
define gen_openapi_client
GO_POST_PROCESS_FILE='gofmt -w' OPENAPI_GENERATOR_VERSION=$(OPENAPI_GENERATOR_VERSION) bash ./hack/openapi-generator-cli.sh \
generate --enable-post-process-file $(4) -i ./$(1) -g go -o ./$(2) --package-name $(3)
Expand All @@ -169,18 +169,7 @@ refresh-rfq-client: ## Generate the RFQ backend client (openapi-generator, Go) f
.PHONY: refresh-lifi-client
refresh-lifi-client: ## Generate the LI.FI order-server client (openapi-generator, Go) from the vendored spec
@rm -f api/lifiorder/*.go
@# The raw vendored spec has two upstream defects that make the generated Go uncompilable (dangling
@# oneOf $refs in QuoteDto.order; multi-tag operations that duplicate request structs). We keep the
@# vendored file raw (contract of record) and generate from a normalized temp copy produced by
@# hack/lifi-openapi-normalize.py (see that script for the exact, documented fixes). Inlined rather than
@# using gen_openapi_client so the normalization + temp-file plumbing lives in one shell block;
@# --skip-validate-spec is still needed (the spec is labelled 3.0.0 but uses 3.1 JSON-Schema constructs).
tmp="$$(mktemp -p . --suffix=.lifi-normalized.json)"; \
trap 'rm -f "$$tmp"' EXIT; \
python3 hack/lifi-openapi-normalize.py < openapi/lifi-order.openapi.json > "$$tmp"; \
GO_POST_PROCESS_FILE='gofmt -w' OPENAPI_GENERATOR_VERSION=$(OPENAPI_GENERATOR_VERSION) bash ./hack/openapi-generator-cli.sh \
generate --enable-post-process-file --skip-validate-spec -i "$$tmp" -g go -o ./api/lifiorder --package-name lifiorder
cd api/lifiorder && rm -rf go.mod go.sum .gitignore .openapi-generator-ignore .travis.yml git_push.sh README.md api docs test .openapi-generator
$(call gen_openapi_client,openapi/lifi-order.openapi.json,api/lifiorder,lifiorder)

.PHONY: refresh-morpho-graphql-client
refresh-morpho-graphql-client: ## Generate the Morpho GraphQL client (genqlient) from the vendored schema + operations
Expand Down Expand Up @@ -216,6 +205,10 @@ test: ## Run tests with race detector + coverage (hermetic only; fork/live suite
test-oev-live: ## OEV live checks — Morpho API discovery plus optional Sepolia fork payload dump
go test -tags live -run TestLive -v ./internal/solvers/redstoneoev/...

.PHONY: test-txmanager-anvil
test-txmanager-anvil: ## Exercise replacement/cancellation against an Anvil mempool with automine disabled
go test -race -tags integration -run TestAnvilTxManagerPendingLifecycle -v ./internal/txmanager

.PHONY: format
format: ## Run golangci-lint with autofix
golangci-lint run --fix
Expand Down
60 changes: 56 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +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 sender shared across solvers.
Multicall3 reads, a pluggable signer, and a nonce-serialized transaction broadcaster with independent
receipt waits, shared across solvers.
- **`api/`** — committed codegen: contract `bindings/` (abigen) and protocol API clients, each
refreshable from upstream.

Expand All @@ -39,8 +40,9 @@ and validated by its own solver. Adding a solver touches **no** framework code
| `3f-bridge-facilitator` | 3F (Grunt) bridge-loan auctions | [plan](docs/3F-PLAN.md) | [yaml](config/3f.example.yaml) |
| `rfq-filler` | Symbiotic RFQ quoting + order filling | [plan](docs/RFQ-PLAN.md) | [yaml](config/rfq.example.yaml) |
| `redstone-oev` | RedStone OEV liquidations | [plan](docs/OEV-PLAN.md) | [yaml](config/redstone-oev.example.yaml) |
| `lifi-samechain` | LI.FI same-chain intents over LiquidLane | [plan](docs/LIFI-PLAN.md) | [yaml](config/lifi.example.yaml) |

The `3f-bridge-facilitator`, `rfq-filler`, and `redstone-oev` solvers expose a pluggable
All solvers expose a pluggable
**strategy** — the built-in `default` or an external `webhook` you run; see
[Strategies](#strategies).

Expand Down Expand Up @@ -97,18 +99,66 @@ and roadmap:
[`docs/OEV-PLAN.md`](docs/OEV-PLAN.md) · example
[`config/redstone-oev.example.yaml`](config/redstone-oev.example.yaml).

### LI.FI Same-Chain Intents — `lifi-samechain`

A same-chain LI.FI Intents solver for LiquidLane-backed RWA → underlying routes. It publishes gas-aware
standing quotes from current adapter liquidity and receives matched, already-opened escrow orders over the
LI.FI WebSocket feed. Before each fill it rechecks the canonical order status, adapter state, gas cost, and
strategy decision, then atomically claims 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.

The executor contract is the registered LI.FI solver account. It is registered once through EIP-1271 using
a caller signature bound to the executor's EIP-712 domain, appears as `exclusiveFor` in quotes, and calls the
settler's direct finalise path. The framework signer is an authorized executor caller and transaction sender;
fills do not carry a per-order `AllowOpen` signature.
The owner manages callers, while ERC-1271 validates domain-separated registration signatures against the
current callers.

Our deployment convention is one LI.FI API key per registered executor contract. LI.FI can register
multiple accounts under one key, but this deployment deliberately does not share a key across executors.
All processes using one executor therefore share its API key and LI.FI reputation; active/active operation
also requires external order coordination. The API key, executor owner key, and caller transaction key are
distinct credentials.

Only on-chain escrow orders are supported; gasless Compact, Permit2/3009, Dutch auctions, and future-order
scheduling are out of scope. Dutch (`0x01`) and exclusive Dutch (`0xe1`) orders are ignored at WebSocket
admission and logged as unsupported. `solverMode: external` serves direct filler-authorized adapters.
`solverMode: internal` also enables signed private discounts through the shared backend. `tokensToQuote` uses the same `all`,
`permissioned`, and `permissionless` scopes as RFQ; permissioned inputs must execute through one physical
route. The order-server REST/WS endpoints are explicit required config, and each Chainlink gas feed has
its own required max age. The default strategy evaluates quote ranges as exact input across every allocation
transition; `rangeCount` sets the target number of ranges across available capacity. See the
plan for settlement, pricing, concurrency, and onboarding details:
[`docs/LIFI-PLAN.md`](docs/LIFI-PLAN.md) · example
[`config/lifi.example.yaml`](config/lifi.example.yaml).

The opened-order settler must report `governanceFee() == 0`. The solver checks this at startup and again for
every admitted order. Startup fails closed; at runtime an unreadable or non-zero fee skips the order with an
error log before planning or submission.

The implementation is ready for the opened-order path. The next live E2E requires deploying the current
executor build, registering it with LI.FI, and granting it filler authorization on the target adapter.

### Strategies

The solvers split protocol plumbing (reads, signing, submission — fixed) from the
**decision** — how to size, price, and select — which is a pluggable *strategy*, chosen in config:

- **`default`** — the built-in in-process strategy for that solver.
- **`webhook`** — delegates each decision to an **external HTTP service you run**: the solver sends it
the raw facts as JSON and executes the plan it returns, so your service owns the logic.
the raw facts as JSON and executes the validated plan it returns, so your service owns the logic.
LI.FI also rejects returned fills that exceed current capacity or do not cover the order plus gas.
It uses `POST /decide-quotes` and `POST /decide-fill` under the configured webhook URL.

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 before allowing later queued nonces
to proceed. The required `maxFeeGwei` is the absolute ceiling; normal sends reserve one fee bump
inside that ceiling so cancellation still has headroom.

## Requirements

- Go (toolchain version pinned in [`go.mod`](./go.mod); auto-fetched by recent Go releases).
Expand All @@ -122,6 +172,7 @@ This is the seam for customizing a solver without forking. Contract and trust mo
make build # build ./bin/vault-solver
./bin/vault-solver version
make test # go test -race -cover ./...
make test-txmanager-anvil # real pending replacement/cancellation against local Anvil
make lint # golangci-lint
./bin/vault-solver run --config config/3f.example.yaml
```
Expand All @@ -141,7 +192,8 @@ implementation and hands the opaque `solver.config` block to that solver to type
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.
The `chain` block takes a primary `rpcUrl` plus optional `rpcFallbackUrls` — HTTP(S) endpoints tried
in order when the primary is unavailable. **Never commit a real key or live config** — keys are
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.

## Code generation
Expand Down
Loading