diff --git a/.github/chart/hoodi.yaml b/.github/chart/hoodi.yaml index b2551cc8..d177b41e 100644 --- a/.github/chart/hoodi.yaml +++ b/.github/chart/hoodi.yaml @@ -27,6 +27,9 @@ configMap: solvers: - name: rfq-filler config: + strategy: + name: default + config: {} backendUrl: http://rfq-backend:42072 backendSharedSecretEnv: BACKEND_SHARED_SECRET # env var NAME (secret never in config) listenAddr: ":42073" # quote HTTP server (poll-only; no /notify) diff --git a/.github/chart/mainnet.yaml b/.github/chart/mainnet.yaml index e69de29b..2538d029 100644 --- a/.github/chart/mainnet.yaml +++ b/.github/chart/mainnet.yaml @@ -0,0 +1,55 @@ +configMap: + data: + config.yaml: | + # vault-solver — RFQ filler, Ethereum mainnet (chainId 1) profile. + # + # Addresses are the mainnet deployment of the RFQ contracts. Provide secrets via env: + # SOLVER_PRIVATE_KEY — the caller EOA (must hold CALLER_ROLE on the Executor) + # BACKEND_SHARED_SECRET — shared secret authenticating the backend peer on /quote + # ${VAR} fields are expanded from the environment at load time. + + chain: + rpcUrl: ${ETH_RPC_URL} # primary READ RPC; expanded from env; do not commit a real URL + # writeRpcUrl carries ONLY transaction broadcasts (eth_sendRawTransaction); nonce, gas, + # receipts and every other read stay on rpcUrl. Point it at a private/MEV-protected relay + # so fills submit privately. Optional — omit to broadcast through rpcUrl. + writeRpcUrl: ${WRITE_RPC_URL} # e.g. https://rpc.mevblocker.io/fullprivacy + chainId: 1 + + signer: + keyEnv: SOLVER_PRIVATE_KEY # the CALLER_ROLE EOA that submits Executor.fill (P2) + + txManager: + confirmations: 2 + + observability: + addr: ":9090" # /metrics, /healthz, /readyz (separate from the quote server below) + # Optional: set env SENTRY_DSN (and SENTRY_ENVIRONMENT) to tee Error+ logs to Sentry. + + solvers: + - name: rfq-filler + config: + strategy: + name: default + config: {} + backendUrl: http://rfq-backend:42072 + backendSharedSecretEnv: BACKEND_SHARED_SECRET # env var NAME (secret never in config) + listenAddr: ":42073" # quote HTTP server (poll-only; no /notify) + # Mainnet RFQ deployment: + executor: ${EXECUTOR} # per-instance: deploy.yml sets EXECUTOR (non-secret env, not vault) + reactor: "0x5eB54c47837cC84249F697e3CD8C5D88bCc35dac" # used at execution time + pollIntervalMs: 166 # backend order poll cadence + orderLimit: 20 # max open orders fetched per poll + # internal — public discounts + every advertised adapter; `adapters` optional. (external + # would REQUIRE a concrete adapter and error on empty; we don't have the mainnet instance + # yet, so stay internal to quote permissionlessly via the discounts API.) + solverMode: internal + # Token routing: quote scope ("all"|"permissioned"|"permissionless") is set per-instance + # via env (deploy.yml). mGLOBAL is not yet launched on mainnet (tabled), so there is no + # local permissioned set to evaluate against. + tokensToQuote: ${TOKENS_TO_QUOTE} + permissionedTokens: [] + # LiquidLane adapters (optional in internal mode = extra recovery inventory). Left empty + # until the concrete mainnet mF-ONE LiquidLane adapter INSTANCE is known — a deployed + # instance of factory 0x3b5Bb07d7af98EBdD5A715fa09C5c10aF1749460, NOT the factory itself. + adapters: [] diff --git a/.github/chart/sepolia.yaml b/.github/chart/sepolia.yaml index ead7ecd3..2d946f66 100644 --- a/.github/chart/sepolia.yaml +++ b/.github/chart/sepolia.yaml @@ -25,6 +25,9 @@ configMap: solvers: - name: rfq-filler config: + strategy: + name: default + config: {} backendUrl: http://rfq-backend:42072 backendSharedSecretEnv: BACKEND_SHARED_SECRET # env var NAME (secret never in config) listenAddr: ":42073" # quote HTTP server (poll-only; no /notify) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb68c7da..35ea3fd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # pin@v6.4.0 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # pin@v6.5.0 with: go-version-file: go.mod cache: true @@ -36,12 +36,12 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # pin@v6.4.0 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # pin@v6.5.0 with: go-version-file: go.mod cache: true - name: golangci-lint - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # pin@v9.2.1 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # pin@v9.3.0 with: version: v2.11.4 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8f5daca7..f878c212 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -28,7 +28,7 @@ jobs: -f .github/chart/${{ inputs.network }}.yaml -f .github/chart/vault-solver/${{ inputs.namespace }}.yaml --set deployment.containers.vault-solver.image.tag=${{ github.sha }}@${{ inputs.digest }} - --set deployment.containers.vault-solver.env.EXECUTOR.value=${{ inputs.network == 'hoodi' && '0x5Ef14C81eBf7941523f8195F2E55b2A22700C181' || '0xc12714738A4772Bf966b2e999037D39315589263' }} + --set deployment.containers.vault-solver.env.EXECUTOR.value=${{ inputs.network == 'hoodi' && '0x5Ef14C81eBf7941523f8195F2E55b2A22700C181' || inputs.network == 'mainnet' && '0xe60E84218BB81539cc599A1E213d6F67058C69Cf' || '0xc12714738A4772Bf966b2e999037D39315589263' }} --set deployment.containers.vault-solver.env.TOKENS_TO_QUOTE.value=permissionless --set deployment.containers.vault-solver.env.RFQ_ADAPTER.value='${{ inputs.network == 'hoodi' && '"0x788Ab0D58bC7E5537109064F95875013c886ACC8"\, "0x9292Ad3e9C3747cFA31885657B5A458002205281"' || '"0x8F38656B85fb440018c109A5118aFfDfD923721c"\, "0xe4eb1E756F2d78F77B9ebE304515fA61B2451A33"' }}' --set "podAnnotations.vault\.security\.banzaicloud\.io/vault-role"="${{ inputs.namespace }}-vault-solver" @@ -36,6 +36,10 @@ jobs: vault-solver: name: Deploy ${{ matrix.name }} + # The first/second/third solvers are the dedicated mGLOBAL fillers. mGLOBAL is + # not launched on mainnet yet (tabled), so skip them on mainnet — only the + # permissionless mF-ONE daemon deploys there. + if: ${{ inputs.network != 'mainnet' }} uses: symbioticfi/github-workflows-common/.github/workflows/deploy.yml@main secrets: inherit strategy: diff --git a/.github/workflows/deploy_prod.yml b/.github/workflows/deploy_prod.yml index 3b23953d..497c76db 100644 --- a/.github/workflows/deploy_prod.yml +++ b/.github/workflows/deploy_prod.yml @@ -34,14 +34,14 @@ jobs: namespace: '${{ matrix.network }}-symbiotic-fi' digest: ${{ needs.docker-build.outputs.digest }} - # deploy-prod: - # name: Deploy symbiotic-fi - # needs: - # - docker-build - # - deploy-public-stage - # uses: ./.github/workflows/deploy.yml - # secrets: inherit - # with: - # network: mainnet - # namespace: 'symbiotic-fi' - # digest: ${{ needs.docker-build.outputs.digest }} + deploy-prod: + name: Deploy symbiotic-fi + needs: + - docker-build + - deploy-public-stage + uses: ./.github/workflows/deploy.yml + secrets: inherit + with: + network: mainnet + namespace: 'symbiotic-fi' + digest: ${{ needs.docker-build.outputs.digest }} diff --git a/.github/workflows/deploy_stage.yml b/.github/workflows/deploy_stage.yml index 19a598a7..1a0c6683 100644 --- a/.github/workflows/deploy_stage.yml +++ b/.github/workflows/deploy_stage.yml @@ -25,6 +25,7 @@ jobs: fail-fast: false matrix: network: + - mainnet - hoodi - sepolia uses: ./.github/workflows/deploy.yml diff --git a/.gitignore b/.gitignore index 33ebec96..901b28fd 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ dev-account.local.txt .vscode/ *.swp .DS_Store + +# Local debug tooling (not part of the solver) +/cmd/3f-debug/ diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 74e04e1f..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,11 +0,0 @@ -# AGENTS.md - -The engineering guidelines and project conventions for this repository live in **[CLAUDE.md](./CLAUDE.md)**. - -Any agent or contributor working in this repo must read and follow `CLAUDE.md` before making changes. -It covers the project's purpose, the modular framework/integration boundary (3F today; RFQ, Redstone, -and others next), config-file-driven configuration, modern Go 1.26 style, the required -test/lint/format gate, and secure-coding rules. - -In short: keep integrations modular and self-contained, drive everything from the config file, write -unit tests for new logic, and ensure `make format && make test && make lint` is green before finishing. diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 7c6867f2..80bb0559 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # CLAUDE.md — working agreement for this repo This file is the source of truth for how code is written here. Read it before making changes. -It applies to AI agents and humans alike. `AGENTS.md` points here. +It applies to AI agents and humans alike. `AGENTS.md` is a symlink to this file. ## Purpose @@ -27,6 +27,13 @@ Two layers, and code lives in exactly one: (today `bridgefacilitator/`). All protocol-specific logic, types, ABIs usage, pricing, and config live here. +**Shared protocol code** used by ≥2 solvers lives in its own shared package or generated binding — e.g. +Morpho's math in `internal/morpho/`, generated Morpho GraphQL bindings in `api/morphographql`, or neutral +contract bindings like `api/bindings/liquidlane/adapter` / `api/bindings/erc4626` (shared by redstone-oev + +rfq). Hand-written domain adapters stay inside the solver that owns the workflow unless a second solver +actually reuses them. Neutral, protocol-agnostic helpers (config parsing, etc.) live in their own small +helper package — `internal/parse`. + To add a new integration (e.g. `rfq`): 1. Create `internal/solvers/rfq/` implementing `solver.Solver` (`Name()`, `Run(ctx)`), with a `Factory(raw yaml.Node, deps solver.Deps) (Solver, error)`. @@ -104,7 +111,7 @@ bot's view of an external surface honest (it comes from the source of truth, not that silently drifts), keeps the build hermetic (generated code is committed, so a clean checkout builds with no network/toolchain surprises), and turns an upstream change into a reviewable diff. -Two instances of the same pattern — **vendor → generate → commit, regenerated only via `make`:** +Three instances of the same pattern — **vendor → generate → commit, regenerated only via `make`:** - **Contract bindings (ABI → abigen).** Vendor the ABI JSON under `api/abi/` (from a `forge build` out-dir; `make refresh-abi` extracts `.abi` from the build artifacts of `ABIS`/`CORE_MIRROR_ABIS`), @@ -115,8 +122,9 @@ Two instances of the same pattern — **vendor → generate → commit, regenera renamed method or changed signature in a refreshed ABI breaks the build at the call site instead of panicking at runtime. **Never reintroduce stringly-typed `abi.Pack("method", …)`/`abi.Unpack` in solver code** — use the generated `Pack`/`Unpack` (or `TryPack` for the error-returning variant). - Multicall3 stays on v1 (`BINDINGS_V1`): it's the transport (`chain.Multicall` binds its `Aggregate3` - caller), where v2's pure helpers buy nothing. An ABI that can't be sourced from a build (e.g. + Multicall3 is v2 like every other binding: `chain.Multicall` builds its sub-calls with the generated + `PackAggregate3`/`UnpackAggregate3` pure helpers and does its own `eth_call`. An ABI that can't be + sourced from a build (e.g. Multicall3, or a minimal hand-pruned `UniversalDelegator` whose full ABI has an abigen-hostile overload) is hand-vendored into `api/abi/` with a comment saying why — still generated from, never hand-bound. @@ -127,8 +135,14 @@ Two instances of the same pattern — **vendor → generate → commit, regenera (e.g. 7.12.0 for an OpenAPI 3.1 spec with numeric `exclusiveMinimum` / `type:[…,null]` unions, which `oapi-codegen`/kin-openapi and `ogen` reject). The recipe strips the generator's non-package cruft (its `go.mod`/docs/test/etc.), keeping only the Go client so it joins the main module. - -Rules for both: the vendored artifact (ABI/spec) is the **contract of record** — when upstream changes, +- **GraphQL clients (schema SDL + operations → genqlient).** Vendor the upstream schema SDL under + `api/graphql//` (`make refresh-morpho-graphql-schema` pulls Morpho's live schema), keep named + operation documents under `operations/`, then `make refresh-morpho-graphql-client` runs pinned + `genqlient` into `api//` and emits `operations.json` for review/safelisting. The generated package + is the shared binding; hand-written adapters that parse generated response types into domain types live in + the owning integration until reuse proves they belong elsewhere. + +Rules for every generated surface: the vendored artifact (ABI/spec/schema) is the **contract of record** — when upstream changes, re-vendor + regenerate in the same change rather than patching generated Go. The integration code wraps the generated client/binding behind a thin adapter so generated types (nullable pointers, response wrappers) stay contained at the boundary and don't leak into solver logic. Reach for this pattern @@ -155,19 +169,48 @@ Write defensively; this bot holds a signing key and moves funds. - Prefer the standard library and already-vendored deps; adding a dependency is a deliberate decision (supply-chain surface). Run `make tidy` and keep `go.sum` honest. -## Keep the plan in sync — required +## Commits — semantic titles + +Commit titles follow [Conventional Commits](https://www.conventionalcommits.org): +`type(scope): summary`. + +- **type** — one of `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `build`, `ci`, `perf`. +- **scope** — the area touched, lowercase: the solver or subsystem (`rfq`, `3f`, `oev`, + `strategy`, `config`, `deps`, `bindings`, …). Optional but preferred. +- **summary** — imperative mood, lowercase, no trailing period + (`feat(rfq): route quotes through pluggable strategies`, `fix(3f): bound 3F API calls with an HTTP timeout`). +- Breaking changes: append `!` after the scope (`refactor(rfq)!: …`) and explain the break in the body. +- Keep the title under ~72 chars; put detail, rationale, and any plan-sync note in the body. + +## Keep the docs in sync — required -The per-solver plans under `docs/` (e.g. `docs/3F-PLAN.md`, `docs/RFQ-PLAN.md`) are the source of truth -for the high-level architecture, design decisions, and the live TODO list. They are not write-once docs. +Two audiences, two docs, kept current **in the same change** as the code: + +**Plans** (`docs/*-PLAN.md`, plus the cross-cutting `docs/strategy-plan.md`) are the source of truth +for **internal architecture, design decisions, and the live TODO list** — write for a future +maintainer. - **Whenever you change the high-level architecture or a design decision** — a new layer or boundary, a changed data flow, a new/removed integration, an interface or external-contract change, a deliberate deviation from an upstream reference — **update the relevant plan in the same change.** - **Whenever the TODO work changes** — an item is started, finished, dropped, or added — **update the TODO list (§10 of the relevant solver plan)** so it always reflects reality. -- A code change that alters architecture/design but leaves the plan stale is **incomplete**. If a - change is purely local (a bug fix, a refactor with no design impact), no plan update is needed — - use judgement, but err toward recording anything a future reader would be surprised to discover. +- A code change that alters architecture/design but leaves a plan stale is **incomplete**. + +**README** (`README.md`) is the **external, user-facing** entry point — write for an operator or +integrator running the bot, not a maintainer of it. Keep internal design out of it; keep runtime and +integration surface in it. + +- **Whenever you change something a user observes or configures** — a new or renamed CLI flag or + subcommand, a config knob or its default, a new/removed solver or a change to what a solver does, a + new strategy or integration surface, quickstart/build/run steps, or requirements — **update the + README in the same change.** +- A user-facing change (flag, config field, solver capability) that lands without a README update is + **incomplete**. + +If a change is purely internal (a bug fix or refactor with no design impact and nothing a user +observes), neither doc needs an update — use judgement, but err toward recording anything a future +reader or operator would be surprised to discover. ## Quick reference @@ -175,5 +218,6 @@ for the high-level architecture, design decisions, and the live TODO list. They - Add an integration: new `internal/solvers//` + `solver.Register` in `init()` + bindings under `api/bindings//` + a `solvers[]` entry. No framework changes. - Config is king: if it varies by deployment, it belongs in the YAML, not in code. -- Keep the plan current: architecture/design or TODO changes must update `docs/*-PLAN.md` - in the same change. +- Keep the docs current in the same change: architecture/design or TODO changes update `docs/*-PLAN.md`; + user-facing changes (CLI flags, config knobs, solver/strategy capabilities) update `README.md`. +- Commit titles are Conventional Commits: `type(scope): imperative summary` (e.g. `feat(rfq): …`). diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..c70653dc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Symbiotic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile index ff12c898..4bf04d36 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,9 @@ SHELL := bash # Pinned codegen tool versions. ABIGEN_VERSION ?= v1.16.1 GOLANGCI_LINT_VERSION ?= v2.11.4 +GENQLIENT_VERSION ?= v0.8.1 +GQLFETCH_VERSION ?= v0.7.0 +GENQLIENT_X_TOOLS_VERSION ?= v0.38.0 # Java openapi-generator (downloaded on demand by hack/openapi-generator-cli.sh). 7.12.0 is the floor: # it ingests OpenAPI 3.1 (the RFQ backend spec); 5.4.0/7.0.1 fail on it. OPENAPI_GENERATOR_VERSION ?= 7.12.0 @@ -25,32 +28,41 @@ OPENAPI_URL ?= https://bf.dev.gcp.3f.xyz/docs/openapi.json # NOTE: the temp railway deployment is currently behind the repo (pre adapter/protocolSignature # rename); point this at a backend running current code, or regenerate in-repo (see docs/RFQ-PLAN.md). RFQ_OPENAPI_URL ?= https://backend-production-a0ca.up.railway.app/api/v1/openapi.json +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 (LiquidLane adapter, universal delegator, vault/ERC4626 interfaces) come from the -# core-mirror build, since nothing in rfq/src imports them so they aren't in rfq/out. -ABIS := BridgeFacilitatorAdapter IRequest IVaultController IWhitelist Executor Reactor -CORE_MIRROR_ABIS := LiquidLaneAdapter IVaultV2 IERC4626 +# CORE_MIRROR_ABIS (the 3F ThreeFAdapter, LiquidLane adapter, universal delegator, vault/ERC4626 +# interfaces) come from the core-mirror build, since they aren't in rfq/out. +ABIS := IRequest IVaultController IWhitelist Executor Reactor +CORE_MIRROR_ABIS := ThreeFAdapter LiquidLaneAdapter 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. # Contract:relpath mapping for Go bindings. Each contract gets its own package (the leaf dir) so # shared ABI structs (e.g. the `Offer` tuple in both the adapter and IRequest) don't collide. -# Adapter-specific bindings are grouped per integration (3f/, and later rfq/, oev/); shared -# infra (vaultv2, multicall3) stays top-level so every integration reuses it. -# Leaf-contract bindings use abigen --v2, which emits typed, backend-free PackXxx/UnpackXxx helpers. -# The on-chain read paths build their Multicall3 sub-calls and decode the return blobs through those -# helpers (see the chainreaders), so an ABI change that renames a method or alters a signature breaks -# the build at the call site instead of panicking at runtime — no stringly-typed abi.Pack("method"). -BINDINGS_V2 := BridgeFacilitatorAdapter:3f/adapter IRequest:3f/request \ +# Integration-specific bindings are grouped per integration (3f/, rfq/, oev/); contracts SHARED by more +# than one integration get a neutral group (e.g. the LiquidLane adapter under liquidlane/, used by both +# rfq and redstone-oev) so no integration owns another's surface; shared infra (vaultv2, multicall3) +# stays top-level. +# +# BINDINGS_V2 uses abigen --v2 (typed PackXxx/UnpackXxx/UnpackXxxEvent), so an ABI change breaks the build +# at the call site, not at runtime. +BINDINGS_V2 := ThreeFAdapter:3f/adapter IRequest:3f/request \ IVaultController:3f/vaultcontroller IWhitelist:3f/whitelist \ - LiquidLaneAdapter:rfq/adapter Executor:rfq/executor Reactor:rfq/reactor \ - UniversalDelegator:delegator IVaultV2:vaultv2 IERC4626:erc4626 -# Note: api/abi/Multicall3.json is hand-vendored (not a Foundry contract), so Multicall3 is in -# BINDINGS_V1 but not ABIS. It stays on the v1 generator: it's the transport (chain.Multicall binds -# its Aggregate3 caller), where v2's pure pack/unpack helpers buy nothing. aggregate3 is marked `view` -# there so abigen binds it as a Caller. -BINDINGS_V1 := Multicall3:multicall3 + LiquidLaneAdapter:liquidlane/adapter Executor:rfq/executor Reactor:rfq/reactor \ + UniversalDelegator:delegator IVaultV2:vaultv2 IERC4626:erc4626 \ + SymbioticOevSolver:oev/callback RedStoneExecutor:oev/executor Morpho:oev/morpho \ + AdaptiveCurveIrm:oev/irm MorphoOracle:oev/oracle \ + AggregatorV3:oev/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. +# 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. BIN := bin/vault-solver PKG := github.com/symbioticfi/vault-solver @@ -71,6 +83,7 @@ tools: ## Install pinned codegen + lint tools go install github.com/ethereum/go-ethereum/cmd/abigen@$(ABIGEN_VERSION) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) @echo "OpenAPI clients use the Java openapi-generator via hack/openapi-generator-cli.sh (needs a JRE; jar auto-downloaded)." + @echo "Morpho GraphQL uses gqlfetch + genqlient through go run in the make targets." .PHONY: refresh-abi refresh-abi: ## Re-vendor ABIs from the rfq + core-mirror Foundry builds (FORGE_OUT=..., CORE_MIRROR_OUT=...) @@ -100,6 +113,13 @@ refresh-rfq-openapi: ## Re-pull the RFQ backend OpenAPI spec (RFQ_OPENAPI_URL=.. curl -fsSL "$(RFQ_OPENAPI_URL)" | jq . > openapi/rfq-backend.openapi.json @echo "vendored openapi/rfq-backend.openapi.json (verify field names — see docs/RFQ-PLAN.md)" +.PHONY: refresh-morpho-graphql-schema +refresh-morpho-graphql-schema: ## Re-pull the live Morpho GraphQL schema SDL (MORPHO_GRAPHQL_URL=...) + @mkdir -p api/graphql/morpho + go run github.com/suessflorian/gqlfetch/gqlfetch@$(GQLFETCH_VERSION) \ + -endpoint "$(MORPHO_GRAPHQL_URL)" > api/graphql/morpho/schema.graphql + @echo "vendored api/graphql/morpho/schema.graphql" + .PHONY: bindings bindings: ## Generate Go bindings from vendored ABIs (grouped per integration; package = leaf dir) @for pair in $(BINDINGS_V2); do \ @@ -110,14 +130,6 @@ bindings: ## Generate Go bindings from vendored ABIs (grouped per integration; p abigen --v2 --abi "$$abi" --pkg "$$pkg" --type "$$c" --out "api/bindings/$$rel/$$c.go"; \ echo "generated api/bindings/$$rel/$$c.go (v2)"; \ done - @for pair in $(BINDINGS_V1); 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; \ - mkdir -p "api/bindings/$$rel"; \ - abigen --abi "$$abi" --pkg "$$pkg" --type "$$c" --out "api/bindings/$$rel/$$c.go"; \ - echo "generated api/bindings/$$rel/$$c.go (v1)"; \ - done # Both OpenAPI clients are generated with the Java openapi-generator (via hack/openapi-generator-cli.sh, # which downloads the pinned jar on demand — needs a JRE). It is the only generator that ingests the RFQ @@ -140,11 +152,25 @@ refresh-rfq-client: ## Generate the RFQ backend client (openapi-generator, Go) f @rm -f api/rfqbackend/*.go $(call gen_openapi_client,openapi/rfq-backend.openapi.json,api/rfqbackend,rfqbackend) +.PHONY: refresh-morpho-graphql-client +refresh-morpho-graphql-client: ## Generate the Morpho GraphQL client (genqlient) from the vendored schema + operations + @mkdir -p api/morphographql + @tmp="$$(mktemp -d)"; \ + trap 'rm -rf "$$tmp"' EXIT; \ + cd "$$tmp"; \ + go mod init genqlient-runner >/dev/null 2>&1; \ + go get github.com/Khan/genqlient@$(GENQLIENT_VERSION) golang.org/x/tools@$(GENQLIENT_X_TOOLS_VERSION) >/dev/null 2>&1; \ + go run github.com/Khan/genqlient "$(CURDIR)/api/graphql/morpho/genqlient.yaml" + @gofmt -w api/morphographql/generated.go + .PHONY: openapi-client openapi-client: refresh-3f-client refresh-rfq-client ## Generate both OpenAPI clients +.PHONY: graphql-client +graphql-client: refresh-morpho-graphql-client ## Generate GraphQL clients + .PHONY: generate -generate: bindings openapi-client ## Regenerate all committed codegen +generate: bindings openapi-client graphql-client ## Regenerate all committed codegen .PHONY: build build: ## Build the binary @@ -152,13 +178,26 @@ build: ## Build the binary go build -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/vault-solver .PHONY: test -test: ## Run tests with race detector + coverage +test: ## Run tests with race detector + coverage (hermetic only; fork/live suites are tag-gated out) go test -race -cover ./... +# Local-only OEV integration suite — build-tagged, skipped by the default `test` + CI. +.PHONY: test-oev-live +test-oev-live: ## OEV live checks — Morpho API borrower + token-pair market discovery + go test -tags live -run TestLive -v ./internal/solvers/redstoneoev/ + +.PHONY: test-oev-refuel +test-oev-refuel: ## OEV gas-refuel orchestration on an anvil Sepolia fork (needs ETH_RPC_URL_SEPOLIA + OEV_SIGNER_PRIVATE_KEY + sibling rfq-integration) + ./scripts/oev/oev-fork-refuel.sh + .PHONY: format -format: ## Run golangci-lint +format: ## Run golangci-lint with autofix golangci-lint run --fix +.PHONY: lint +lint: ## Run golangci-lint (no autofix; must report 0 issues) + golangci-lint run + .PHONY: tidy tidy: ## Tidy and verify go.mod / go.sum go mod tidy diff --git a/README.md b/README.md index 5f4fbeb4..c30ecf22 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,98 @@ -# vault-solver +# Vault Solver A Go service that monitors a configured selection of [Symbiotic](https://symbiotic.fi) vaults and -runs a pluggable **solver** strategy against them. A solver is a self-contained integration with an -external protocol that sources, prices, or routes liquidity on top of a Symbiotic vault adapter; the -bot handles discovery, pricing/signing, on-chain reads, reconciliation, and settlement for it. +runs a pluggable **solver** against them. A solver is a self-contained integration with an external +protocol that sources, prices, or routes liquidity on top of a Symbiotic vault adapter; the bot +handles discovery, pricing/signing, on-chain reads, reconciliation, and settlement for it. The framework is **solver-agnostic**: each integration lives in its own package, registers itself, -and is selected by config — adding one never touches the generic engine. The available solvers are -described under [Solvers](#solvers) below. +and is selected by config — adding one never touches the generic engine. The available integrations +are listed under [Solvers](#solvers). > **Status:** early build. Engineering guidelines: [`CLAUDE.md`](./CLAUDE.md). Per-solver scope, -> architecture, and roadmap live under [`docs/`](docs). On-chain adapters live in the sibling `rfq` -> repo and are consumed here only via generated ABI bindings. +> architecture, and roadmap live under [`docs/`](docs). ## Architecture at a glance - **`cmd/vault-solver`** — process bootstrap: flags, logging, signal-driven shutdown. - **`internal/solver`** — generic `Solver` interface, registry, and engine. -- **`internal/solvers//`** — one self-contained package per integration - (`bridgefacilitator/`, `rfq/`); all protocol-specific logic lives here. +- **`internal/solvers//`** — 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. -- **`api/`** — committed codegen: contract `bindings/` (abigen, grouped per integration) and protocol - API clients (e.g. the 3F `threef/` client via the Java openapi-generator), each refreshable from upstream. +- **`api/`** — committed codegen: contract `bindings/` (abigen) and protocol API clients, each + refreshable from upstream. -State is intentionally minimal: open positions, redemption readiness, and liquidity are read from -on-chain views and the relevant protocol API on each tick — no database. See -[`docs/3F-PLAN.md`](docs/3F-PLAN.md) §3. +State is intentionally minimal — positions, liquidity, and readiness are read from on-chain views and +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 — that shared sender is exactly why -running them together is safe. Each entry's `config` block is typed and validated by its own solver -(two-stage decode). Adding a solver touches **no** framework code — see the recipe in +`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 [`CLAUDE.md`](./CLAUDE.md). -| `solver.name` | Integration | Status | Docs | +| `solver.name` | Integration | Docs | Example config | |---|---|---|---| -| `3f-bridge-facilitator` | 3F (Grunt) bridge-loan auctions | Live on Sepolia dev | [`docs/3F-PLAN.md`](docs/3F-PLAN.md) | -| `rfq-filler` | RFQ quoting + order filling | Implemented (quote · fill · discounts) | [`docs/RFQ-PLAN.md`](docs/RFQ-PLAN.md) | -| _(planned)_ `redstone-oev` | Redstone / OEV | Planned | — | +| `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) | + +The `3f-bridge-facilitator` and `rfq-filler` solvers expose a pluggable **strategy** — the built-in +`default` or an external `webhook` you run; see [Strategies](#strategies). (`redstone-oev` has a single +built-in decision path and no `strategy` config.) ### 3F Bridge Facilitator — `3f-bridge-facilitator` -Acts as a Bridge Facilitator in 3F's bridge-loan auctions, on top of a Symbiotic -`BridgeFacilitatorAdapter`: - -- **Discover** open auctions via the 3F API (matched to a target vault by deposit asset == collateral). -- **Price & size** an offer at the auction's `maxRate`, capped by fundable vault liquidity and curator - exposure (per-request / total-sleeve / max-concurrent). -- **Sign & submit** the offer (EIP-712), with the adapter as the on-chain maker (verified via EIP-1271 - against an owner-set offer-signer key). -- **Fund** a won loan just-in-time inside the adapter's consume callback (self-allocation from vault - liquidity), then **redeem** repaid loans permissionlessly — realizing principal + yield back to the - vault. - -Onboarding generates a 3F facilitator API key (EIP-712) and registers the adapter as the facilitator -offer-address. Because 3F allows exactly **one offer-address per facilitator**, this solver serves a -**single `vault` + `adapter` pair**. The on-chain `BridgeFacilitatorAdapter` lives in the sibling -`rfq` repo, consumed via `api/bindings/3f/`. Config block: `apiBaseUrl`, `apiKeyEnv`, `minReturnBps`, -`vault`, `adapter`, `exposure`, `intervals` — see -[`config/config.example.yaml`](config/config.example.yaml). Design, decisions, and the live TODO -list: [`docs/3F-PLAN.md`](docs/3F-PLAN.md). +Acts as a Bridge Facilitator in **[3F (Grunt)](https://3f.xyz)**'s bridge-loan auctions, on top of one +or more Symbiotic `BridgeFacilitatorAdapter`s. 3F auctions the right to front a bridge loan; this solver bids on behalf +of its adapters, funds the loans it wins just-in-time, and permissionlessly redeems repaid loans back +to the vault with yield. + +It holds no API key: each adapter is registered with 3F by its vault creator, who sets this solver's +signer as the adapter's EIP-1271 signer, so offers are authorized by signature alone. Design, config, +and roadmap: [`docs/3F-PLAN.md`](docs/3F-PLAN.md) · example +[`config/3f.example.yaml`](config/3f.example.yaml). ### RFQ Filler — `rfq-filler` -An externally-owned solver/executor for Symbiotic RFQ on top of per-vault `LiquidLaneAdapter`s. It is -both a request/response **quote server** and an order-filling **poller**: - -- **Quote** — serves `POST /quote` (gated by an `x-rfq-shared-secret` header from the backend peer): - it prices the requested swap directly off the adapter's on-chain `getAmountOut` (the oracle rate, - quoted as-is), selects the best adapter legs across the inventory in the request, - persists the chosen strategy by `quoteId`, and returns an `amountOut` (or `204` when it cannot - quote — wrong chain, no in-scope adapter, no matching asset, or no viable strategy). In the default - `external` `solverMode`, quoting and filling are scoped to the configured `adapters` — **at least one - is required** (an external solver has no discounts fallback). `internal` mode accepts every advertised - adapter and uses public discounts; its `adapters` are optional extra inventory. The - HTTP surface is **code-first OpenAPI 3.1**: request validation and the spec served at - `/openapi.json` + `/docs` are generated from the same typed structs; `/health` is public. -- **Fill** — polls `GET /orders?filler=&orderStatus=open` every `pollIntervalMs`, then - drives each awarded order through `queued → submitting → submitted → {filled | expired | failed}`, - building `Executor.fill(Order, protocolSig, Swap[], DiscountSwapInput[], bytes)` and submitting it - through the shared, nonce-serialized `txmanager` (an on-chain revert marks the order failed). - Terminal status is reconciled back from the backend. Order discovery is **poll-only**. -- **Strategy recovery** — when the quote-time strategy isn't cached (e.g. after a restart), it rebuilds - one from current on-chain state across the configured `vaults`, restricted to those the executor is - authorized to fill through (adapter `marketMaker`, adapter `owner`, or delegated `isFiller`), - plus — in `internal` mode only — any currently-offered backend discounts. -- **Leg types** — **direct** legs (the public adapter rate) and **discount** legs (a signature-gated - private rate resolved fresh from the backend's `/discounts` flow at fill time). - -Reads are Multicall3-batched (a warm quote is a single `getAmountOut` multicall; `tokenIn` decimals -are cached). State is in-memory only — strategies, orders, attempts — and TTL-swept so it stays -bounded over long runs. The caller EOA must hold `CALLER_ROLE` on the `Executor`. The on-chain -`Executor` and `Reactor` live in the sibling `rfq` repo and the `LiquidLaneAdapter` in its -`core-mirror` submodule, all consumed via `api/bindings/rfq/`; the backend contract is pinned by a -vendored OpenAPI spec (`openapi/rfq-backend.openapi.json`). Config block: `backendUrl`, -`backendSharedSecretEnv`, `listenAddr`, `executor`, `reactor`, `pollIntervalMs`, `orderLimit`, -`solverMode`, `adapters` — see -[`config/rfq.hoodi.example.yaml`](config/rfq.hoodi.example.yaml). -Design, decisions, and the live TODO list: [`docs/RFQ-PLAN.md`](docs/RFQ-PLAN.md). +An externally-owned solver/executor for **[Symbiotic RFQ](https://symbiotic.fi)**, on top of per-vault +`LiquidLaneAdapter`s. It runs a `POST /quote` server that prices swaps for the RFQ backend and a poller +that fills the orders it is awarded, settling on-chain through the adapter. + +It runs either in `external` mode (the open-source filler; quoting and filling scoped to the operator's +own adapters) or `internal` mode (Symbiotic-internal; adds the private discounts flow). The caller EOA +must be an authorized caller of the RFQ `Executor` (its `setCallers` allowlist, granted by the owner). +Design, config, and roadmap: +[`docs/RFQ-PLAN.md`](docs/RFQ-PLAN.md) · example +[`config/rfq.example.yaml`](config/rfq.example.yaml). + +### RedStone OEV — `redstone-oev` + +An off-chain bidder for **[RedStone Atom OEV](https://docs.redstone.finance/docs/oev)** auctions. When a +price update makes a **[Morpho Blue](https://morpho.org)** position liquidatable, RedStone runs a +sub-second WebSocket auction for the right to be the liquidator; this solver bids, and on winning, its +signed payload is bundled atomically with the price update and the liquidation. + +On settlement it liquidates the position and exits the seized collateral through a single Symbiotic +`LiquidLaneAdapter`, realizing the spread and paying its bid. It signs and bids but never submits the +settlement transaction — RedStone's auctioneer does. Design, config, and roadmap: +[`docs/OEV-PLAN.md`](docs/OEV-PLAN.md) · example +[`config/redstone-oev.example.yaml`](config/redstone-oev.example.yaml). + +### Strategies + +The 3F and RFQ solvers split on-chain 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; used when the `strategy` block is omitted. +- **`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. + +This is the seam for customizing a solver without forking. Contract and trust model: +[`docs/strategy-plan.md`](docs/strategy-plan.md). ## Requirements @@ -117,7 +108,7 @@ make build # build ./bin/vault-solver ./bin/vault-solver version make test # go test -race -cover ./... make lint # golangci-lint -./bin/vault-solver run --config config/3f.sepolia.example.yaml +./bin/vault-solver run --config config/3f.example.yaml ``` The CLI is built with [Cobra](https://github.com/spf13/cobra); run `vault-solver --help` for the @@ -125,17 +116,18 @@ command list (`run`, `version`). Debug logging is off by default; enable it with `observability.debug: true` in config or the `--debug` flag (the flag wins): ```bash -./bin/vault-solver run --config config/3f.sepolia.example.yaml --debug +./bin/vault-solver run --config config/3f.example.yaml --debug ``` ## Configuration 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. A documented -example lives at `config/config.example.yaml` (per-instance vault selection, exposure caps, -intervals). 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 supplied via env/file behind the `Signer` interface; `*.local.*` and `.env` are gitignored. +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. +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 +supplied via env/file behind the `Signer` interface; `*.local.*` and `.env` are gitignored. ## Code generation @@ -152,5 +144,5 @@ make generate # regenerate bindings + API client Engineering conventions — the modular framework/integration boundary, config-driven configuration, modern Go 1.26 style, the required test/lint/format gate, and secure-coding rules — are in -[`CLAUDE.md`](./CLAUDE.md) ([`AGENTS.md`](./AGENTS.md) points there). Every change must keep -`make format && make test && make lint` green and unit-test new logic. \ No newline at end of file +[`CLAUDE.md`](./CLAUDE.md) (`AGENTS.md` is a symlink to it). Every change must keep +`make format && make test && make lint` green and unit-test new logic. diff --git a/api/abi/AdaptiveCurveIrm.json b/api/abi/AdaptiveCurveIrm.json new file mode 100644 index 00000000..59a840f3 --- /dev/null +++ b/api/abi/AdaptiveCurveIrm.json @@ -0,0 +1,70 @@ +[ + { + "inputs": [ + { + "name": "marketParams", + "type": "tuple", + "components": [ + { + "name": "loanToken", + "type": "address" + }, + { + "name": "collateralToken", + "type": "address" + }, + { + "name": "oracle", + "type": "address" + }, + { + "name": "irm", + "type": "address" + }, + { + "name": "lltv", + "type": "uint256" + } + ] + }, + { + "name": "market", + "type": "tuple", + "components": [ + { + "name": "totalSupplyAssets", + "type": "uint128" + }, + { + "name": "totalSupplyShares", + "type": "uint128" + }, + { + "name": "totalBorrowAssets", + "type": "uint128" + }, + { + "name": "totalBorrowShares", + "type": "uint128" + }, + { + "name": "lastUpdate", + "type": "uint128" + }, + { + "name": "fee", + "type": "uint128" + } + ] + } + ], + "name": "borrowRateView", + "outputs": [ + { + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/api/abi/AggregatorV3.json b/api/abi/AggregatorV3.json new file mode 100644 index 00000000..1176072a --- /dev/null +++ b/api/abi/AggregatorV3.json @@ -0,0 +1,41 @@ +[ + { + "inputs": [], + "name": "latestRoundData", + "outputs": [ + { + "name": "roundId", + "type": "uint80" + }, + { + "name": "answer", + "type": "int256" + }, + { + "name": "startedAt", + "type": "uint256" + }, + { + "name": "updatedAt", + "type": "uint256" + }, + { + "name": "answeredInRound", + "type": "uint80" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/api/abi/ERC20.json b/api/abi/ERC20.json new file mode 100644 index 00000000..ba7d0f58 --- /dev/null +++ b/api/abi/ERC20.json @@ -0,0 +1,9 @@ +[ + { + "inputs": [], + "name": "decimals", + "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], + "stateMutability": "view", + "type": "function" + } +] diff --git a/api/abi/Morpho.json b/api/abi/Morpho.json new file mode 100644 index 00000000..77ed04d5 --- /dev/null +++ b/api/abi/Morpho.json @@ -0,0 +1,97 @@ +[ + { + "inputs": [ + { + "type": "bytes32" + } + ], + "name": "market", + "outputs": [ + { + "name": "totalSupplyAssets", + "type": "uint128" + }, + { + "name": "totalSupplyShares", + "type": "uint128" + }, + { + "name": "totalBorrowAssets", + "type": "uint128" + }, + { + "name": "totalBorrowShares", + "type": "uint128" + }, + { + "name": "lastUpdate", + "type": "uint128" + }, + { + "name": "fee", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "type": "bytes32" + }, + { + "type": "address" + } + ], + "name": "position", + "outputs": [ + { + "name": "supplyShares", + "type": "uint256" + }, + { + "name": "borrowShares", + "type": "uint128" + }, + { + "name": "collateral", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "type": "bytes32" + } + ], + "name": "idToMarketParams", + "outputs": [ + { + "name": "loanToken", + "type": "address" + }, + { + "name": "collateralToken", + "type": "address" + }, + { + "name": "oracle", + "type": "address" + }, + { + "name": "irm", + "type": "address" + }, + { + "name": "lltv", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/api/abi/MorphoOracle.json b/api/abi/MorphoOracle.json new file mode 100644 index 00000000..59d476d8 --- /dev/null +++ b/api/abi/MorphoOracle.json @@ -0,0 +1,13 @@ +[ + { + "inputs": [], + "name": "price", + "outputs": [ + { + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/api/abi/RedStoneExecutor.json b/api/abi/RedStoneExecutor.json new file mode 100644 index 00000000..e0cc2376 --- /dev/null +++ b/api/abi/RedStoneExecutor.json @@ -0,0 +1,71 @@ +[ + { + "inputs": [ + { + "type": "address" + } + ], + "name": "nonces", + "outputs": [ + { + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "type": "address" + } + ], + "name": "deposits", + "outputs": [ + { + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "type": "address" + } + ], + "name": "locked", + "outputs": [ + { + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "deposit", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "solver", + "type": "address" + }, + { + "indexed": false, + "name": "nonce", + "type": "uint256" + } + ], + "name": "LiquidationFailed", + "type": "event" + } +] diff --git a/api/abi/SymbioticOevSolver.json b/api/abi/SymbioticOevSolver.json new file mode 100644 index 00000000..40f63927 --- /dev/null +++ b/api/abi/SymbioticOevSolver.json @@ -0,0 +1,453 @@ +[ + { + "type": "constructor", + "inputs": [ + { + "name": "executor", + "type": "address", + "internalType": "address" + }, + { + "name": "morpho", + "type": "address", + "internalType": "address" + }, + { + "name": "liquidLaneAdapter", + "type": "address", + "internalType": "address" + }, + { + "name": "authSigner", + "type": "address", + "internalType": "address" + }, + { + "name": "initialOwner", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "receive", + "stateMutability": "payable" + }, + { + "type": "function", + "name": "AUTH_SIGNER", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "EXECUTOR", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "LIQUID_LANE_ADAPTER", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "MORPHO", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "liquidate", + "inputs": [ + { + "name": "bidAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "operationData", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "onMorphoLiquidate", + "inputs": [ + { + "name": "repaidAssets", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "payBid", + "inputs": [ + { + "name": "bidAmount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "usedAuctionKey", + "inputs": [ + { + "name": "auctionKey", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "used", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "withdrawERC20", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "withdrawNative", + "inputs": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "BundleResult", + "inputs": [ + { + "name": "auctionKey", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "totalProfitLoan", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "minProfitLoan", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "gasUsed", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "bidAuthorized", + "type": "bool", + "indexed": false, + "internalType": "bool" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "LegResult", + "inputs": [ + { + "name": "auctionKey", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "marketId", + "type": "bytes32", + "indexed": true, + "internalType": "Id" + }, + { + "name": "borrower", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "code", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "seizedAssets", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "repaidAssets", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "profitLoan", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "gasUsed", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnerUpdated", + "inputs": [ + { + "name": "previous", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "next", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PayBidResult", + "inputs": [ + { + "name": "auctionKey", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "bidAmount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "paid", + "type": "bool", + "indexed": false, + "internalType": "bool" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "ECDSAInvalidSignature", + "inputs": [] + }, + { + "type": "error", + "name": "ECDSAInvalidSignatureLength", + "inputs": [ + { + "name": "length", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "ECDSAInvalidSignatureS", + "inputs": [ + { + "name": "s", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InsufficientLoanProceeds", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidAuth", + "inputs": [] + }, + { + "type": "error", + "name": "NotExecutor", + "inputs": [] + }, + { + "type": "error", + "name": "NotMorpho", + "inputs": [] + }, + { + "type": "error", + "name": "NotOwner", + "inputs": [] + }, + { + "type": "error", + "name": "ProfitBelowMin", + "inputs": [] + }, + { + "type": "error", + "name": "ReentrancyGuardReentrantCall", + "inputs": [] + }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "SwapOutputBelowMin", + "inputs": [] + }, + { + "type": "error", + "name": "TransferFailed", + "inputs": [] + }, + { + "type": "error", + "name": "ZeroAddress", + "inputs": [] + } +] diff --git a/api/abi/BridgeFacilitatorAdapter.json b/api/abi/ThreeFAdapter.json similarity index 83% rename from api/abi/BridgeFacilitatorAdapter.json rename to api/abi/ThreeFAdapter.json index 2b914aaa..23a4668e 100644 --- a/api/abi/BridgeFacilitatorAdapter.json +++ b/api/abi/ThreeFAdapter.json @@ -3,17 +3,17 @@ "type": "constructor", "inputs": [ { - "name": "requestWhitelist", + "name": "vaultFactory", "type": "address", "internalType": "address" }, { - "name": "vaultFactory", + "name": "adapterFactory", "type": "address", "internalType": "address" }, { - "name": "adapterFactory", + "name": "requestWhitelist", "type": "address", "internalType": "address" } @@ -46,19 +46,6 @@ ], "stateMutability": "view" }, - { - "type": "function", - "name": "activeRequests", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "address[]", - "internalType": "address[]" - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "allocatable", @@ -103,13 +90,26 @@ ], "outputs": [ { - "name": "deallocated", + "name": "", "type": "uint256", "internalType": "uint256" } ], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "finalizeRequest", + "inputs": [ + { + "name": "request", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "freeAssets", @@ -123,6 +123,19 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getMaxAssets", + "inputs": [], + "outputs": [ + { + "name": "assets", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "initialize", @@ -172,7 +185,7 @@ }, { "type": "function", - "name": "maxConcurrentLoans", + "name": "maxAssetsPerRequest", "inputs": [], "outputs": [ { @@ -203,7 +216,20 @@ }, { "type": "function", - "name": "minRequestYieldBps", + "name": "minAssetsPerRequest", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "minYieldPerRequest", "inputs": [], "outputs": [ { @@ -245,7 +271,7 @@ "name": "onRequestConsumed", "inputs": [ { - "name": "", + "name": "offer", "type": "tuple", "internalType": "struct Offer", "components": [ @@ -287,12 +313,12 @@ "internalType": "bytes" }, { - "name": "principal", + "name": "principalAssets", "type": "uint256", "internalType": "uint256" }, { - "name": "yield", + "name": "yieldAssets", "type": "uint256", "internalType": "uint256" } @@ -300,19 +326,6 @@ "outputs": [], "stateMutability": "nonpayable" }, - { - "type": "function", - "name": "outstandingPrincipal", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "owner", @@ -328,20 +341,27 @@ }, { "type": "function", - "name": "perRequestMaxCollateral", + "name": "renounceOwnership", "inputs": [], - "outputs": [ + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "requestDeallocate", + "inputs": [ { - "name": "", + "name": "amount", "type": "uint256", "internalType": "uint256" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", - "name": "positions", + "name": "requestIndex", "inputs": [ { "name": "request", @@ -351,95 +371,61 @@ ], "outputs": [ { - "name": "principal", - "type": "uint128", - "internalType": "uint128" - }, - { - "name": "ytExpected", - "type": "uint128", - "internalType": "uint128" - }, - { - "name": "openedAt", - "type": "uint48", - "internalType": "uint48" - }, - { - "name": "redeemed", - "type": "bool", - "internalType": "bool" + "name": "index", + "type": "uint256", + "internalType": "uint256" } ], "stateMutability": "view" }, { "type": "function", - "name": "realizedPrincipal", - "inputs": [], - "outputs": [ + "name": "requests", + "inputs": [ { "name": "", "type": "uint256", "internalType": "uint256" } ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "redeem", - "inputs": [ + "outputs": [ { - "name": "requests", - "type": "address[]", - "internalType": "address[]" + "name": "", + "type": "address", + "internalType": "address" } ], - "outputs": [], - "stateMutability": "nonpayable" + "stateMutability": "view" }, { "type": "function", - "name": "renounceOwnership", + "name": "requestsLength", "inputs": [], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "requestDeallocate", - "inputs": [ + "outputs": [ { - "name": "amount", + "name": "", "type": "uint256", "internalType": "uint256" } ], - "outputs": [], - "stateMutability": "nonpayable" + "stateMutability": "view" }, { "type": "function", - "name": "setExposureLimits", + "name": "setLimitsPerRequest", "inputs": [ { - "name": "perRequestMaxCollateral_", + "name": "newMinYieldPerRequest", "type": "uint256", "internalType": "uint256" }, { - "name": "totalMaxCollateral_", + "name": "newMinAssetsPerRequest", "type": "uint256", "internalType": "uint256" }, { - "name": "minRequestYieldBps_", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "maxConcurrentLoans_", + "name": "newMaxAssetsPerRequest", "type": "uint256", "internalType": "uint256" } @@ -452,7 +438,7 @@ "name": "setOfferSigner", "inputs": [ { - "name": "signer", + "name": "newOfferSigner", "type": "address", "internalType": "address" } @@ -462,24 +448,29 @@ }, { "type": "function", - "name": "totalAssets", - "inputs": [], - "outputs": [ + "name": "staticDelegateCall", + "inputs": [ { - "name": "", - "type": "uint256", - "internalType": "uint256" + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" } ], - "stateMutability": "view" + "outputs": [], + "stateMutability": "nonpayable" }, { "type": "function", - "name": "totalMaxCollateral", + "name": "totalAssets", "inputs": [], "outputs": [ { - "name": "", + "name": "assets", "type": "uint256", "internalType": "uint256" } @@ -527,39 +518,33 @@ }, { "type": "event", - "name": "Initialized", + "name": "FinalizeRequest", "inputs": [ { - "name": "version", - "type": "uint64", - "indexed": false, - "internalType": "uint64" + "name": "request", + "type": "address", + "indexed": true, + "internalType": "address" } ], "anonymous": false }, { "type": "event", - "name": "OwnershipTransferred", + "name": "Initialized", "inputs": [ { - "name": "previousOwner", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "newOwner", - "type": "address", - "indexed": true, - "internalType": "address" + "name": "version", + "type": "uint64", + "indexed": false, + "internalType": "uint64" } ], "anonymous": false }, { "type": "event", - "name": "PositionOpened", + "name": "OnRequestConsumed", "inputs": [ { "name": "request", @@ -568,13 +553,51 @@ "internalType": "address" }, { - "name": "principal", + "name": "offer", + "type": "tuple", + "indexed": false, + "internalType": "struct Offer", + "components": [ + { + "name": "maker", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expectedReturn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expiration", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "useCallback", + "type": "bool", + "internalType": "bool" + } + ] + }, + { + "name": "principalAssets", "type": "uint256", "indexed": false, "internalType": "uint256" }, { - "name": "ytExpected", + "name": "yieldAssets", "type": "uint256", "indexed": false, "internalType": "uint256" @@ -584,53 +607,41 @@ }, { "type": "event", - "name": "PositionRedeemed", + "name": "OwnershipTransferred", "inputs": [ { - "name": "request", + "name": "previousOwner", "type": "address", "indexed": true, "internalType": "address" }, { - "name": "principal", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - }, - { - "name": "yield", - "type": "uint256", - "indexed": false, - "internalType": "uint256" + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" } ], "anonymous": false }, { "type": "event", - "name": "SetExposureLimits", + "name": "SetLimitsPerRequest", "inputs": [ { - "name": "perRequestMaxCollateral", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - }, - { - "name": "totalMaxCollateral", + "name": "minYieldPerRequest", "type": "uint256", "indexed": false, "internalType": "uint256" }, { - "name": "minRequestYieldBps", + "name": "minAssetsPerRequest", "type": "uint256", "indexed": false, "internalType": "uint256" }, { - "name": "maxConcurrentLoans", + "name": "maxAssetsPerRequest", "type": "uint256", "indexed": false, "internalType": "uint256" @@ -643,7 +654,7 @@ "name": "SetOfferSigner", "inputs": [ { - "name": "signer", + "name": "offerSigner", "type": "address", "indexed": true, "internalType": "address" @@ -671,12 +682,12 @@ }, { "type": "error", - "name": "AssetMismatch", + "name": "AlreadyRequest", "inputs": [] }, { "type": "error", - "name": "InsufficientLiquidity", + "name": "InsufficientAllocate", "inputs": [] }, { @@ -689,11 +700,6 @@ "name": "InvalidVault", "inputs": [] }, - { - "type": "error", - "name": "NotAttested", - "inputs": [] - }, { "type": "error", "name": "NotFactory", @@ -701,12 +707,12 @@ }, { "type": "error", - "name": "NotInitialized", + "name": "NotInitializing", "inputs": [] }, { "type": "error", - "name": "NotInitializing", + "name": "NotRequest", "inputs": [] }, { @@ -736,32 +742,11 @@ } ] }, - { - "type": "error", - "name": "PerRequestCapExceeded", - "inputs": [] - }, { "type": "error", "name": "ReentrancyGuardReentrantCall", "inputs": [] }, - { - "type": "error", - "name": "SafeCastOverflowedUintDowncast", - "inputs": [ - { - "name": "bits", - "type": "uint8", - "internalType": "uint8" - }, - { - "name": "value", - "type": "uint256", - "internalType": "uint256" - } - ] - }, { "type": "error", "name": "SafeERC20FailedOperation", @@ -775,17 +760,27 @@ }, { "type": "error", - "name": "SleeveCapExceeded", + "name": "TooLargeRequest", + "inputs": [] + }, + { + "type": "error", + "name": "TooLowYield", + "inputs": [] + }, + { + "type": "error", + "name": "TooManyRequests", "inputs": [] }, { "type": "error", - "name": "TooManyConcurrentLoans", + "name": "TooSmallRequest", "inputs": [] }, { "type": "error", - "name": "YieldTooLow", + "name": "WrongAsset", "inputs": [] } ] diff --git a/api/bindings/3f/adapter/BridgeFacilitatorAdapter.go b/api/bindings/3f/adapter/BridgeFacilitatorAdapter.go deleted file mode 100644 index 3434f0df..00000000 --- a/api/bindings/3f/adapter/BridgeFacilitatorAdapter.go +++ /dev/null @@ -1,1806 +0,0 @@ -// Code generated via abigen V2 - DO NOT EDIT. -// This file is a generated binding and any manual changes will be lost. - -package adapter - -import ( - "bytes" - "errors" - "math/big" - - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" -) - -// Reference imports to suppress errors if they are not otherwise used. -var ( - _ = bytes.Equal - _ = errors.New - _ = big.NewInt - _ = common.Big1 - _ = types.BloomLookup - _ = abi.ConvertType -) - -// Offer is an auto generated low-level Go binding around an user-defined struct. -type Offer struct { - Maker common.Address - Amount *big.Int - ExpectedReturn *big.Int - Nonce *big.Int - Expiration *big.Int - UseCallback bool -} - -// BridgeFacilitatorAdapterMetaData contains all meta data concerning the BridgeFacilitatorAdapter contract. -var BridgeFacilitatorAdapterMetaData = bind.MetaData{ - ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"requestWhitelist\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"vaultFactory\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"adapterFactory\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"FACTORY\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"REQUEST_WHITELIST\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"activeRequests\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address[]\",\"internalType\":\"address[]\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"allocatable\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"allocate\",\"inputs\":[{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"deallocate\",\"inputs\":[{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"deallocated\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"freeAssets\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"initialize\",\"inputs\":[{\"name\":\"initialVersion\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"owner_\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"isValidSignature\",\"inputs\":[{\"name\":\"hash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"signature\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"maxConcurrentLoans\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"migrate\",\"inputs\":[{\"name\":\"newVersion\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"minRequestYieldBps\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"multicall\",\"inputs\":[{\"name\":\"data\",\"type\":\"bytes[]\",\"internalType\":\"bytes[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"offerSigner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"onRequestConsumed\",\"inputs\":[{\"name\":\"\",\"type\":\"tuple\",\"internalType\":\"structOffer\",\"components\":[{\"name\":\"maker\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"expectedReturn\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"expiration\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"useCallback\",\"type\":\"bool\",\"internalType\":\"bool\"}]},{\"name\":\"\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"principal\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"yield\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"outstandingPrincipal\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"perRequestMaxCollateral\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"positions\",\"inputs\":[{\"name\":\"request\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"principal\",\"type\":\"uint128\",\"internalType\":\"uint128\"},{\"name\":\"ytExpected\",\"type\":\"uint128\",\"internalType\":\"uint128\"},{\"name\":\"openedAt\",\"type\":\"uint48\",\"internalType\":\"uint48\"},{\"name\":\"redeemed\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"realizedPrincipal\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"redeem\",\"inputs\":[{\"name\":\"requests\",\"type\":\"address[]\",\"internalType\":\"address[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"renounceOwnership\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"requestDeallocate\",\"inputs\":[{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setExposureLimits\",\"inputs\":[{\"name\":\"perRequestMaxCollateral_\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"totalMaxCollateral_\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"minRequestYieldBps_\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxConcurrentLoans_\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setOfferSigner\",\"inputs\":[{\"name\":\"signer\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"totalAssets\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"totalMaxCollateral\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"vault\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"Initialized\",\"inputs\":[{\"name\":\"version\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OwnershipTransferred\",\"inputs\":[{\"name\":\"previousOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"newOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"PositionOpened\",\"inputs\":[{\"name\":\"request\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"principal\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"ytExpected\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"PositionRedeemed\",\"inputs\":[{\"name\":\"request\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"principal\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"yield\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"SetExposureLimits\",\"inputs\":[{\"name\":\"perRequestMaxCollateral\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"totalMaxCollateral\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"minRequestYieldBps\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"maxConcurrentLoans\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"SetOfferSigner\",\"inputs\":[{\"name\":\"signer\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"SetVault\",\"inputs\":[{\"name\":\"vault\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AlreadyInitialized\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AssetMismatch\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientLiquidity\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidInitialization\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidVault\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotAttested\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotFactory\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotInitialized\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotInitializing\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotVault\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"OwnableInvalidOwner\",\"inputs\":[{\"name\":\"owner\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"OwnableUnauthorizedAccount\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"PerRequestCapExceeded\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ReentrancyGuardReentrantCall\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SafeCastOverflowedUintDowncast\",\"inputs\":[{\"name\":\"bits\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"value\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"SafeERC20FailedOperation\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"SleeveCapExceeded\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TooManyConcurrentLoans\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"YieldTooLow\",\"inputs\":[]}]", - ID: "BridgeFacilitatorAdapter", -} - -// BridgeFacilitatorAdapter is an auto generated Go binding around an Ethereum contract. -type BridgeFacilitatorAdapter struct { - abi abi.ABI -} - -// NewBridgeFacilitatorAdapter creates a new instance of BridgeFacilitatorAdapter. -func NewBridgeFacilitatorAdapter() *BridgeFacilitatorAdapter { - parsed, err := BridgeFacilitatorAdapterMetaData.ParseABI() - if err != nil { - panic(errors.New("invalid ABI: " + err.Error())) - } - return &BridgeFacilitatorAdapter{abi: *parsed} -} - -// Instance creates a wrapper for a deployed contract instance at the given address. -// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. -func (c *BridgeFacilitatorAdapter) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { - return bind.NewBoundContract(addr, c.abi, backend, backend, backend) -} - -// PackConstructor is the Go binding used to pack the parameters required for -// contract deployment. -// -// Solidity: constructor(address requestWhitelist, address vaultFactory, address adapterFactory) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackConstructor(requestWhitelist common.Address, vaultFactory common.Address, adapterFactory common.Address) []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("", requestWhitelist, vaultFactory, adapterFactory) - if err != nil { - panic(err) - } - return enc -} - -// PackFACTORY is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x2dd31000. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function FACTORY() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackFACTORY() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("FACTORY") - if err != nil { - panic(err) - } - return enc -} - -// TryPackFACTORY is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x2dd31000. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function FACTORY() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackFACTORY() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("FACTORY") -} - -// UnpackFACTORY is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x2dd31000. -// -// Solidity: function FACTORY() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackFACTORY(data []byte) (common.Address, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("FACTORY", data) - if err != nil { - return *new(common.Address), err - } - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - return out0, nil -} - -// PackREQUESTWHITELIST is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x894e6d61. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function REQUEST_WHITELIST() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackREQUESTWHITELIST() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("REQUEST_WHITELIST") - if err != nil { - panic(err) - } - return enc -} - -// TryPackREQUESTWHITELIST is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x894e6d61. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function REQUEST_WHITELIST() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackREQUESTWHITELIST() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("REQUEST_WHITELIST") -} - -// UnpackREQUESTWHITELIST is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x894e6d61. -// -// Solidity: function REQUEST_WHITELIST() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackREQUESTWHITELIST(data []byte) (common.Address, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("REQUEST_WHITELIST", data) - if err != nil { - return *new(common.Address), err - } - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - return out0, nil -} - -// PackActiveRequests is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x83cc915c. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function activeRequests() view returns(address[]) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackActiveRequests() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("activeRequests") - if err != nil { - panic(err) - } - return enc -} - -// TryPackActiveRequests is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x83cc915c. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function activeRequests() view returns(address[]) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackActiveRequests() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("activeRequests") -} - -// UnpackActiveRequests is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x83cc915c. -// -// Solidity: function activeRequests() view returns(address[]) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackActiveRequests(data []byte) ([]common.Address, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("activeRequests", data) - if err != nil { - return *new([]common.Address), err - } - out0 := *abi.ConvertType(out[0], new([]common.Address)).(*[]common.Address) - return out0, nil -} - -// PackAllocatable is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x1d3b809a. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function allocatable() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackAllocatable() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("allocatable") - if err != nil { - panic(err) - } - return enc -} - -// TryPackAllocatable is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x1d3b809a. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function allocatable() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackAllocatable() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("allocatable") -} - -// UnpackAllocatable is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x1d3b809a. -// -// Solidity: function allocatable() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackAllocatable(data []byte) (*big.Int, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("allocatable", data) - if err != nil { - return new(big.Int), err - } - out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) - return out0, nil -} - -// PackAllocate is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x90ca796b. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function allocate(uint256 amount) returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackAllocate(amount *big.Int) []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("allocate", amount) - if err != nil { - panic(err) - } - return enc -} - -// TryPackAllocate is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x90ca796b. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function allocate(uint256 amount) returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackAllocate(amount *big.Int) ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("allocate", amount) -} - -// UnpackAllocate is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x90ca796b. -// -// Solidity: function allocate(uint256 amount) returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackAllocate(data []byte) (*big.Int, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("allocate", data) - if err != nil { - return new(big.Int), err - } - out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) - return out0, nil -} - -// PackDeallocate is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x6f6c441f. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function deallocate(uint256 amount) returns(uint256 deallocated) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackDeallocate(amount *big.Int) []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("deallocate", amount) - if err != nil { - panic(err) - } - return enc -} - -// TryPackDeallocate is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x6f6c441f. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function deallocate(uint256 amount) returns(uint256 deallocated) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackDeallocate(amount *big.Int) ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("deallocate", amount) -} - -// UnpackDeallocate is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x6f6c441f. -// -// Solidity: function deallocate(uint256 amount) returns(uint256 deallocated) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackDeallocate(data []byte) (*big.Int, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("deallocate", data) - if err != nil { - return new(big.Int), err - } - out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) - return out0, nil -} - -// PackFreeAssets is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x11f240ac. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function freeAssets() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackFreeAssets() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("freeAssets") - if err != nil { - panic(err) - } - return enc -} - -// TryPackFreeAssets is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x11f240ac. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function freeAssets() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackFreeAssets() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("freeAssets") -} - -// UnpackFreeAssets is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x11f240ac. -// -// Solidity: function freeAssets() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackFreeAssets(data []byte) (*big.Int, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("freeAssets", data) - if err != nil { - return new(big.Int), err - } - out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) - return out0, nil -} - -// PackInitialize is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x57ec83cc. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function initialize(uint64 initialVersion, address owner_, bytes data) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackInitialize(initialVersion uint64, owner common.Address, data []byte) []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("initialize", initialVersion, owner, data) - if err != nil { - panic(err) - } - return enc -} - -// TryPackInitialize is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x57ec83cc. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function initialize(uint64 initialVersion, address owner_, bytes data) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackInitialize(initialVersion uint64, owner common.Address, data []byte) ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("initialize", initialVersion, owner, data) -} - -// PackIsValidSignature is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x1626ba7e. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function isValidSignature(bytes32 hash, bytes signature) view returns(bytes4) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackIsValidSignature(hash [32]byte, signature []byte) []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("isValidSignature", hash, signature) - if err != nil { - panic(err) - } - return enc -} - -// TryPackIsValidSignature is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x1626ba7e. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function isValidSignature(bytes32 hash, bytes signature) view returns(bytes4) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackIsValidSignature(hash [32]byte, signature []byte) ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("isValidSignature", hash, signature) -} - -// UnpackIsValidSignature is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x1626ba7e. -// -// Solidity: function isValidSignature(bytes32 hash, bytes signature) view returns(bytes4) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackIsValidSignature(data []byte) ([4]byte, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("isValidSignature", data) - if err != nil { - return *new([4]byte), err - } - out0 := *abi.ConvertType(out[0], new([4]byte)).(*[4]byte) - return out0, nil -} - -// PackMaxConcurrentLoans is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x0fa715c7. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function maxConcurrentLoans() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackMaxConcurrentLoans() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("maxConcurrentLoans") - if err != nil { - panic(err) - } - return enc -} - -// TryPackMaxConcurrentLoans is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x0fa715c7. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function maxConcurrentLoans() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackMaxConcurrentLoans() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("maxConcurrentLoans") -} - -// UnpackMaxConcurrentLoans is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x0fa715c7. -// -// Solidity: function maxConcurrentLoans() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackMaxConcurrentLoans(data []byte) (*big.Int, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("maxConcurrentLoans", data) - if err != nil { - return new(big.Int), err - } - out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) - return out0, nil -} - -// PackMigrate is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x2abe3048. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function migrate(uint64 newVersion, bytes data) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackMigrate(newVersion uint64, data []byte) []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("migrate", newVersion, data) - if err != nil { - panic(err) - } - return enc -} - -// TryPackMigrate is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x2abe3048. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function migrate(uint64 newVersion, bytes data) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackMigrate(newVersion uint64, data []byte) ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("migrate", newVersion, data) -} - -// PackMinRequestYieldBps is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x6762571b. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function minRequestYieldBps() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackMinRequestYieldBps() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("minRequestYieldBps") - if err != nil { - panic(err) - } - return enc -} - -// TryPackMinRequestYieldBps is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x6762571b. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function minRequestYieldBps() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackMinRequestYieldBps() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("minRequestYieldBps") -} - -// UnpackMinRequestYieldBps is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x6762571b. -// -// Solidity: function minRequestYieldBps() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackMinRequestYieldBps(data []byte) (*big.Int, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("minRequestYieldBps", data) - if err != nil { - return new(big.Int), err - } - out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) - return out0, nil -} - -// PackMulticall is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xac9650d8. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function multicall(bytes[] data) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackMulticall(data [][]byte) []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("multicall", data) - if err != nil { - panic(err) - } - return enc -} - -// TryPackMulticall is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xac9650d8. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function multicall(bytes[] data) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackMulticall(data [][]byte) ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("multicall", data) -} - -// PackOfferSigner is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x566bd6c3. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function offerSigner() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackOfferSigner() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("offerSigner") - if err != nil { - panic(err) - } - return enc -} - -// TryPackOfferSigner is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x566bd6c3. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function offerSigner() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackOfferSigner() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("offerSigner") -} - -// UnpackOfferSigner is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x566bd6c3. -// -// Solidity: function offerSigner() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackOfferSigner(data []byte) (common.Address, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("offerSigner", data) - if err != nil { - return *new(common.Address), err - } - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - return out0, nil -} - -// PackOnRequestConsumed is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xf2fe1357. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function onRequestConsumed((address,uint256,uint256,uint256,uint256,bool) , bytes , uint256 principal, uint256 yield) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackOnRequestConsumed(arg0 Offer, arg1 []byte, principal *big.Int, yield *big.Int) []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("onRequestConsumed", arg0, arg1, principal, yield) - if err != nil { - panic(err) - } - return enc -} - -// TryPackOnRequestConsumed is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xf2fe1357. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function onRequestConsumed((address,uint256,uint256,uint256,uint256,bool) , bytes , uint256 principal, uint256 yield) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackOnRequestConsumed(arg0 Offer, arg1 []byte, principal *big.Int, yield *big.Int) ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("onRequestConsumed", arg0, arg1, principal, yield) -} - -// PackOutstandingPrincipal is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x29b1829e. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function outstandingPrincipal() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackOutstandingPrincipal() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("outstandingPrincipal") - if err != nil { - panic(err) - } - return enc -} - -// TryPackOutstandingPrincipal is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x29b1829e. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function outstandingPrincipal() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackOutstandingPrincipal() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("outstandingPrincipal") -} - -// UnpackOutstandingPrincipal is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x29b1829e. -// -// Solidity: function outstandingPrincipal() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackOutstandingPrincipal(data []byte) (*big.Int, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("outstandingPrincipal", data) - if err != nil { - return new(big.Int), err - } - out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) - return out0, nil -} - -// PackOwner is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x8da5cb5b. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function owner() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackOwner() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("owner") - if err != nil { - panic(err) - } - return enc -} - -// TryPackOwner is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x8da5cb5b. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function owner() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackOwner() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("owner") -} - -// UnpackOwner is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x8da5cb5b. -// -// Solidity: function owner() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackOwner(data []byte) (common.Address, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("owner", data) - if err != nil { - return *new(common.Address), err - } - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - return out0, nil -} - -// PackPerRequestMaxCollateral is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xca1f1576. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function perRequestMaxCollateral() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackPerRequestMaxCollateral() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("perRequestMaxCollateral") - if err != nil { - panic(err) - } - return enc -} - -// TryPackPerRequestMaxCollateral is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xca1f1576. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function perRequestMaxCollateral() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackPerRequestMaxCollateral() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("perRequestMaxCollateral") -} - -// UnpackPerRequestMaxCollateral is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0xca1f1576. -// -// Solidity: function perRequestMaxCollateral() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackPerRequestMaxCollateral(data []byte) (*big.Int, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("perRequestMaxCollateral", data) - if err != nil { - return new(big.Int), err - } - out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) - return out0, nil -} - -// PackPositions is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x55f57510. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function positions(address request) view returns(uint128 principal, uint128 ytExpected, uint48 openedAt, bool redeemed) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackPositions(request common.Address) []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("positions", request) - if err != nil { - panic(err) - } - return enc -} - -// TryPackPositions is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x55f57510. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function positions(address request) view returns(uint128 principal, uint128 ytExpected, uint48 openedAt, bool redeemed) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackPositions(request common.Address) ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("positions", request) -} - -// PositionsOutput serves as a container for the return parameters of contract -// method Positions. -type PositionsOutput struct { - Principal *big.Int - YtExpected *big.Int - OpenedAt *big.Int - Redeemed bool -} - -// UnpackPositions is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x55f57510. -// -// Solidity: function positions(address request) view returns(uint128 principal, uint128 ytExpected, uint48 openedAt, bool redeemed) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackPositions(data []byte) (PositionsOutput, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("positions", data) - outstruct := new(PositionsOutput) - if err != nil { - return *outstruct, err - } - outstruct.Principal = abi.ConvertType(out[0], new(big.Int)).(*big.Int) - outstruct.YtExpected = abi.ConvertType(out[1], new(big.Int)).(*big.Int) - outstruct.OpenedAt = abi.ConvertType(out[2], new(big.Int)).(*big.Int) - outstruct.Redeemed = *abi.ConvertType(out[3], new(bool)).(*bool) - return *outstruct, nil -} - -// PackRealizedPrincipal is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x5b348b1f. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function realizedPrincipal() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackRealizedPrincipal() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("realizedPrincipal") - if err != nil { - panic(err) - } - return enc -} - -// TryPackRealizedPrincipal is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x5b348b1f. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function realizedPrincipal() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackRealizedPrincipal() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("realizedPrincipal") -} - -// UnpackRealizedPrincipal is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x5b348b1f. -// -// Solidity: function realizedPrincipal() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackRealizedPrincipal(data []byte) (*big.Int, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("realizedPrincipal", data) - if err != nil { - return new(big.Int), err - } - out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) - return out0, nil -} - -// PackRedeem is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x8730b205. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function redeem(address[] requests) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackRedeem(requests []common.Address) []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("redeem", requests) - if err != nil { - panic(err) - } - return enc -} - -// TryPackRedeem is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x8730b205. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function redeem(address[] requests) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackRedeem(requests []common.Address) ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("redeem", requests) -} - -// PackRenounceOwnership is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x715018a6. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function renounceOwnership() returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackRenounceOwnership() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("renounceOwnership") - if err != nil { - panic(err) - } - return enc -} - -// TryPackRenounceOwnership is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x715018a6. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function renounceOwnership() returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackRenounceOwnership() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("renounceOwnership") -} - -// PackRequestDeallocate is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xf79f679d. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function requestDeallocate(uint256 amount) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackRequestDeallocate(amount *big.Int) []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("requestDeallocate", amount) - if err != nil { - panic(err) - } - return enc -} - -// TryPackRequestDeallocate is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xf79f679d. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function requestDeallocate(uint256 amount) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackRequestDeallocate(amount *big.Int) ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("requestDeallocate", amount) -} - -// PackSetExposureLimits is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xe05d0a0c. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function setExposureLimits(uint256 perRequestMaxCollateral_, uint256 totalMaxCollateral_, uint256 minRequestYieldBps_, uint256 maxConcurrentLoans_) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackSetExposureLimits(perRequestMaxCollateral *big.Int, totalMaxCollateral *big.Int, minRequestYieldBps *big.Int, maxConcurrentLoans *big.Int) []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("setExposureLimits", perRequestMaxCollateral, totalMaxCollateral, minRequestYieldBps, maxConcurrentLoans) - if err != nil { - panic(err) - } - return enc -} - -// TryPackSetExposureLimits is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xe05d0a0c. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function setExposureLimits(uint256 perRequestMaxCollateral_, uint256 totalMaxCollateral_, uint256 minRequestYieldBps_, uint256 maxConcurrentLoans_) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackSetExposureLimits(perRequestMaxCollateral *big.Int, totalMaxCollateral *big.Int, minRequestYieldBps *big.Int, maxConcurrentLoans *big.Int) ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("setExposureLimits", perRequestMaxCollateral, totalMaxCollateral, minRequestYieldBps, maxConcurrentLoans) -} - -// PackSetOfferSigner is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x868adcae. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function setOfferSigner(address signer) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackSetOfferSigner(signer common.Address) []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("setOfferSigner", signer) - if err != nil { - panic(err) - } - return enc -} - -// TryPackSetOfferSigner is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x868adcae. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function setOfferSigner(address signer) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackSetOfferSigner(signer common.Address) ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("setOfferSigner", signer) -} - -// PackTotalAssets is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x01e1d114. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function totalAssets() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackTotalAssets() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("totalAssets") - if err != nil { - panic(err) - } - return enc -} - -// TryPackTotalAssets is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x01e1d114. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function totalAssets() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackTotalAssets() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("totalAssets") -} - -// UnpackTotalAssets is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x01e1d114. -// -// Solidity: function totalAssets() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackTotalAssets(data []byte) (*big.Int, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("totalAssets", data) - if err != nil { - return new(big.Int), err - } - out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) - return out0, nil -} - -// PackTotalMaxCollateral is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xe5a81bbc. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function totalMaxCollateral() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackTotalMaxCollateral() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("totalMaxCollateral") - if err != nil { - panic(err) - } - return enc -} - -// TryPackTotalMaxCollateral is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xe5a81bbc. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function totalMaxCollateral() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackTotalMaxCollateral() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("totalMaxCollateral") -} - -// UnpackTotalMaxCollateral is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0xe5a81bbc. -// -// Solidity: function totalMaxCollateral() view returns(uint256) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackTotalMaxCollateral(data []byte) (*big.Int, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("totalMaxCollateral", data) - if err != nil { - return new(big.Int), err - } - out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) - return out0, nil -} - -// PackTransferOwnership is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xf2fde38b. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function transferOwnership(address newOwner) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackTransferOwnership(newOwner common.Address) []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("transferOwnership", newOwner) - if err != nil { - panic(err) - } - return enc -} - -// TryPackTransferOwnership is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xf2fde38b. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function transferOwnership(address newOwner) returns() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackTransferOwnership(newOwner common.Address) ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("transferOwnership", newOwner) -} - -// PackVault is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xfbfa77cf. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function vault() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackVault() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("vault") - if err != nil { - panic(err) - } - return enc -} - -// TryPackVault is the Go binding used to pack the parameters required for calling -// the contract method with ID 0xfbfa77cf. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function vault() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackVault() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("vault") -} - -// UnpackVault is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0xfbfa77cf. -// -// Solidity: function vault() view returns(address) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackVault(data []byte) (common.Address, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("vault", data) - if err != nil { - return *new(common.Address), err - } - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - return out0, nil -} - -// PackVersion is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x54fd4d50. This method will panic if any -// invalid/nil inputs are passed. -// -// Solidity: function version() view returns(uint64) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) PackVersion() []byte { - enc, err := bridgeFacilitatorAdapter.abi.Pack("version") - if err != nil { - panic(err) - } - return enc -} - -// TryPackVersion is the Go binding used to pack the parameters required for calling -// the contract method with ID 0x54fd4d50. This method will return an error -// if any inputs are invalid/nil. -// -// Solidity: function version() view returns(uint64) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) TryPackVersion() ([]byte, error) { - return bridgeFacilitatorAdapter.abi.Pack("version") -} - -// UnpackVersion is the Go binding that unpacks the parameters returned -// from invoking the contract method with ID 0x54fd4d50. -// -// Solidity: function version() view returns(uint64) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackVersion(data []byte) (uint64, error) { - out, err := bridgeFacilitatorAdapter.abi.Unpack("version", data) - if err != nil { - return *new(uint64), err - } - out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) - return out0, nil -} - -// BridgeFacilitatorAdapterInitialized represents a Initialized event raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterInitialized struct { - Version uint64 - Raw *types.Log // Blockchain specific contextual infos -} - -const BridgeFacilitatorAdapterInitializedEventName = "Initialized" - -// ContractEventName returns the user-defined event name. -func (BridgeFacilitatorAdapterInitialized) ContractEventName() string { - return BridgeFacilitatorAdapterInitializedEventName -} - -// UnpackInitializedEvent is the Go binding that unpacks the event data emitted -// by contract. -// -// Solidity: event Initialized(uint64 version) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackInitializedEvent(log *types.Log) (*BridgeFacilitatorAdapterInitialized, error) { - event := "Initialized" - if log.Topics[0] != bridgeFacilitatorAdapter.abi.Events[event].ID { - return nil, errors.New("event signature mismatch") - } - out := new(BridgeFacilitatorAdapterInitialized) - if len(log.Data) > 0 { - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, event, log.Data); err != nil { - return nil, err - } - } - var indexed abi.Arguments - for _, arg := range bridgeFacilitatorAdapter.abi.Events[event].Inputs { - if arg.Indexed { - indexed = append(indexed, arg) - } - } - if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { - return nil, err - } - out.Raw = log - return out, nil -} - -// BridgeFacilitatorAdapterOwnershipTransferred represents a OwnershipTransferred event raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterOwnershipTransferred struct { - PreviousOwner common.Address - NewOwner common.Address - Raw *types.Log // Blockchain specific contextual infos -} - -const BridgeFacilitatorAdapterOwnershipTransferredEventName = "OwnershipTransferred" - -// ContractEventName returns the user-defined event name. -func (BridgeFacilitatorAdapterOwnershipTransferred) ContractEventName() string { - return BridgeFacilitatorAdapterOwnershipTransferredEventName -} - -// UnpackOwnershipTransferredEvent is the Go binding that unpacks the event data emitted -// by contract. -// -// Solidity: event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackOwnershipTransferredEvent(log *types.Log) (*BridgeFacilitatorAdapterOwnershipTransferred, error) { - event := "OwnershipTransferred" - if log.Topics[0] != bridgeFacilitatorAdapter.abi.Events[event].ID { - return nil, errors.New("event signature mismatch") - } - out := new(BridgeFacilitatorAdapterOwnershipTransferred) - if len(log.Data) > 0 { - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, event, log.Data); err != nil { - return nil, err - } - } - var indexed abi.Arguments - for _, arg := range bridgeFacilitatorAdapter.abi.Events[event].Inputs { - if arg.Indexed { - indexed = append(indexed, arg) - } - } - if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { - return nil, err - } - out.Raw = log - return out, nil -} - -// BridgeFacilitatorAdapterPositionOpened represents a PositionOpened event raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterPositionOpened struct { - Request common.Address - Principal *big.Int - YtExpected *big.Int - Raw *types.Log // Blockchain specific contextual infos -} - -const BridgeFacilitatorAdapterPositionOpenedEventName = "PositionOpened" - -// ContractEventName returns the user-defined event name. -func (BridgeFacilitatorAdapterPositionOpened) ContractEventName() string { - return BridgeFacilitatorAdapterPositionOpenedEventName -} - -// UnpackPositionOpenedEvent is the Go binding that unpacks the event data emitted -// by contract. -// -// Solidity: event PositionOpened(address indexed request, uint256 principal, uint256 ytExpected) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackPositionOpenedEvent(log *types.Log) (*BridgeFacilitatorAdapterPositionOpened, error) { - event := "PositionOpened" - if log.Topics[0] != bridgeFacilitatorAdapter.abi.Events[event].ID { - return nil, errors.New("event signature mismatch") - } - out := new(BridgeFacilitatorAdapterPositionOpened) - if len(log.Data) > 0 { - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, event, log.Data); err != nil { - return nil, err - } - } - var indexed abi.Arguments - for _, arg := range bridgeFacilitatorAdapter.abi.Events[event].Inputs { - if arg.Indexed { - indexed = append(indexed, arg) - } - } - if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { - return nil, err - } - out.Raw = log - return out, nil -} - -// BridgeFacilitatorAdapterPositionRedeemed represents a PositionRedeemed event raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterPositionRedeemed struct { - Request common.Address - Principal *big.Int - Yield *big.Int - Raw *types.Log // Blockchain specific contextual infos -} - -const BridgeFacilitatorAdapterPositionRedeemedEventName = "PositionRedeemed" - -// ContractEventName returns the user-defined event name. -func (BridgeFacilitatorAdapterPositionRedeemed) ContractEventName() string { - return BridgeFacilitatorAdapterPositionRedeemedEventName -} - -// UnpackPositionRedeemedEvent is the Go binding that unpacks the event data emitted -// by contract. -// -// Solidity: event PositionRedeemed(address indexed request, uint256 principal, uint256 yield) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackPositionRedeemedEvent(log *types.Log) (*BridgeFacilitatorAdapterPositionRedeemed, error) { - event := "PositionRedeemed" - if log.Topics[0] != bridgeFacilitatorAdapter.abi.Events[event].ID { - return nil, errors.New("event signature mismatch") - } - out := new(BridgeFacilitatorAdapterPositionRedeemed) - if len(log.Data) > 0 { - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, event, log.Data); err != nil { - return nil, err - } - } - var indexed abi.Arguments - for _, arg := range bridgeFacilitatorAdapter.abi.Events[event].Inputs { - if arg.Indexed { - indexed = append(indexed, arg) - } - } - if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { - return nil, err - } - out.Raw = log - return out, nil -} - -// BridgeFacilitatorAdapterSetExposureLimits represents a SetExposureLimits event raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterSetExposureLimits struct { - PerRequestMaxCollateral *big.Int - TotalMaxCollateral *big.Int - MinRequestYieldBps *big.Int - MaxConcurrentLoans *big.Int - Raw *types.Log // Blockchain specific contextual infos -} - -const BridgeFacilitatorAdapterSetExposureLimitsEventName = "SetExposureLimits" - -// ContractEventName returns the user-defined event name. -func (BridgeFacilitatorAdapterSetExposureLimits) ContractEventName() string { - return BridgeFacilitatorAdapterSetExposureLimitsEventName -} - -// UnpackSetExposureLimitsEvent is the Go binding that unpacks the event data emitted -// by contract. -// -// Solidity: event SetExposureLimits(uint256 perRequestMaxCollateral, uint256 totalMaxCollateral, uint256 minRequestYieldBps, uint256 maxConcurrentLoans) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackSetExposureLimitsEvent(log *types.Log) (*BridgeFacilitatorAdapterSetExposureLimits, error) { - event := "SetExposureLimits" - if log.Topics[0] != bridgeFacilitatorAdapter.abi.Events[event].ID { - return nil, errors.New("event signature mismatch") - } - out := new(BridgeFacilitatorAdapterSetExposureLimits) - if len(log.Data) > 0 { - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, event, log.Data); err != nil { - return nil, err - } - } - var indexed abi.Arguments - for _, arg := range bridgeFacilitatorAdapter.abi.Events[event].Inputs { - if arg.Indexed { - indexed = append(indexed, arg) - } - } - if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { - return nil, err - } - out.Raw = log - return out, nil -} - -// BridgeFacilitatorAdapterSetOfferSigner represents a SetOfferSigner event raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterSetOfferSigner struct { - Signer common.Address - Raw *types.Log // Blockchain specific contextual infos -} - -const BridgeFacilitatorAdapterSetOfferSignerEventName = "SetOfferSigner" - -// ContractEventName returns the user-defined event name. -func (BridgeFacilitatorAdapterSetOfferSigner) ContractEventName() string { - return BridgeFacilitatorAdapterSetOfferSignerEventName -} - -// UnpackSetOfferSignerEvent is the Go binding that unpacks the event data emitted -// by contract. -// -// Solidity: event SetOfferSigner(address indexed signer) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackSetOfferSignerEvent(log *types.Log) (*BridgeFacilitatorAdapterSetOfferSigner, error) { - event := "SetOfferSigner" - if log.Topics[0] != bridgeFacilitatorAdapter.abi.Events[event].ID { - return nil, errors.New("event signature mismatch") - } - out := new(BridgeFacilitatorAdapterSetOfferSigner) - if len(log.Data) > 0 { - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, event, log.Data); err != nil { - return nil, err - } - } - var indexed abi.Arguments - for _, arg := range bridgeFacilitatorAdapter.abi.Events[event].Inputs { - if arg.Indexed { - indexed = append(indexed, arg) - } - } - if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { - return nil, err - } - out.Raw = log - return out, nil -} - -// BridgeFacilitatorAdapterSetVault represents a SetVault event raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterSetVault struct { - Vault common.Address - Raw *types.Log // Blockchain specific contextual infos -} - -const BridgeFacilitatorAdapterSetVaultEventName = "SetVault" - -// ContractEventName returns the user-defined event name. -func (BridgeFacilitatorAdapterSetVault) ContractEventName() string { - return BridgeFacilitatorAdapterSetVaultEventName -} - -// UnpackSetVaultEvent is the Go binding that unpacks the event data emitted -// by contract. -// -// Solidity: event SetVault(address indexed vault) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackSetVaultEvent(log *types.Log) (*BridgeFacilitatorAdapterSetVault, error) { - event := "SetVault" - if log.Topics[0] != bridgeFacilitatorAdapter.abi.Events[event].ID { - return nil, errors.New("event signature mismatch") - } - out := new(BridgeFacilitatorAdapterSetVault) - if len(log.Data) > 0 { - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, event, log.Data); err != nil { - return nil, err - } - } - var indexed abi.Arguments - for _, arg := range bridgeFacilitatorAdapter.abi.Events[event].Inputs { - if arg.Indexed { - indexed = append(indexed, arg) - } - } - if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { - return nil, err - } - out.Raw = log - return out, nil -} - -// UnpackError attempts to decode the provided error data using user-defined -// error definitions. -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackError(raw []byte) (any, error) { - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["AlreadyInitialized"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackAlreadyInitializedError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["AssetMismatch"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackAssetMismatchError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["InsufficientLiquidity"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackInsufficientLiquidityError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["InvalidInitialization"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackInvalidInitializationError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["InvalidVault"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackInvalidVaultError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["NotAttested"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackNotAttestedError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["NotFactory"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackNotFactoryError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["NotInitialized"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackNotInitializedError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["NotInitializing"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackNotInitializingError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["NotVault"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackNotVaultError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["OwnableInvalidOwner"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackOwnableInvalidOwnerError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["OwnableUnauthorizedAccount"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackOwnableUnauthorizedAccountError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["PerRequestCapExceeded"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackPerRequestCapExceededError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["ReentrancyGuardReentrantCall"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackReentrancyGuardReentrantCallError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["SafeCastOverflowedUintDowncast"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackSafeCastOverflowedUintDowncastError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["SafeERC20FailedOperation"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackSafeERC20FailedOperationError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["SleeveCapExceeded"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackSleeveCapExceededError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["TooManyConcurrentLoans"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackTooManyConcurrentLoansError(raw[4:]) - } - if bytes.Equal(raw[:4], bridgeFacilitatorAdapter.abi.Errors["YieldTooLow"].ID.Bytes()[:4]) { - return bridgeFacilitatorAdapter.UnpackYieldTooLowError(raw[4:]) - } - return nil, errors.New("Unknown error") -} - -// BridgeFacilitatorAdapterAlreadyInitialized represents a AlreadyInitialized error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterAlreadyInitialized struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error AlreadyInitialized() -func BridgeFacilitatorAdapterAlreadyInitializedErrorID() common.Hash { - return common.HexToHash("0x0dc149f07762891dbcea3fe72770f3d63a1863fc54b2f084e8c59ec476996927") -} - -// UnpackAlreadyInitializedError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error AlreadyInitialized() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackAlreadyInitializedError(raw []byte) (*BridgeFacilitatorAdapterAlreadyInitialized, error) { - out := new(BridgeFacilitatorAdapterAlreadyInitialized) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "AlreadyInitialized", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterAssetMismatch represents a AssetMismatch error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterAssetMismatch struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error AssetMismatch() -func BridgeFacilitatorAdapterAssetMismatchErrorID() common.Hash { - return common.HexToHash("0x83c1010ad7aa04f27fb612a82818ae1f4e183ffb2c2ce08a49b7b56cdd6dd4fb") -} - -// UnpackAssetMismatchError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error AssetMismatch() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackAssetMismatchError(raw []byte) (*BridgeFacilitatorAdapterAssetMismatch, error) { - out := new(BridgeFacilitatorAdapterAssetMismatch) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "AssetMismatch", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterInsufficientLiquidity represents a InsufficientLiquidity error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterInsufficientLiquidity struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error InsufficientLiquidity() -func BridgeFacilitatorAdapterInsufficientLiquidityErrorID() common.Hash { - return common.HexToHash("0xbb55fd27c46b5ba9f88ff2cb2222216afeb0f193423b26615497b3020ab61f8e") -} - -// UnpackInsufficientLiquidityError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error InsufficientLiquidity() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackInsufficientLiquidityError(raw []byte) (*BridgeFacilitatorAdapterInsufficientLiquidity, error) { - out := new(BridgeFacilitatorAdapterInsufficientLiquidity) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "InsufficientLiquidity", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterInvalidInitialization represents a InvalidInitialization error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterInvalidInitialization struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error InvalidInitialization() -func BridgeFacilitatorAdapterInvalidInitializationErrorID() common.Hash { - return common.HexToHash("0xf92ee8a957075833165f68c320933b1a1294aafc84ee6e0dd3fb178008f9aaf5") -} - -// UnpackInvalidInitializationError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error InvalidInitialization() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackInvalidInitializationError(raw []byte) (*BridgeFacilitatorAdapterInvalidInitialization, error) { - out := new(BridgeFacilitatorAdapterInvalidInitialization) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "InvalidInitialization", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterInvalidVault represents a InvalidVault error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterInvalidVault struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error InvalidVault() -func BridgeFacilitatorAdapterInvalidVaultErrorID() common.Hash { - return common.HexToHash("0xd03a63207f799c8b4a310cf73db481de483ce6543ef24d1f75f918a11e4eae1f") -} - -// UnpackInvalidVaultError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error InvalidVault() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackInvalidVaultError(raw []byte) (*BridgeFacilitatorAdapterInvalidVault, error) { - out := new(BridgeFacilitatorAdapterInvalidVault) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "InvalidVault", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterNotAttested represents a NotAttested error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterNotAttested struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error NotAttested() -func BridgeFacilitatorAdapterNotAttestedErrorID() common.Hash { - return common.HexToHash("0x99efb89078879e78f0f307145c3360fe4f6680762a21d87392e067610a80f73d") -} - -// UnpackNotAttestedError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error NotAttested() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackNotAttestedError(raw []byte) (*BridgeFacilitatorAdapterNotAttested, error) { - out := new(BridgeFacilitatorAdapterNotAttested) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "NotAttested", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterNotFactory represents a NotFactory error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterNotFactory struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error NotFactory() -func BridgeFacilitatorAdapterNotFactoryErrorID() common.Hash { - return common.HexToHash("0x32cc723614e775fc4a8386492bc9a860c12fe98d5f5f28ec17e265818645b229") -} - -// UnpackNotFactoryError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error NotFactory() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackNotFactoryError(raw []byte) (*BridgeFacilitatorAdapterNotFactory, error) { - out := new(BridgeFacilitatorAdapterNotFactory) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "NotFactory", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterNotInitialized represents a NotInitialized error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterNotInitialized struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error NotInitialized() -func BridgeFacilitatorAdapterNotInitializedErrorID() common.Hash { - return common.HexToHash("0x87138d5c8c2e77cb9f25c07b03277aad63d22f6a05255580ec55d2c21666e734") -} - -// UnpackNotInitializedError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error NotInitialized() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackNotInitializedError(raw []byte) (*BridgeFacilitatorAdapterNotInitialized, error) { - out := new(BridgeFacilitatorAdapterNotInitialized) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "NotInitialized", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterNotInitializing represents a NotInitializing error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterNotInitializing struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error NotInitializing() -func BridgeFacilitatorAdapterNotInitializingErrorID() common.Hash { - return common.HexToHash("0xd7e6bcf8597daa127dc9f0048d2f08d5ef140a2cb659feabd700beff1f7a8302") -} - -// UnpackNotInitializingError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error NotInitializing() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackNotInitializingError(raw []byte) (*BridgeFacilitatorAdapterNotInitializing, error) { - out := new(BridgeFacilitatorAdapterNotInitializing) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "NotInitializing", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterNotVault represents a NotVault error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterNotVault struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error NotVault() -func BridgeFacilitatorAdapterNotVaultErrorID() common.Hash { - return common.HexToHash("0x62df0545b0e47f06f6a9990975121b8c49c83a96f18696393f66a69dd2ffe568") -} - -// UnpackNotVaultError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error NotVault() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackNotVaultError(raw []byte) (*BridgeFacilitatorAdapterNotVault, error) { - out := new(BridgeFacilitatorAdapterNotVault) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "NotVault", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterOwnableInvalidOwner represents a OwnableInvalidOwner error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterOwnableInvalidOwner struct { - Owner common.Address -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error OwnableInvalidOwner(address owner) -func BridgeFacilitatorAdapterOwnableInvalidOwnerErrorID() common.Hash { - return common.HexToHash("0x1e4fbdf7f3ef8bcaa855599e3abf48b232380f183f08f6f813d9ffa5bd585188") -} - -// UnpackOwnableInvalidOwnerError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error OwnableInvalidOwner(address owner) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackOwnableInvalidOwnerError(raw []byte) (*BridgeFacilitatorAdapterOwnableInvalidOwner, error) { - out := new(BridgeFacilitatorAdapterOwnableInvalidOwner) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "OwnableInvalidOwner", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterOwnableUnauthorizedAccount represents a OwnableUnauthorizedAccount error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterOwnableUnauthorizedAccount struct { - Account common.Address -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error OwnableUnauthorizedAccount(address account) -func BridgeFacilitatorAdapterOwnableUnauthorizedAccountErrorID() common.Hash { - return common.HexToHash("0x118cdaa7a341953d1887a2245fd6665d741c67c8c50581daa59e1d03373fa188") -} - -// UnpackOwnableUnauthorizedAccountError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error OwnableUnauthorizedAccount(address account) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackOwnableUnauthorizedAccountError(raw []byte) (*BridgeFacilitatorAdapterOwnableUnauthorizedAccount, error) { - out := new(BridgeFacilitatorAdapterOwnableUnauthorizedAccount) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "OwnableUnauthorizedAccount", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterPerRequestCapExceeded represents a PerRequestCapExceeded error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterPerRequestCapExceeded struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error PerRequestCapExceeded() -func BridgeFacilitatorAdapterPerRequestCapExceededErrorID() common.Hash { - return common.HexToHash("0x71f1d368b03a6a05e70fa19b11e67ee48021141a925ec34bb569da61b20c54ba") -} - -// UnpackPerRequestCapExceededError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error PerRequestCapExceeded() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackPerRequestCapExceededError(raw []byte) (*BridgeFacilitatorAdapterPerRequestCapExceeded, error) { - out := new(BridgeFacilitatorAdapterPerRequestCapExceeded) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "PerRequestCapExceeded", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterReentrancyGuardReentrantCall represents a ReentrancyGuardReentrantCall error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterReentrancyGuardReentrantCall struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error ReentrancyGuardReentrantCall() -func BridgeFacilitatorAdapterReentrancyGuardReentrantCallErrorID() common.Hash { - return common.HexToHash("0x3ee5aeb571de7fc460830b4d0017439a1ca56fb0bc39062227ade4fe4a24c1ca") -} - -// UnpackReentrancyGuardReentrantCallError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error ReentrancyGuardReentrantCall() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackReentrancyGuardReentrantCallError(raw []byte) (*BridgeFacilitatorAdapterReentrancyGuardReentrantCall, error) { - out := new(BridgeFacilitatorAdapterReentrancyGuardReentrantCall) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "ReentrancyGuardReentrantCall", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterSafeCastOverflowedUintDowncast represents a SafeCastOverflowedUintDowncast error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterSafeCastOverflowedUintDowncast struct { - Bits uint8 - Value *big.Int -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error SafeCastOverflowedUintDowncast(uint8 bits, uint256 value) -func BridgeFacilitatorAdapterSafeCastOverflowedUintDowncastErrorID() common.Hash { - return common.HexToHash("0x6dfcc6503a32754ce7a89698e18201fc5294fd4aad43edefee786f88423b1a12") -} - -// UnpackSafeCastOverflowedUintDowncastError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error SafeCastOverflowedUintDowncast(uint8 bits, uint256 value) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackSafeCastOverflowedUintDowncastError(raw []byte) (*BridgeFacilitatorAdapterSafeCastOverflowedUintDowncast, error) { - out := new(BridgeFacilitatorAdapterSafeCastOverflowedUintDowncast) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "SafeCastOverflowedUintDowncast", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterSafeERC20FailedOperation represents a SafeERC20FailedOperation error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterSafeERC20FailedOperation struct { - Token common.Address -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error SafeERC20FailedOperation(address token) -func BridgeFacilitatorAdapterSafeERC20FailedOperationErrorID() common.Hash { - return common.HexToHash("0x5274afe73c98b4749fc91ffae6b7b574e7842cb2144a159e9377a5f20b32edf9") -} - -// UnpackSafeERC20FailedOperationError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error SafeERC20FailedOperation(address token) -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackSafeERC20FailedOperationError(raw []byte) (*BridgeFacilitatorAdapterSafeERC20FailedOperation, error) { - out := new(BridgeFacilitatorAdapterSafeERC20FailedOperation) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "SafeERC20FailedOperation", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterSleeveCapExceeded represents a SleeveCapExceeded error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterSleeveCapExceeded struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error SleeveCapExceeded() -func BridgeFacilitatorAdapterSleeveCapExceededErrorID() common.Hash { - return common.HexToHash("0x8d3a6f3e593b3d68ab893b7400dc61f24661274e31ea7e1a15c542fef17b22de") -} - -// UnpackSleeveCapExceededError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error SleeveCapExceeded() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackSleeveCapExceededError(raw []byte) (*BridgeFacilitatorAdapterSleeveCapExceeded, error) { - out := new(BridgeFacilitatorAdapterSleeveCapExceeded) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "SleeveCapExceeded", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterTooManyConcurrentLoans represents a TooManyConcurrentLoans error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterTooManyConcurrentLoans struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error TooManyConcurrentLoans() -func BridgeFacilitatorAdapterTooManyConcurrentLoansErrorID() common.Hash { - return common.HexToHash("0x300b297baedcb5c69bc8e4b45ba3f071ba8caa59a4b3e446905bac4dc7450498") -} - -// UnpackTooManyConcurrentLoansError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error TooManyConcurrentLoans() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackTooManyConcurrentLoansError(raw []byte) (*BridgeFacilitatorAdapterTooManyConcurrentLoans, error) { - out := new(BridgeFacilitatorAdapterTooManyConcurrentLoans) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "TooManyConcurrentLoans", raw); err != nil { - return nil, err - } - return out, nil -} - -// BridgeFacilitatorAdapterYieldTooLow represents a YieldTooLow error raised by the BridgeFacilitatorAdapter contract. -type BridgeFacilitatorAdapterYieldTooLow struct { -} - -// ErrorID returns the hash of canonical representation of the error's signature. -// -// Solidity: error YieldTooLow() -func BridgeFacilitatorAdapterYieldTooLowErrorID() common.Hash { - return common.HexToHash("0x6f0b92522c675a3e71e7d7b1715735261c69f22c160af876cf34df7e201b542f") -} - -// UnpackYieldTooLowError is the Go binding used to decode the provided -// error data into the corresponding Go error struct. -// -// Solidity: error YieldTooLow() -func (bridgeFacilitatorAdapter *BridgeFacilitatorAdapter) UnpackYieldTooLowError(raw []byte) (*BridgeFacilitatorAdapterYieldTooLow, error) { - out := new(BridgeFacilitatorAdapterYieldTooLow) - if err := bridgeFacilitatorAdapter.abi.UnpackIntoInterface(out, "YieldTooLow", raw); err != nil { - return nil, err - } - return out, nil -} diff --git a/api/bindings/3f/adapter/ThreeFAdapter.go b/api/bindings/3f/adapter/ThreeFAdapter.go new file mode 100644 index 00000000..1291d416 --- /dev/null +++ b/api/bindings/3f/adapter/ThreeFAdapter.go @@ -0,0 +1,1750 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package adapter + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// Offer is an auto generated low-level Go binding around an user-defined struct. +type Offer struct { + Maker common.Address + Amount *big.Int + ExpectedReturn *big.Int + Nonce *big.Int + Expiration *big.Int + UseCallback bool +} + +// ThreeFAdapterMetaData contains all meta data concerning the ThreeFAdapter contract. +var ThreeFAdapterMetaData = bind.MetaData{ + ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"vaultFactory\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"adapterFactory\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"requestWhitelist\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"FACTORY\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"REQUEST_WHITELIST\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"allocatable\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"allocate\",\"inputs\":[{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"deallocate\",\"inputs\":[{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"finalizeRequest\",\"inputs\":[{\"name\":\"request\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"freeAssets\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getMaxAssets\",\"inputs\":[],\"outputs\":[{\"name\":\"assets\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"initialize\",\"inputs\":[{\"name\":\"initialVersion\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"owner_\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"isValidSignature\",\"inputs\":[{\"name\":\"hash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"signature\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"maxAssetsPerRequest\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"migrate\",\"inputs\":[{\"name\":\"newVersion\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"minAssetsPerRequest\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"minYieldPerRequest\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"multicall\",\"inputs\":[{\"name\":\"data\",\"type\":\"bytes[]\",\"internalType\":\"bytes[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"offerSigner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"onRequestConsumed\",\"inputs\":[{\"name\":\"offer\",\"type\":\"tuple\",\"internalType\":\"structOffer\",\"components\":[{\"name\":\"maker\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"expectedReturn\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"expiration\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"useCallback\",\"type\":\"bool\",\"internalType\":\"bool\"}]},{\"name\":\"\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"principalAssets\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"yieldAssets\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"renounceOwnership\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"requestDeallocate\",\"inputs\":[{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"requestIndex\",\"inputs\":[{\"name\":\"request\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"index\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"requests\",\"inputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"requestsLength\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"setLimitsPerRequest\",\"inputs\":[{\"name\":\"newMinYieldPerRequest\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"newMinAssetsPerRequest\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"newMaxAssetsPerRequest\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setOfferSigner\",\"inputs\":[{\"name\":\"newOfferSigner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"staticDelegateCall\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"totalAssets\",\"inputs\":[],\"outputs\":[{\"name\":\"assets\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"vault\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"version\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"event\",\"name\":\"FinalizeRequest\",\"inputs\":[{\"name\":\"request\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Initialized\",\"inputs\":[{\"name\":\"version\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OnRequestConsumed\",\"inputs\":[{\"name\":\"request\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"offer\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structOffer\",\"components\":[{\"name\":\"maker\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"expectedReturn\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"expiration\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"useCallback\",\"type\":\"bool\",\"internalType\":\"bool\"}]},{\"name\":\"principalAssets\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"yieldAssets\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OwnershipTransferred\",\"inputs\":[{\"name\":\"previousOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"newOwner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"SetLimitsPerRequest\",\"inputs\":[{\"name\":\"minYieldPerRequest\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"minAssetsPerRequest\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"maxAssetsPerRequest\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"SetOfferSigner\",\"inputs\":[{\"name\":\"offerSigner\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"SetVault\",\"inputs\":[{\"name\":\"vault\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AlreadyInitialized\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AlreadyRequest\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientAllocate\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidInitialization\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidVault\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotFactory\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotInitializing\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotRequest\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotVault\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"OwnableInvalidOwner\",\"inputs\":[{\"name\":\"owner\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"OwnableUnauthorizedAccount\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ReentrancyGuardReentrantCall\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SafeERC20FailedOperation\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"TooLargeRequest\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TooLowYield\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TooManyRequests\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TooSmallRequest\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"WrongAsset\",\"inputs\":[]}]", + ID: "ThreeFAdapter", +} + +// ThreeFAdapter is an auto generated Go binding around an Ethereum contract. +type ThreeFAdapter struct { + abi abi.ABI +} + +// NewThreeFAdapter creates a new instance of ThreeFAdapter. +func NewThreeFAdapter() *ThreeFAdapter { + parsed, err := ThreeFAdapterMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &ThreeFAdapter{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *ThreeFAdapter) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackConstructor is the Go binding used to pack the parameters required for +// contract deployment. +// +// Solidity: constructor(address vaultFactory, address adapterFactory, address requestWhitelist) returns() +func (threeFAdapter *ThreeFAdapter) PackConstructor(vaultFactory common.Address, adapterFactory common.Address, requestWhitelist common.Address) []byte { + enc, err := threeFAdapter.abi.Pack("", vaultFactory, adapterFactory, requestWhitelist) + if err != nil { + panic(err) + } + return enc +} + +// PackFACTORY is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x2dd31000. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function FACTORY() view returns(address) +func (threeFAdapter *ThreeFAdapter) PackFACTORY() []byte { + enc, err := threeFAdapter.abi.Pack("FACTORY") + if err != nil { + panic(err) + } + return enc +} + +// TryPackFACTORY is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x2dd31000. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function FACTORY() view returns(address) +func (threeFAdapter *ThreeFAdapter) TryPackFACTORY() ([]byte, error) { + return threeFAdapter.abi.Pack("FACTORY") +} + +// UnpackFACTORY is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x2dd31000. +// +// Solidity: function FACTORY() view returns(address) +func (threeFAdapter *ThreeFAdapter) UnpackFACTORY(data []byte) (common.Address, error) { + out, err := threeFAdapter.abi.Unpack("FACTORY", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackREQUESTWHITELIST is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x894e6d61. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function REQUEST_WHITELIST() view returns(address) +func (threeFAdapter *ThreeFAdapter) PackREQUESTWHITELIST() []byte { + enc, err := threeFAdapter.abi.Pack("REQUEST_WHITELIST") + if err != nil { + panic(err) + } + return enc +} + +// TryPackREQUESTWHITELIST is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x894e6d61. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function REQUEST_WHITELIST() view returns(address) +func (threeFAdapter *ThreeFAdapter) TryPackREQUESTWHITELIST() ([]byte, error) { + return threeFAdapter.abi.Pack("REQUEST_WHITELIST") +} + +// UnpackREQUESTWHITELIST is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x894e6d61. +// +// Solidity: function REQUEST_WHITELIST() view returns(address) +func (threeFAdapter *ThreeFAdapter) UnpackREQUESTWHITELIST(data []byte) (common.Address, error) { + out, err := threeFAdapter.abi.Unpack("REQUEST_WHITELIST", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackAllocatable is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x1d3b809a. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function allocatable() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) PackAllocatable() []byte { + enc, err := threeFAdapter.abi.Pack("allocatable") + if err != nil { + panic(err) + } + return enc +} + +// TryPackAllocatable is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x1d3b809a. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function allocatable() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) TryPackAllocatable() ([]byte, error) { + return threeFAdapter.abi.Pack("allocatable") +} + +// UnpackAllocatable is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x1d3b809a. +// +// Solidity: function allocatable() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) UnpackAllocatable(data []byte) (*big.Int, error) { + out, err := threeFAdapter.abi.Unpack("allocatable", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackAllocate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x90ca796b. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function allocate(uint256 amount) returns(uint256) +func (threeFAdapter *ThreeFAdapter) PackAllocate(amount *big.Int) []byte { + enc, err := threeFAdapter.abi.Pack("allocate", amount) + if err != nil { + panic(err) + } + return enc +} + +// TryPackAllocate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x90ca796b. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function allocate(uint256 amount) returns(uint256) +func (threeFAdapter *ThreeFAdapter) TryPackAllocate(amount *big.Int) ([]byte, error) { + return threeFAdapter.abi.Pack("allocate", amount) +} + +// UnpackAllocate is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x90ca796b. +// +// Solidity: function allocate(uint256 amount) returns(uint256) +func (threeFAdapter *ThreeFAdapter) UnpackAllocate(data []byte) (*big.Int, error) { + out, err := threeFAdapter.abi.Unpack("allocate", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackDeallocate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x6f6c441f. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function deallocate(uint256 amount) returns(uint256) +func (threeFAdapter *ThreeFAdapter) PackDeallocate(amount *big.Int) []byte { + enc, err := threeFAdapter.abi.Pack("deallocate", amount) + if err != nil { + panic(err) + } + return enc +} + +// TryPackDeallocate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x6f6c441f. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function deallocate(uint256 amount) returns(uint256) +func (threeFAdapter *ThreeFAdapter) TryPackDeallocate(amount *big.Int) ([]byte, error) { + return threeFAdapter.abi.Pack("deallocate", amount) +} + +// UnpackDeallocate is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x6f6c441f. +// +// Solidity: function deallocate(uint256 amount) returns(uint256) +func (threeFAdapter *ThreeFAdapter) UnpackDeallocate(data []byte) (*big.Int, error) { + out, err := threeFAdapter.abi.Unpack("deallocate", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackFinalizeRequest is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x1d280eb9. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function finalizeRequest(address request) returns() +func (threeFAdapter *ThreeFAdapter) PackFinalizeRequest(request common.Address) []byte { + enc, err := threeFAdapter.abi.Pack("finalizeRequest", request) + if err != nil { + panic(err) + } + return enc +} + +// TryPackFinalizeRequest is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x1d280eb9. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function finalizeRequest(address request) returns() +func (threeFAdapter *ThreeFAdapter) TryPackFinalizeRequest(request common.Address) ([]byte, error) { + return threeFAdapter.abi.Pack("finalizeRequest", request) +} + +// PackFreeAssets is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x11f240ac. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function freeAssets() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) PackFreeAssets() []byte { + enc, err := threeFAdapter.abi.Pack("freeAssets") + if err != nil { + panic(err) + } + return enc +} + +// TryPackFreeAssets is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x11f240ac. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function freeAssets() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) TryPackFreeAssets() ([]byte, error) { + return threeFAdapter.abi.Pack("freeAssets") +} + +// UnpackFreeAssets is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x11f240ac. +// +// Solidity: function freeAssets() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) UnpackFreeAssets(data []byte) (*big.Int, error) { + out, err := threeFAdapter.abi.Unpack("freeAssets", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackGetMaxAssets is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x1755da83. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function getMaxAssets() returns(uint256 assets) +func (threeFAdapter *ThreeFAdapter) PackGetMaxAssets() []byte { + enc, err := threeFAdapter.abi.Pack("getMaxAssets") + if err != nil { + panic(err) + } + return enc +} + +// TryPackGetMaxAssets is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x1755da83. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function getMaxAssets() returns(uint256 assets) +func (threeFAdapter *ThreeFAdapter) TryPackGetMaxAssets() ([]byte, error) { + return threeFAdapter.abi.Pack("getMaxAssets") +} + +// UnpackGetMaxAssets is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x1755da83. +// +// Solidity: function getMaxAssets() returns(uint256 assets) +func (threeFAdapter *ThreeFAdapter) UnpackGetMaxAssets(data []byte) (*big.Int, error) { + out, err := threeFAdapter.abi.Unpack("getMaxAssets", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackInitialize is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x57ec83cc. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function initialize(uint64 initialVersion, address owner_, bytes data) returns() +func (threeFAdapter *ThreeFAdapter) PackInitialize(initialVersion uint64, owner common.Address, data []byte) []byte { + enc, err := threeFAdapter.abi.Pack("initialize", initialVersion, owner, data) + if err != nil { + panic(err) + } + return enc +} + +// TryPackInitialize is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x57ec83cc. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function initialize(uint64 initialVersion, address owner_, bytes data) returns() +func (threeFAdapter *ThreeFAdapter) TryPackInitialize(initialVersion uint64, owner common.Address, data []byte) ([]byte, error) { + return threeFAdapter.abi.Pack("initialize", initialVersion, owner, data) +} + +// PackIsValidSignature is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x1626ba7e. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function isValidSignature(bytes32 hash, bytes signature) view returns(bytes4) +func (threeFAdapter *ThreeFAdapter) PackIsValidSignature(hash [32]byte, signature []byte) []byte { + enc, err := threeFAdapter.abi.Pack("isValidSignature", hash, signature) + if err != nil { + panic(err) + } + return enc +} + +// TryPackIsValidSignature is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x1626ba7e. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function isValidSignature(bytes32 hash, bytes signature) view returns(bytes4) +func (threeFAdapter *ThreeFAdapter) TryPackIsValidSignature(hash [32]byte, signature []byte) ([]byte, error) { + return threeFAdapter.abi.Pack("isValidSignature", hash, signature) +} + +// UnpackIsValidSignature is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x1626ba7e. +// +// Solidity: function isValidSignature(bytes32 hash, bytes signature) view returns(bytes4) +func (threeFAdapter *ThreeFAdapter) UnpackIsValidSignature(data []byte) ([4]byte, error) { + out, err := threeFAdapter.abi.Unpack("isValidSignature", data) + if err != nil { + return *new([4]byte), err + } + out0 := *abi.ConvertType(out[0], new([4]byte)).(*[4]byte) + return out0, nil +} + +// PackMaxAssetsPerRequest is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xe84fb141. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function maxAssetsPerRequest() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) PackMaxAssetsPerRequest() []byte { + enc, err := threeFAdapter.abi.Pack("maxAssetsPerRequest") + if err != nil { + panic(err) + } + return enc +} + +// TryPackMaxAssetsPerRequest is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xe84fb141. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function maxAssetsPerRequest() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) TryPackMaxAssetsPerRequest() ([]byte, error) { + return threeFAdapter.abi.Pack("maxAssetsPerRequest") +} + +// UnpackMaxAssetsPerRequest is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xe84fb141. +// +// Solidity: function maxAssetsPerRequest() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) UnpackMaxAssetsPerRequest(data []byte) (*big.Int, error) { + out, err := threeFAdapter.abi.Unpack("maxAssetsPerRequest", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackMigrate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x2abe3048. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function migrate(uint64 newVersion, bytes data) returns() +func (threeFAdapter *ThreeFAdapter) PackMigrate(newVersion uint64, data []byte) []byte { + enc, err := threeFAdapter.abi.Pack("migrate", newVersion, data) + if err != nil { + panic(err) + } + return enc +} + +// TryPackMigrate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x2abe3048. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function migrate(uint64 newVersion, bytes data) returns() +func (threeFAdapter *ThreeFAdapter) TryPackMigrate(newVersion uint64, data []byte) ([]byte, error) { + return threeFAdapter.abi.Pack("migrate", newVersion, data) +} + +// PackMinAssetsPerRequest is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x5b0a8440. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function minAssetsPerRequest() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) PackMinAssetsPerRequest() []byte { + enc, err := threeFAdapter.abi.Pack("minAssetsPerRequest") + if err != nil { + panic(err) + } + return enc +} + +// TryPackMinAssetsPerRequest is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x5b0a8440. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function minAssetsPerRequest() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) TryPackMinAssetsPerRequest() ([]byte, error) { + return threeFAdapter.abi.Pack("minAssetsPerRequest") +} + +// UnpackMinAssetsPerRequest is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x5b0a8440. +// +// Solidity: function minAssetsPerRequest() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) UnpackMinAssetsPerRequest(data []byte) (*big.Int, error) { + out, err := threeFAdapter.abi.Unpack("minAssetsPerRequest", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackMinYieldPerRequest is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xa9c6b425. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function minYieldPerRequest() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) PackMinYieldPerRequest() []byte { + enc, err := threeFAdapter.abi.Pack("minYieldPerRequest") + if err != nil { + panic(err) + } + return enc +} + +// TryPackMinYieldPerRequest is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xa9c6b425. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function minYieldPerRequest() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) TryPackMinYieldPerRequest() ([]byte, error) { + return threeFAdapter.abi.Pack("minYieldPerRequest") +} + +// UnpackMinYieldPerRequest is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xa9c6b425. +// +// Solidity: function minYieldPerRequest() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) UnpackMinYieldPerRequest(data []byte) (*big.Int, error) { + out, err := threeFAdapter.abi.Unpack("minYieldPerRequest", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackMulticall is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xac9650d8. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function multicall(bytes[] data) returns() +func (threeFAdapter *ThreeFAdapter) PackMulticall(data [][]byte) []byte { + enc, err := threeFAdapter.abi.Pack("multicall", data) + if err != nil { + panic(err) + } + return enc +} + +// TryPackMulticall is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xac9650d8. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function multicall(bytes[] data) returns() +func (threeFAdapter *ThreeFAdapter) TryPackMulticall(data [][]byte) ([]byte, error) { + return threeFAdapter.abi.Pack("multicall", data) +} + +// PackOfferSigner is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x566bd6c3. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function offerSigner() view returns(address) +func (threeFAdapter *ThreeFAdapter) PackOfferSigner() []byte { + enc, err := threeFAdapter.abi.Pack("offerSigner") + if err != nil { + panic(err) + } + return enc +} + +// TryPackOfferSigner is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x566bd6c3. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function offerSigner() view returns(address) +func (threeFAdapter *ThreeFAdapter) TryPackOfferSigner() ([]byte, error) { + return threeFAdapter.abi.Pack("offerSigner") +} + +// UnpackOfferSigner is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x566bd6c3. +// +// Solidity: function offerSigner() view returns(address) +func (threeFAdapter *ThreeFAdapter) UnpackOfferSigner(data []byte) (common.Address, error) { + out, err := threeFAdapter.abi.Unpack("offerSigner", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackOnRequestConsumed is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf2fe1357. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function onRequestConsumed((address,uint256,uint256,uint256,uint256,bool) offer, bytes , uint256 principalAssets, uint256 yieldAssets) returns() +func (threeFAdapter *ThreeFAdapter) PackOnRequestConsumed(offer Offer, arg1 []byte, principalAssets *big.Int, yieldAssets *big.Int) []byte { + enc, err := threeFAdapter.abi.Pack("onRequestConsumed", offer, arg1, principalAssets, yieldAssets) + if err != nil { + panic(err) + } + return enc +} + +// TryPackOnRequestConsumed is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf2fe1357. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function onRequestConsumed((address,uint256,uint256,uint256,uint256,bool) offer, bytes , uint256 principalAssets, uint256 yieldAssets) returns() +func (threeFAdapter *ThreeFAdapter) TryPackOnRequestConsumed(offer Offer, arg1 []byte, principalAssets *big.Int, yieldAssets *big.Int) ([]byte, error) { + return threeFAdapter.abi.Pack("onRequestConsumed", offer, arg1, principalAssets, yieldAssets) +} + +// PackOwner is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8da5cb5b. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function owner() view returns(address) +func (threeFAdapter *ThreeFAdapter) PackOwner() []byte { + enc, err := threeFAdapter.abi.Pack("owner") + if err != nil { + panic(err) + } + return enc +} + +// TryPackOwner is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8da5cb5b. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function owner() view returns(address) +func (threeFAdapter *ThreeFAdapter) TryPackOwner() ([]byte, error) { + return threeFAdapter.abi.Pack("owner") +} + +// UnpackOwner is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x8da5cb5b. +// +// Solidity: function owner() view returns(address) +func (threeFAdapter *ThreeFAdapter) UnpackOwner(data []byte) (common.Address, error) { + out, err := threeFAdapter.abi.Unpack("owner", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackRenounceOwnership is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x715018a6. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function renounceOwnership() returns() +func (threeFAdapter *ThreeFAdapter) PackRenounceOwnership() []byte { + enc, err := threeFAdapter.abi.Pack("renounceOwnership") + if err != nil { + panic(err) + } + return enc +} + +// TryPackRenounceOwnership is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x715018a6. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function renounceOwnership() returns() +func (threeFAdapter *ThreeFAdapter) TryPackRenounceOwnership() ([]byte, error) { + return threeFAdapter.abi.Pack("renounceOwnership") +} + +// PackRequestDeallocate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf79f679d. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function requestDeallocate(uint256 amount) returns() +func (threeFAdapter *ThreeFAdapter) PackRequestDeallocate(amount *big.Int) []byte { + enc, err := threeFAdapter.abi.Pack("requestDeallocate", amount) + if err != nil { + panic(err) + } + return enc +} + +// TryPackRequestDeallocate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf79f679d. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function requestDeallocate(uint256 amount) returns() +func (threeFAdapter *ThreeFAdapter) TryPackRequestDeallocate(amount *big.Int) ([]byte, error) { + return threeFAdapter.abi.Pack("requestDeallocate", amount) +} + +// PackRequestIndex is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8163ade3. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function requestIndex(address request) view returns(uint256 index) +func (threeFAdapter *ThreeFAdapter) PackRequestIndex(request common.Address) []byte { + enc, err := threeFAdapter.abi.Pack("requestIndex", request) + if err != nil { + panic(err) + } + return enc +} + +// TryPackRequestIndex is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8163ade3. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function requestIndex(address request) view returns(uint256 index) +func (threeFAdapter *ThreeFAdapter) TryPackRequestIndex(request common.Address) ([]byte, error) { + return threeFAdapter.abi.Pack("requestIndex", request) +} + +// UnpackRequestIndex is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x8163ade3. +// +// Solidity: function requestIndex(address request) view returns(uint256 index) +func (threeFAdapter *ThreeFAdapter) UnpackRequestIndex(data []byte) (*big.Int, error) { + out, err := threeFAdapter.abi.Unpack("requestIndex", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackRequests is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x81d12c58. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function requests(uint256 ) view returns(address) +func (threeFAdapter *ThreeFAdapter) PackRequests(arg0 *big.Int) []byte { + enc, err := threeFAdapter.abi.Pack("requests", arg0) + if err != nil { + panic(err) + } + return enc +} + +// TryPackRequests is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x81d12c58. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function requests(uint256 ) view returns(address) +func (threeFAdapter *ThreeFAdapter) TryPackRequests(arg0 *big.Int) ([]byte, error) { + return threeFAdapter.abi.Pack("requests", arg0) +} + +// UnpackRequests is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x81d12c58. +// +// Solidity: function requests(uint256 ) view returns(address) +func (threeFAdapter *ThreeFAdapter) UnpackRequests(data []byte) (common.Address, error) { + out, err := threeFAdapter.abi.Unpack("requests", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackRequestsLength is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xffbbfcb0. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function requestsLength() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) PackRequestsLength() []byte { + enc, err := threeFAdapter.abi.Pack("requestsLength") + if err != nil { + panic(err) + } + return enc +} + +// TryPackRequestsLength is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xffbbfcb0. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function requestsLength() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) TryPackRequestsLength() ([]byte, error) { + return threeFAdapter.abi.Pack("requestsLength") +} + +// UnpackRequestsLength is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xffbbfcb0. +// +// Solidity: function requestsLength() view returns(uint256) +func (threeFAdapter *ThreeFAdapter) UnpackRequestsLength(data []byte) (*big.Int, error) { + out, err := threeFAdapter.abi.Unpack("requestsLength", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackSetLimitsPerRequest is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x719b949f. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function setLimitsPerRequest(uint256 newMinYieldPerRequest, uint256 newMinAssetsPerRequest, uint256 newMaxAssetsPerRequest) returns() +func (threeFAdapter *ThreeFAdapter) PackSetLimitsPerRequest(newMinYieldPerRequest *big.Int, newMinAssetsPerRequest *big.Int, newMaxAssetsPerRequest *big.Int) []byte { + enc, err := threeFAdapter.abi.Pack("setLimitsPerRequest", newMinYieldPerRequest, newMinAssetsPerRequest, newMaxAssetsPerRequest) + if err != nil { + panic(err) + } + return enc +} + +// TryPackSetLimitsPerRequest is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x719b949f. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function setLimitsPerRequest(uint256 newMinYieldPerRequest, uint256 newMinAssetsPerRequest, uint256 newMaxAssetsPerRequest) returns() +func (threeFAdapter *ThreeFAdapter) TryPackSetLimitsPerRequest(newMinYieldPerRequest *big.Int, newMinAssetsPerRequest *big.Int, newMaxAssetsPerRequest *big.Int) ([]byte, error) { + return threeFAdapter.abi.Pack("setLimitsPerRequest", newMinYieldPerRequest, newMinAssetsPerRequest, newMaxAssetsPerRequest) +} + +// PackSetOfferSigner is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x868adcae. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function setOfferSigner(address newOfferSigner) returns() +func (threeFAdapter *ThreeFAdapter) PackSetOfferSigner(newOfferSigner common.Address) []byte { + enc, err := threeFAdapter.abi.Pack("setOfferSigner", newOfferSigner) + if err != nil { + panic(err) + } + return enc +} + +// TryPackSetOfferSigner is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x868adcae. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function setOfferSigner(address newOfferSigner) returns() +func (threeFAdapter *ThreeFAdapter) TryPackSetOfferSigner(newOfferSigner common.Address) ([]byte, error) { + return threeFAdapter.abi.Pack("setOfferSigner", newOfferSigner) +} + +// PackStaticDelegateCall is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x9f86fd85. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function staticDelegateCall(address target, bytes data) returns() +func (threeFAdapter *ThreeFAdapter) PackStaticDelegateCall(target common.Address, data []byte) []byte { + enc, err := threeFAdapter.abi.Pack("staticDelegateCall", target, data) + if err != nil { + panic(err) + } + return enc +} + +// TryPackStaticDelegateCall is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x9f86fd85. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function staticDelegateCall(address target, bytes data) returns() +func (threeFAdapter *ThreeFAdapter) TryPackStaticDelegateCall(target common.Address, data []byte) ([]byte, error) { + return threeFAdapter.abi.Pack("staticDelegateCall", target, data) +} + +// PackTotalAssets is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x01e1d114. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function totalAssets() view returns(uint256 assets) +func (threeFAdapter *ThreeFAdapter) PackTotalAssets() []byte { + enc, err := threeFAdapter.abi.Pack("totalAssets") + if err != nil { + panic(err) + } + return enc +} + +// TryPackTotalAssets is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x01e1d114. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function totalAssets() view returns(uint256 assets) +func (threeFAdapter *ThreeFAdapter) TryPackTotalAssets() ([]byte, error) { + return threeFAdapter.abi.Pack("totalAssets") +} + +// UnpackTotalAssets is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x01e1d114. +// +// Solidity: function totalAssets() view returns(uint256 assets) +func (threeFAdapter *ThreeFAdapter) UnpackTotalAssets(data []byte) (*big.Int, error) { + out, err := threeFAdapter.abi.Unpack("totalAssets", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackTransferOwnership is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf2fde38b. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function transferOwnership(address newOwner) returns() +func (threeFAdapter *ThreeFAdapter) PackTransferOwnership(newOwner common.Address) []byte { + enc, err := threeFAdapter.abi.Pack("transferOwnership", newOwner) + if err != nil { + panic(err) + } + return enc +} + +// TryPackTransferOwnership is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf2fde38b. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function transferOwnership(address newOwner) returns() +func (threeFAdapter *ThreeFAdapter) TryPackTransferOwnership(newOwner common.Address) ([]byte, error) { + return threeFAdapter.abi.Pack("transferOwnership", newOwner) +} + +// PackVault is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xfbfa77cf. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function vault() view returns(address) +func (threeFAdapter *ThreeFAdapter) PackVault() []byte { + enc, err := threeFAdapter.abi.Pack("vault") + if err != nil { + panic(err) + } + return enc +} + +// TryPackVault is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xfbfa77cf. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function vault() view returns(address) +func (threeFAdapter *ThreeFAdapter) TryPackVault() ([]byte, error) { + return threeFAdapter.abi.Pack("vault") +} + +// UnpackVault is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xfbfa77cf. +// +// Solidity: function vault() view returns(address) +func (threeFAdapter *ThreeFAdapter) UnpackVault(data []byte) (common.Address, error) { + out, err := threeFAdapter.abi.Unpack("vault", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackVersion is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x54fd4d50. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function version() view returns(uint64) +func (threeFAdapter *ThreeFAdapter) PackVersion() []byte { + enc, err := threeFAdapter.abi.Pack("version") + if err != nil { + panic(err) + } + return enc +} + +// TryPackVersion is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x54fd4d50. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function version() view returns(uint64) +func (threeFAdapter *ThreeFAdapter) TryPackVersion() ([]byte, error) { + return threeFAdapter.abi.Pack("version") +} + +// UnpackVersion is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x54fd4d50. +// +// Solidity: function version() view returns(uint64) +func (threeFAdapter *ThreeFAdapter) UnpackVersion(data []byte) (uint64, error) { + out, err := threeFAdapter.abi.Unpack("version", data) + if err != nil { + return *new(uint64), err + } + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + return out0, nil +} + +// ThreeFAdapterFinalizeRequest represents a FinalizeRequest event raised by the ThreeFAdapter contract. +type ThreeFAdapterFinalizeRequest struct { + Request common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const ThreeFAdapterFinalizeRequestEventName = "FinalizeRequest" + +// ContractEventName returns the user-defined event name. +func (ThreeFAdapterFinalizeRequest) ContractEventName() string { + return ThreeFAdapterFinalizeRequestEventName +} + +// UnpackFinalizeRequestEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event FinalizeRequest(address indexed request) +func (threeFAdapter *ThreeFAdapter) UnpackFinalizeRequestEvent(log *types.Log) (*ThreeFAdapterFinalizeRequest, error) { + event := "FinalizeRequest" + if log.Topics[0] != threeFAdapter.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ThreeFAdapterFinalizeRequest) + if len(log.Data) > 0 { + if err := threeFAdapter.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range threeFAdapter.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// ThreeFAdapterInitialized represents a Initialized event raised by the ThreeFAdapter contract. +type ThreeFAdapterInitialized struct { + Version uint64 + Raw *types.Log // Blockchain specific contextual infos +} + +const ThreeFAdapterInitializedEventName = "Initialized" + +// ContractEventName returns the user-defined event name. +func (ThreeFAdapterInitialized) ContractEventName() string { + return ThreeFAdapterInitializedEventName +} + +// UnpackInitializedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event Initialized(uint64 version) +func (threeFAdapter *ThreeFAdapter) UnpackInitializedEvent(log *types.Log) (*ThreeFAdapterInitialized, error) { + event := "Initialized" + if log.Topics[0] != threeFAdapter.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ThreeFAdapterInitialized) + if len(log.Data) > 0 { + if err := threeFAdapter.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range threeFAdapter.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// ThreeFAdapterOnRequestConsumed represents a OnRequestConsumed event raised by the ThreeFAdapter contract. +type ThreeFAdapterOnRequestConsumed struct { + Request common.Address + Offer Offer + PrincipalAssets *big.Int + YieldAssets *big.Int + Raw *types.Log // Blockchain specific contextual infos +} + +const ThreeFAdapterOnRequestConsumedEventName = "OnRequestConsumed" + +// ContractEventName returns the user-defined event name. +func (ThreeFAdapterOnRequestConsumed) ContractEventName() string { + return ThreeFAdapterOnRequestConsumedEventName +} + +// UnpackOnRequestConsumedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event OnRequestConsumed(address indexed request, (address,uint256,uint256,uint256,uint256,bool) offer, uint256 principalAssets, uint256 yieldAssets) +func (threeFAdapter *ThreeFAdapter) UnpackOnRequestConsumedEvent(log *types.Log) (*ThreeFAdapterOnRequestConsumed, error) { + event := "OnRequestConsumed" + if log.Topics[0] != threeFAdapter.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ThreeFAdapterOnRequestConsumed) + if len(log.Data) > 0 { + if err := threeFAdapter.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range threeFAdapter.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// ThreeFAdapterOwnershipTransferred represents a OwnershipTransferred event raised by the ThreeFAdapter contract. +type ThreeFAdapterOwnershipTransferred struct { + PreviousOwner common.Address + NewOwner common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const ThreeFAdapterOwnershipTransferredEventName = "OwnershipTransferred" + +// ContractEventName returns the user-defined event name. +func (ThreeFAdapterOwnershipTransferred) ContractEventName() string { + return ThreeFAdapterOwnershipTransferredEventName +} + +// UnpackOwnershipTransferredEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) +func (threeFAdapter *ThreeFAdapter) UnpackOwnershipTransferredEvent(log *types.Log) (*ThreeFAdapterOwnershipTransferred, error) { + event := "OwnershipTransferred" + if log.Topics[0] != threeFAdapter.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ThreeFAdapterOwnershipTransferred) + if len(log.Data) > 0 { + if err := threeFAdapter.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range threeFAdapter.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// ThreeFAdapterSetLimitsPerRequest represents a SetLimitsPerRequest event raised by the ThreeFAdapter contract. +type ThreeFAdapterSetLimitsPerRequest struct { + MinYieldPerRequest *big.Int + MinAssetsPerRequest *big.Int + MaxAssetsPerRequest *big.Int + Raw *types.Log // Blockchain specific contextual infos +} + +const ThreeFAdapterSetLimitsPerRequestEventName = "SetLimitsPerRequest" + +// ContractEventName returns the user-defined event name. +func (ThreeFAdapterSetLimitsPerRequest) ContractEventName() string { + return ThreeFAdapterSetLimitsPerRequestEventName +} + +// UnpackSetLimitsPerRequestEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event SetLimitsPerRequest(uint256 minYieldPerRequest, uint256 minAssetsPerRequest, uint256 maxAssetsPerRequest) +func (threeFAdapter *ThreeFAdapter) UnpackSetLimitsPerRequestEvent(log *types.Log) (*ThreeFAdapterSetLimitsPerRequest, error) { + event := "SetLimitsPerRequest" + if log.Topics[0] != threeFAdapter.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ThreeFAdapterSetLimitsPerRequest) + if len(log.Data) > 0 { + if err := threeFAdapter.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range threeFAdapter.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// ThreeFAdapterSetOfferSigner represents a SetOfferSigner event raised by the ThreeFAdapter contract. +type ThreeFAdapterSetOfferSigner struct { + OfferSigner common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const ThreeFAdapterSetOfferSignerEventName = "SetOfferSigner" + +// ContractEventName returns the user-defined event name. +func (ThreeFAdapterSetOfferSigner) ContractEventName() string { + return ThreeFAdapterSetOfferSignerEventName +} + +// UnpackSetOfferSignerEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event SetOfferSigner(address indexed offerSigner) +func (threeFAdapter *ThreeFAdapter) UnpackSetOfferSignerEvent(log *types.Log) (*ThreeFAdapterSetOfferSigner, error) { + event := "SetOfferSigner" + if log.Topics[0] != threeFAdapter.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ThreeFAdapterSetOfferSigner) + if len(log.Data) > 0 { + if err := threeFAdapter.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range threeFAdapter.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// ThreeFAdapterSetVault represents a SetVault event raised by the ThreeFAdapter contract. +type ThreeFAdapterSetVault struct { + Vault common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const ThreeFAdapterSetVaultEventName = "SetVault" + +// ContractEventName returns the user-defined event name. +func (ThreeFAdapterSetVault) ContractEventName() string { + return ThreeFAdapterSetVaultEventName +} + +// UnpackSetVaultEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event SetVault(address indexed vault) +func (threeFAdapter *ThreeFAdapter) UnpackSetVaultEvent(log *types.Log) (*ThreeFAdapterSetVault, error) { + event := "SetVault" + if log.Topics[0] != threeFAdapter.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ThreeFAdapterSetVault) + if len(log.Data) > 0 { + if err := threeFAdapter.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range threeFAdapter.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// UnpackError attempts to decode the provided error data using user-defined +// error definitions. +func (threeFAdapter *ThreeFAdapter) UnpackError(raw []byte) (any, error) { + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["AlreadyInitialized"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackAlreadyInitializedError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["AlreadyRequest"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackAlreadyRequestError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["InsufficientAllocate"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackInsufficientAllocateError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["InvalidInitialization"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackInvalidInitializationError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["InvalidVault"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackInvalidVaultError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["NotFactory"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackNotFactoryError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["NotInitializing"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackNotInitializingError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["NotRequest"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackNotRequestError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["NotVault"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackNotVaultError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["OwnableInvalidOwner"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackOwnableInvalidOwnerError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["OwnableUnauthorizedAccount"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackOwnableUnauthorizedAccountError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["ReentrancyGuardReentrantCall"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackReentrancyGuardReentrantCallError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["SafeERC20FailedOperation"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackSafeERC20FailedOperationError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["TooLargeRequest"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackTooLargeRequestError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["TooLowYield"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackTooLowYieldError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["TooManyRequests"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackTooManyRequestsError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["TooSmallRequest"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackTooSmallRequestError(raw[4:]) + } + if bytes.Equal(raw[:4], threeFAdapter.abi.Errors["WrongAsset"].ID.Bytes()[:4]) { + return threeFAdapter.UnpackWrongAssetError(raw[4:]) + } + return nil, errors.New("Unknown error") +} + +// ThreeFAdapterAlreadyInitialized represents a AlreadyInitialized error raised by the ThreeFAdapter contract. +type ThreeFAdapterAlreadyInitialized struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error AlreadyInitialized() +func ThreeFAdapterAlreadyInitializedErrorID() common.Hash { + return common.HexToHash("0x0dc149f07762891dbcea3fe72770f3d63a1863fc54b2f084e8c59ec476996927") +} + +// UnpackAlreadyInitializedError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error AlreadyInitialized() +func (threeFAdapter *ThreeFAdapter) UnpackAlreadyInitializedError(raw []byte) (*ThreeFAdapterAlreadyInitialized, error) { + out := new(ThreeFAdapterAlreadyInitialized) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "AlreadyInitialized", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterAlreadyRequest represents a AlreadyRequest error raised by the ThreeFAdapter contract. +type ThreeFAdapterAlreadyRequest struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error AlreadyRequest() +func ThreeFAdapterAlreadyRequestErrorID() common.Hash { + return common.HexToHash("0x8d93e31a683f438f3632655955c279b3597d3f204f2f379582403ed351370f82") +} + +// UnpackAlreadyRequestError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error AlreadyRequest() +func (threeFAdapter *ThreeFAdapter) UnpackAlreadyRequestError(raw []byte) (*ThreeFAdapterAlreadyRequest, error) { + out := new(ThreeFAdapterAlreadyRequest) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "AlreadyRequest", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterInsufficientAllocate represents a InsufficientAllocate error raised by the ThreeFAdapter contract. +type ThreeFAdapterInsufficientAllocate struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InsufficientAllocate() +func ThreeFAdapterInsufficientAllocateErrorID() common.Hash { + return common.HexToHash("0xb128897f3cb0ff1be99d96c4772ed6c60ee2a8e88745c65f2a907980f83cad61") +} + +// UnpackInsufficientAllocateError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InsufficientAllocate() +func (threeFAdapter *ThreeFAdapter) UnpackInsufficientAllocateError(raw []byte) (*ThreeFAdapterInsufficientAllocate, error) { + out := new(ThreeFAdapterInsufficientAllocate) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "InsufficientAllocate", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterInvalidInitialization represents a InvalidInitialization error raised by the ThreeFAdapter contract. +type ThreeFAdapterInvalidInitialization struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InvalidInitialization() +func ThreeFAdapterInvalidInitializationErrorID() common.Hash { + return common.HexToHash("0xf92ee8a957075833165f68c320933b1a1294aafc84ee6e0dd3fb178008f9aaf5") +} + +// UnpackInvalidInitializationError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InvalidInitialization() +func (threeFAdapter *ThreeFAdapter) UnpackInvalidInitializationError(raw []byte) (*ThreeFAdapterInvalidInitialization, error) { + out := new(ThreeFAdapterInvalidInitialization) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "InvalidInitialization", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterInvalidVault represents a InvalidVault error raised by the ThreeFAdapter contract. +type ThreeFAdapterInvalidVault struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InvalidVault() +func ThreeFAdapterInvalidVaultErrorID() common.Hash { + return common.HexToHash("0xd03a63207f799c8b4a310cf73db481de483ce6543ef24d1f75f918a11e4eae1f") +} + +// UnpackInvalidVaultError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InvalidVault() +func (threeFAdapter *ThreeFAdapter) UnpackInvalidVaultError(raw []byte) (*ThreeFAdapterInvalidVault, error) { + out := new(ThreeFAdapterInvalidVault) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "InvalidVault", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterNotFactory represents a NotFactory error raised by the ThreeFAdapter contract. +type ThreeFAdapterNotFactory struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotFactory() +func ThreeFAdapterNotFactoryErrorID() common.Hash { + return common.HexToHash("0x32cc723614e775fc4a8386492bc9a860c12fe98d5f5f28ec17e265818645b229") +} + +// UnpackNotFactoryError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotFactory() +func (threeFAdapter *ThreeFAdapter) UnpackNotFactoryError(raw []byte) (*ThreeFAdapterNotFactory, error) { + out := new(ThreeFAdapterNotFactory) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "NotFactory", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterNotInitializing represents a NotInitializing error raised by the ThreeFAdapter contract. +type ThreeFAdapterNotInitializing struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotInitializing() +func ThreeFAdapterNotInitializingErrorID() common.Hash { + return common.HexToHash("0xd7e6bcf8597daa127dc9f0048d2f08d5ef140a2cb659feabd700beff1f7a8302") +} + +// UnpackNotInitializingError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotInitializing() +func (threeFAdapter *ThreeFAdapter) UnpackNotInitializingError(raw []byte) (*ThreeFAdapterNotInitializing, error) { + out := new(ThreeFAdapterNotInitializing) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "NotInitializing", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterNotRequest represents a NotRequest error raised by the ThreeFAdapter contract. +type ThreeFAdapterNotRequest struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotRequest() +func ThreeFAdapterNotRequestErrorID() common.Hash { + return common.HexToHash("0x2b1697af70eb58a1fa466030f88f2dd8bedf01f69018be1b04b7747be7c762c7") +} + +// UnpackNotRequestError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotRequest() +func (threeFAdapter *ThreeFAdapter) UnpackNotRequestError(raw []byte) (*ThreeFAdapterNotRequest, error) { + out := new(ThreeFAdapterNotRequest) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "NotRequest", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterNotVault represents a NotVault error raised by the ThreeFAdapter contract. +type ThreeFAdapterNotVault struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotVault() +func ThreeFAdapterNotVaultErrorID() common.Hash { + return common.HexToHash("0x62df0545b0e47f06f6a9990975121b8c49c83a96f18696393f66a69dd2ffe568") +} + +// UnpackNotVaultError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotVault() +func (threeFAdapter *ThreeFAdapter) UnpackNotVaultError(raw []byte) (*ThreeFAdapterNotVault, error) { + out := new(ThreeFAdapterNotVault) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "NotVault", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterOwnableInvalidOwner represents a OwnableInvalidOwner error raised by the ThreeFAdapter contract. +type ThreeFAdapterOwnableInvalidOwner struct { + Owner common.Address +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error OwnableInvalidOwner(address owner) +func ThreeFAdapterOwnableInvalidOwnerErrorID() common.Hash { + return common.HexToHash("0x1e4fbdf7f3ef8bcaa855599e3abf48b232380f183f08f6f813d9ffa5bd585188") +} + +// UnpackOwnableInvalidOwnerError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error OwnableInvalidOwner(address owner) +func (threeFAdapter *ThreeFAdapter) UnpackOwnableInvalidOwnerError(raw []byte) (*ThreeFAdapterOwnableInvalidOwner, error) { + out := new(ThreeFAdapterOwnableInvalidOwner) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "OwnableInvalidOwner", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterOwnableUnauthorizedAccount represents a OwnableUnauthorizedAccount error raised by the ThreeFAdapter contract. +type ThreeFAdapterOwnableUnauthorizedAccount struct { + Account common.Address +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error OwnableUnauthorizedAccount(address account) +func ThreeFAdapterOwnableUnauthorizedAccountErrorID() common.Hash { + return common.HexToHash("0x118cdaa7a341953d1887a2245fd6665d741c67c8c50581daa59e1d03373fa188") +} + +// UnpackOwnableUnauthorizedAccountError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error OwnableUnauthorizedAccount(address account) +func (threeFAdapter *ThreeFAdapter) UnpackOwnableUnauthorizedAccountError(raw []byte) (*ThreeFAdapterOwnableUnauthorizedAccount, error) { + out := new(ThreeFAdapterOwnableUnauthorizedAccount) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "OwnableUnauthorizedAccount", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterReentrancyGuardReentrantCall represents a ReentrancyGuardReentrantCall error raised by the ThreeFAdapter contract. +type ThreeFAdapterReentrancyGuardReentrantCall struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error ReentrancyGuardReentrantCall() +func ThreeFAdapterReentrancyGuardReentrantCallErrorID() common.Hash { + return common.HexToHash("0x3ee5aeb571de7fc460830b4d0017439a1ca56fb0bc39062227ade4fe4a24c1ca") +} + +// UnpackReentrancyGuardReentrantCallError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error ReentrancyGuardReentrantCall() +func (threeFAdapter *ThreeFAdapter) UnpackReentrancyGuardReentrantCallError(raw []byte) (*ThreeFAdapterReentrancyGuardReentrantCall, error) { + out := new(ThreeFAdapterReentrancyGuardReentrantCall) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "ReentrancyGuardReentrantCall", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterSafeERC20FailedOperation represents a SafeERC20FailedOperation error raised by the ThreeFAdapter contract. +type ThreeFAdapterSafeERC20FailedOperation struct { + Token common.Address +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error SafeERC20FailedOperation(address token) +func ThreeFAdapterSafeERC20FailedOperationErrorID() common.Hash { + return common.HexToHash("0x5274afe73c98b4749fc91ffae6b7b574e7842cb2144a159e9377a5f20b32edf9") +} + +// UnpackSafeERC20FailedOperationError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error SafeERC20FailedOperation(address token) +func (threeFAdapter *ThreeFAdapter) UnpackSafeERC20FailedOperationError(raw []byte) (*ThreeFAdapterSafeERC20FailedOperation, error) { + out := new(ThreeFAdapterSafeERC20FailedOperation) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "SafeERC20FailedOperation", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterTooLargeRequest represents a TooLargeRequest error raised by the ThreeFAdapter contract. +type ThreeFAdapterTooLargeRequest struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error TooLargeRequest() +func ThreeFAdapterTooLargeRequestErrorID() common.Hash { + return common.HexToHash("0xd67cf587430cfcee33da8a888aff2db6f5f4b9a07d9bbd7bd784b7f9c8c59ab2") +} + +// UnpackTooLargeRequestError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error TooLargeRequest() +func (threeFAdapter *ThreeFAdapter) UnpackTooLargeRequestError(raw []byte) (*ThreeFAdapterTooLargeRequest, error) { + out := new(ThreeFAdapterTooLargeRequest) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "TooLargeRequest", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterTooLowYield represents a TooLowYield error raised by the ThreeFAdapter contract. +type ThreeFAdapterTooLowYield struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error TooLowYield() +func ThreeFAdapterTooLowYieldErrorID() common.Hash { + return common.HexToHash("0xec84af7bf6cfbe9482148973e3ddd1942a9f2808b5f046694f7cfe46aa2ce953") +} + +// UnpackTooLowYieldError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error TooLowYield() +func (threeFAdapter *ThreeFAdapter) UnpackTooLowYieldError(raw []byte) (*ThreeFAdapterTooLowYield, error) { + out := new(ThreeFAdapterTooLowYield) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "TooLowYield", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterTooManyRequests represents a TooManyRequests error raised by the ThreeFAdapter contract. +type ThreeFAdapterTooManyRequests struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error TooManyRequests() +func ThreeFAdapterTooManyRequestsErrorID() common.Hash { + return common.HexToHash("0x056d63471330a57f6c0d5cc835e9e9c3948af33484f6a6e592a2e3b11a42f713") +} + +// UnpackTooManyRequestsError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error TooManyRequests() +func (threeFAdapter *ThreeFAdapter) UnpackTooManyRequestsError(raw []byte) (*ThreeFAdapterTooManyRequests, error) { + out := new(ThreeFAdapterTooManyRequests) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "TooManyRequests", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterTooSmallRequest represents a TooSmallRequest error raised by the ThreeFAdapter contract. +type ThreeFAdapterTooSmallRequest struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error TooSmallRequest() +func ThreeFAdapterTooSmallRequestErrorID() common.Hash { + return common.HexToHash("0x81b8a5cdb66b9b21248015d7ceda95a251199c135eabf6392fb671dcfa81ea3f") +} + +// UnpackTooSmallRequestError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error TooSmallRequest() +func (threeFAdapter *ThreeFAdapter) UnpackTooSmallRequestError(raw []byte) (*ThreeFAdapterTooSmallRequest, error) { + out := new(ThreeFAdapterTooSmallRequest) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "TooSmallRequest", raw); err != nil { + return nil, err + } + return out, nil +} + +// ThreeFAdapterWrongAsset represents a WrongAsset error raised by the ThreeFAdapter contract. +type ThreeFAdapterWrongAsset struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error WrongAsset() +func ThreeFAdapterWrongAssetErrorID() common.Hash { + return common.HexToHash("0xf170c67fbef37d60daa2c8494fe22631cd135dc228bcc58a8c645c15992ea504") +} + +// UnpackWrongAssetError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error WrongAsset() +func (threeFAdapter *ThreeFAdapter) UnpackWrongAssetError(raw []byte) (*ThreeFAdapterWrongAsset, error) { + out := new(ThreeFAdapterWrongAsset) + if err := threeFAdapter.abi.UnpackIntoInterface(out, "WrongAsset", raw); err != nil { + return nil, err + } + return out, nil +} diff --git a/api/bindings/erc20/ERC20.go b/api/bindings/erc20/ERC20.go new file mode 100644 index 00000000..ff1d66a5 --- /dev/null +++ b/api/bindings/erc20/ERC20.go @@ -0,0 +1,86 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package erc20 + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// ERC20MetaData contains all meta data concerning the ERC20 contract. +var ERC20MetaData = bind.MetaData{ + ABI: "[{\"inputs\":[],\"name\":\"decimals\",\"outputs\":[{\"internalType\":\"uint8\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ID: "ERC20", +} + +// ERC20 is an auto generated Go binding around an Ethereum contract. +type ERC20 struct { + abi abi.ABI +} + +// NewERC20 creates a new instance of ERC20. +func NewERC20() *ERC20 { + parsed, err := ERC20MetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &ERC20{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *ERC20) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackDecimals is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x313ce567. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function decimals() view returns(uint8) +func (eRC20 *ERC20) PackDecimals() []byte { + enc, err := eRC20.abi.Pack("decimals") + if err != nil { + panic(err) + } + return enc +} + +// TryPackDecimals is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x313ce567. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function decimals() view returns(uint8) +func (eRC20 *ERC20) TryPackDecimals() ([]byte, error) { + return eRC20.abi.Pack("decimals") +} + +// UnpackDecimals is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (eRC20 *ERC20) UnpackDecimals(data []byte) (uint8, error) { + out, err := eRC20.abi.Unpack("decimals", data) + if err != nil { + return *new(uint8), err + } + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + return out0, nil +} diff --git a/api/bindings/rfq/adapter/LiquidLaneAdapter.go b/api/bindings/liquidlane/adapter/LiquidLaneAdapter.go similarity index 100% rename from api/bindings/rfq/adapter/LiquidLaneAdapter.go rename to api/bindings/liquidlane/adapter/LiquidLaneAdapter.go diff --git a/api/bindings/multicall3/Multicall3.go b/api/bindings/multicall3/Multicall3.go index 387e4e66..57d31faf 100644 --- a/api/bindings/multicall3/Multicall3.go +++ b/api/bindings/multicall3/Multicall3.go @@ -1,31 +1,26 @@ -// Code generated - DO NOT EDIT. +// Code generated via abigen V2 - DO NOT EDIT. // This file is a generated binding and any manual changes will be lost. package multicall3 import ( + "bytes" "errors" "math/big" - "strings" - ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/event" ) // Reference imports to suppress errors if they are not otherwise used. var ( + _ = bytes.Equal _ = errors.New _ = big.NewInt - _ = strings.NewReader - _ = ethereum.NotFound - _ = bind.Bind _ = common.Big1 _ = types.BloomLookup - _ = event.NewSubscription _ = abi.ConvertType ) @@ -43,183 +38,62 @@ type Multicall3Result struct { } // Multicall3MetaData contains all meta data concerning the Multicall3 contract. -var Multicall3MetaData = &bind.MetaData{ +var Multicall3MetaData = bind.MetaData{ ABI: "[{\"inputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"target\",\"type\":\"address\"},{\"internalType\":\"bool\",\"name\":\"allowFailure\",\"type\":\"bool\"},{\"internalType\":\"bytes\",\"name\":\"callData\",\"type\":\"bytes\"}],\"internalType\":\"structMulticall3.Call3[]\",\"name\":\"calls\",\"type\":\"tuple[]\"}],\"name\":\"aggregate3\",\"outputs\":[{\"components\":[{\"internalType\":\"bool\",\"name\":\"success\",\"type\":\"bool\"},{\"internalType\":\"bytes\",\"name\":\"returnData\",\"type\":\"bytes\"}],\"internalType\":\"structMulticall3.Result[]\",\"name\":\"returnData\",\"type\":\"tuple[]\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ID: "Multicall3", } -// Multicall3ABI is the input ABI used to generate the binding from. -// Deprecated: Use Multicall3MetaData.ABI instead. -var Multicall3ABI = Multicall3MetaData.ABI - // Multicall3 is an auto generated Go binding around an Ethereum contract. type Multicall3 struct { - Multicall3Caller // Read-only binding to the contract - Multicall3Transactor // Write-only binding to the contract - Multicall3Filterer // Log filterer for contract events -} - -// Multicall3Caller is an auto generated read-only Go binding around an Ethereum contract. -type Multicall3Caller struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// Multicall3Transactor is an auto generated write-only Go binding around an Ethereum contract. -type Multicall3Transactor struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// Multicall3Filterer is an auto generated log filtering Go binding around an Ethereum contract events. -type Multicall3Filterer struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// Multicall3Session is an auto generated Go binding around an Ethereum contract, -// with pre-set call and transact options. -type Multicall3Session struct { - Contract *Multicall3 // Generic contract binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// Multicall3CallerSession is an auto generated read-only Go binding around an Ethereum contract, -// with pre-set call options. -type Multicall3CallerSession struct { - Contract *Multicall3Caller // Generic contract caller binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session -} - -// Multicall3TransactorSession is an auto generated write-only Go binding around an Ethereum contract, -// with pre-set transact options. -type Multicall3TransactorSession struct { - Contract *Multicall3Transactor // Generic contract transactor binding to set the session for - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// Multicall3Raw is an auto generated low-level Go binding around an Ethereum contract. -type Multicall3Raw struct { - Contract *Multicall3 // Generic contract binding to access the raw methods on -} - -// Multicall3CallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. -type Multicall3CallerRaw struct { - Contract *Multicall3Caller // Generic read-only contract binding to access the raw methods on -} - -// Multicall3TransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. -type Multicall3TransactorRaw struct { - Contract *Multicall3Transactor // Generic write-only contract binding to access the raw methods on -} - -// NewMulticall3 creates a new instance of Multicall3, bound to a specific deployed contract. -func NewMulticall3(address common.Address, backend bind.ContractBackend) (*Multicall3, error) { - contract, err := bindMulticall3(address, backend, backend, backend) - if err != nil { - return nil, err - } - return &Multicall3{Multicall3Caller: Multicall3Caller{contract: contract}, Multicall3Transactor: Multicall3Transactor{contract: contract}, Multicall3Filterer: Multicall3Filterer{contract: contract}}, nil + abi abi.ABI } -// NewMulticall3Caller creates a new read-only instance of Multicall3, bound to a specific deployed contract. -func NewMulticall3Caller(address common.Address, caller bind.ContractCaller) (*Multicall3Caller, error) { - contract, err := bindMulticall3(address, caller, nil, nil) +// NewMulticall3 creates a new instance of Multicall3. +func NewMulticall3() *Multicall3 { + parsed, err := Multicall3MetaData.ParseABI() if err != nil { - return nil, err + panic(errors.New("invalid ABI: " + err.Error())) } - return &Multicall3Caller{contract: contract}, nil + return &Multicall3{abi: *parsed} } -// NewMulticall3Transactor creates a new write-only instance of Multicall3, bound to a specific deployed contract. -func NewMulticall3Transactor(address common.Address, transactor bind.ContractTransactor) (*Multicall3Transactor, error) { - contract, err := bindMulticall3(address, nil, transactor, nil) - if err != nil { - return nil, err - } - return &Multicall3Transactor{contract: contract}, nil +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *Multicall3) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) } -// NewMulticall3Filterer creates a new log filterer instance of Multicall3, bound to a specific deployed contract. -func NewMulticall3Filterer(address common.Address, filterer bind.ContractFilterer) (*Multicall3Filterer, error) { - contract, err := bindMulticall3(address, nil, nil, filterer) - if err != nil { - return nil, err - } - return &Multicall3Filterer{contract: contract}, nil -} - -// bindMulticall3 binds a generic wrapper to an already deployed contract. -func bindMulticall3(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := Multicall3MetaData.GetAbi() - if err != nil { - return nil, err - } - return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_Multicall3 *Multicall3Raw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _Multicall3.Contract.Multicall3Caller.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_Multicall3 *Multicall3Raw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _Multicall3.Contract.Multicall3Transactor.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_Multicall3 *Multicall3Raw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _Multicall3.Contract.Multicall3Transactor.contract.Transact(opts, method, params...) -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_Multicall3 *Multicall3CallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _Multicall3.Contract.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_Multicall3 *Multicall3TransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _Multicall3.Contract.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_Multicall3 *Multicall3TransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _Multicall3.Contract.contract.Transact(opts, method, params...) -} - -// Aggregate3 is a free data retrieval call binding the contract method 0x82ad56cb. +// PackAggregate3 is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x82ad56cb. This method will panic if any +// invalid/nil inputs are passed. // // Solidity: function aggregate3((address,bool,bytes)[] calls) view returns((bool,bytes)[] returnData) -func (_Multicall3 *Multicall3Caller) Aggregate3(opts *bind.CallOpts, calls []Multicall3Call3) ([]Multicall3Result, error) { - var out []interface{} - err := _Multicall3.contract.Call(opts, &out, "aggregate3", calls) - +func (multicall3 *Multicall3) PackAggregate3(calls []Multicall3Call3) []byte { + enc, err := multicall3.abi.Pack("aggregate3", calls) if err != nil { - return *new([]Multicall3Result), err + panic(err) } - - out0 := *abi.ConvertType(out[0], new([]Multicall3Result)).(*[]Multicall3Result) - - return out0, err - + return enc } -// Aggregate3 is a free data retrieval call binding the contract method 0x82ad56cb. +// TryPackAggregate3 is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x82ad56cb. This method will return an error +// if any inputs are invalid/nil. // // Solidity: function aggregate3((address,bool,bytes)[] calls) view returns((bool,bytes)[] returnData) -func (_Multicall3 *Multicall3Session) Aggregate3(calls []Multicall3Call3) ([]Multicall3Result, error) { - return _Multicall3.Contract.Aggregate3(&_Multicall3.CallOpts, calls) +func (multicall3 *Multicall3) TryPackAggregate3(calls []Multicall3Call3) ([]byte, error) { + return multicall3.abi.Pack("aggregate3", calls) } -// Aggregate3 is a free data retrieval call binding the contract method 0x82ad56cb. +// UnpackAggregate3 is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x82ad56cb. // // Solidity: function aggregate3((address,bool,bytes)[] calls) view returns((bool,bytes)[] returnData) -func (_Multicall3 *Multicall3CallerSession) Aggregate3(calls []Multicall3Call3) ([]Multicall3Result, error) { - return _Multicall3.Contract.Aggregate3(&_Multicall3.CallOpts, calls) +func (multicall3 *Multicall3) UnpackAggregate3(data []byte) ([]Multicall3Result, error) { + out, err := multicall3.abi.Unpack("aggregate3", data) + if err != nil { + return *new([]Multicall3Result), err + } + out0 := *abi.ConvertType(out[0], new([]Multicall3Result)).(*[]Multicall3Result) + return out0, nil } diff --git a/api/bindings/oev/aggregator/AggregatorV3.go b/api/bindings/oev/aggregator/AggregatorV3.go new file mode 100644 index 00000000..0e5dc6b3 --- /dev/null +++ b/api/bindings/oev/aggregator/AggregatorV3.go @@ -0,0 +1,136 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package aggregator + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// AggregatorV3MetaData contains all meta data concerning the AggregatorV3 contract. +var AggregatorV3MetaData = bind.MetaData{ + ABI: "[{\"inputs\":[],\"name\":\"latestRoundData\",\"outputs\":[{\"name\":\"roundId\",\"type\":\"uint80\"},{\"name\":\"answer\",\"type\":\"int256\"},{\"name\":\"startedAt\",\"type\":\"uint256\"},{\"name\":\"updatedAt\",\"type\":\"uint256\"},{\"name\":\"answeredInRound\",\"type\":\"uint80\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"decimals\",\"outputs\":[{\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ID: "AggregatorV3", +} + +// AggregatorV3 is an auto generated Go binding around an Ethereum contract. +type AggregatorV3 struct { + abi abi.ABI +} + +// NewAggregatorV3 creates a new instance of AggregatorV3. +func NewAggregatorV3() *AggregatorV3 { + parsed, err := AggregatorV3MetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &AggregatorV3{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *AggregatorV3) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackDecimals is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x313ce567. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function decimals() view returns(uint8) +func (aggregatorV3 *AggregatorV3) PackDecimals() []byte { + enc, err := aggregatorV3.abi.Pack("decimals") + if err != nil { + panic(err) + } + return enc +} + +// TryPackDecimals is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x313ce567. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function decimals() view returns(uint8) +func (aggregatorV3 *AggregatorV3) TryPackDecimals() ([]byte, error) { + return aggregatorV3.abi.Pack("decimals") +} + +// UnpackDecimals is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (aggregatorV3 *AggregatorV3) UnpackDecimals(data []byte) (uint8, error) { + out, err := aggregatorV3.abi.Unpack("decimals", data) + if err != nil { + return *new(uint8), err + } + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + return out0, nil +} + +// PackLatestRoundData is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xfeaf968c. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function latestRoundData() view returns(uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) +func (aggregatorV3 *AggregatorV3) PackLatestRoundData() []byte { + enc, err := aggregatorV3.abi.Pack("latestRoundData") + if err != nil { + panic(err) + } + return enc +} + +// TryPackLatestRoundData is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xfeaf968c. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function latestRoundData() view returns(uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) +func (aggregatorV3 *AggregatorV3) TryPackLatestRoundData() ([]byte, error) { + return aggregatorV3.abi.Pack("latestRoundData") +} + +// LatestRoundDataOutput serves as a container for the return parameters of contract +// method LatestRoundData. +type LatestRoundDataOutput struct { + RoundId *big.Int + Answer *big.Int + StartedAt *big.Int + UpdatedAt *big.Int + AnsweredInRound *big.Int +} + +// UnpackLatestRoundData is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xfeaf968c. +// +// Solidity: function latestRoundData() view returns(uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) +func (aggregatorV3 *AggregatorV3) UnpackLatestRoundData(data []byte) (LatestRoundDataOutput, error) { + out, err := aggregatorV3.abi.Unpack("latestRoundData", data) + outstruct := new(LatestRoundDataOutput) + if err != nil { + return *outstruct, err + } + outstruct.RoundId = abi.ConvertType(out[0], new(big.Int)).(*big.Int) + outstruct.Answer = abi.ConvertType(out[1], new(big.Int)).(*big.Int) + outstruct.StartedAt = abi.ConvertType(out[2], new(big.Int)).(*big.Int) + outstruct.UpdatedAt = abi.ConvertType(out[3], new(big.Int)).(*big.Int) + outstruct.AnsweredInRound = abi.ConvertType(out[4], new(big.Int)).(*big.Int) + return *outstruct, nil +} diff --git a/api/bindings/oev/callback/SymbioticOevSolver.go b/api/bindings/oev/callback/SymbioticOevSolver.go new file mode 100644 index 00000000..bd13c01b --- /dev/null +++ b/api/bindings/oev/callback/SymbioticOevSolver.go @@ -0,0 +1,956 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package callback + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// SymbioticOevSolverMetaData contains all meta data concerning the SymbioticOevSolver contract. +var SymbioticOevSolverMetaData = bind.MetaData{ + ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"executor\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"morpho\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"liquidLaneAdapter\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"authSigner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"initialOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"receive\",\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"AUTH_SIGNER\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"EXECUTOR\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"LIQUID_LANE_ADAPTER\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"MORPHO\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"liquidate\",\"inputs\":[{\"name\":\"bidAmount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"operationData\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"onMorphoLiquidate\",\"inputs\":[{\"name\":\"repaidAssets\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"payBid\",\"inputs\":[{\"name\":\"bidAmount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"usedAuctionKey\",\"inputs\":[{\"name\":\"auctionKey\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"used\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"withdrawERC20\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"withdrawNative\",\"inputs\":[{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"BundleResult\",\"inputs\":[{\"name\":\"auctionKey\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"totalProfitLoan\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"minProfitLoan\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"gasUsed\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"bidAuthorized\",\"type\":\"bool\",\"indexed\":false,\"internalType\":\"bool\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"LegResult\",\"inputs\":[{\"name\":\"auctionKey\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"marketId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"Id\"},{\"name\":\"borrower\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"code\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"seizedAssets\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"repaidAssets\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"profitLoan\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"gasUsed\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OwnerUpdated\",\"inputs\":[{\"name\":\"previous\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"next\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"PayBidResult\",\"inputs\":[{\"name\":\"auctionKey\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"bidAmount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"paid\",\"type\":\"bool\",\"indexed\":false,\"internalType\":\"bool\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"ECDSAInvalidSignature\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ECDSAInvalidSignatureLength\",\"inputs\":[{\"name\":\"length\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"ECDSAInvalidSignatureS\",\"inputs\":[{\"name\":\"s\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"InsufficientLoanProceeds\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidAuth\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotExecutor\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotMorpho\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotOwner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ProfitBelowMin\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ReentrancyGuardReentrantCall\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SafeERC20FailedOperation\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"SwapOutputBelowMin\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"TransferFailed\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ZeroAddress\",\"inputs\":[]}]", + ID: "SymbioticOevSolver", +} + +// SymbioticOevSolver is an auto generated Go binding around an Ethereum contract. +type SymbioticOevSolver struct { + abi abi.ABI +} + +// NewSymbioticOevSolver creates a new instance of SymbioticOevSolver. +func NewSymbioticOevSolver() *SymbioticOevSolver { + parsed, err := SymbioticOevSolverMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &SymbioticOevSolver{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *SymbioticOevSolver) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackConstructor is the Go binding used to pack the parameters required for +// contract deployment. +// +// Solidity: constructor(address executor, address morpho, address liquidLaneAdapter, address authSigner, address initialOwner) returns() +func (symbioticOevSolver *SymbioticOevSolver) PackConstructor(executor common.Address, morpho common.Address, liquidLaneAdapter common.Address, authSigner common.Address, initialOwner common.Address) []byte { + enc, err := symbioticOevSolver.abi.Pack("", executor, morpho, liquidLaneAdapter, authSigner, initialOwner) + if err != nil { + panic(err) + } + return enc +} + +// PackAUTHSIGNER is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x0a5c9024. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function AUTH_SIGNER() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) PackAUTHSIGNER() []byte { + enc, err := symbioticOevSolver.abi.Pack("AUTH_SIGNER") + if err != nil { + panic(err) + } + return enc +} + +// TryPackAUTHSIGNER is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x0a5c9024. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function AUTH_SIGNER() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) TryPackAUTHSIGNER() ([]byte, error) { + return symbioticOevSolver.abi.Pack("AUTH_SIGNER") +} + +// UnpackAUTHSIGNER is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x0a5c9024. +// +// Solidity: function AUTH_SIGNER() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) UnpackAUTHSIGNER(data []byte) (common.Address, error) { + out, err := symbioticOevSolver.abi.Unpack("AUTH_SIGNER", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackEXECUTOR is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x630dc7cb. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function EXECUTOR() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) PackEXECUTOR() []byte { + enc, err := symbioticOevSolver.abi.Pack("EXECUTOR") + if err != nil { + panic(err) + } + return enc +} + +// TryPackEXECUTOR is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x630dc7cb. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function EXECUTOR() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) TryPackEXECUTOR() ([]byte, error) { + return symbioticOevSolver.abi.Pack("EXECUTOR") +} + +// UnpackEXECUTOR is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x630dc7cb. +// +// Solidity: function EXECUTOR() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) UnpackEXECUTOR(data []byte) (common.Address, error) { + out, err := symbioticOevSolver.abi.Unpack("EXECUTOR", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackLIQUIDLANEADAPTER is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x86e7c9d0. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function LIQUID_LANE_ADAPTER() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) PackLIQUIDLANEADAPTER() []byte { + enc, err := symbioticOevSolver.abi.Pack("LIQUID_LANE_ADAPTER") + if err != nil { + panic(err) + } + return enc +} + +// TryPackLIQUIDLANEADAPTER is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x86e7c9d0. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function LIQUID_LANE_ADAPTER() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) TryPackLIQUIDLANEADAPTER() ([]byte, error) { + return symbioticOevSolver.abi.Pack("LIQUID_LANE_ADAPTER") +} + +// UnpackLIQUIDLANEADAPTER is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x86e7c9d0. +// +// Solidity: function LIQUID_LANE_ADAPTER() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) UnpackLIQUIDLANEADAPTER(data []byte) (common.Address, error) { + out, err := symbioticOevSolver.abi.Unpack("LIQUID_LANE_ADAPTER", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackMORPHO is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x3acb5624. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function MORPHO() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) PackMORPHO() []byte { + enc, err := symbioticOevSolver.abi.Pack("MORPHO") + if err != nil { + panic(err) + } + return enc +} + +// TryPackMORPHO is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x3acb5624. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function MORPHO() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) TryPackMORPHO() ([]byte, error) { + return symbioticOevSolver.abi.Pack("MORPHO") +} + +// UnpackMORPHO is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x3acb5624. +// +// Solidity: function MORPHO() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) UnpackMORPHO(data []byte) (common.Address, error) { + out, err := symbioticOevSolver.abi.Unpack("MORPHO", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackLiquidate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x7ebcdf30. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function liquidate(uint256 bidAmount, address , bytes operationData) returns() +func (symbioticOevSolver *SymbioticOevSolver) PackLiquidate(bidAmount *big.Int, arg1 common.Address, operationData []byte) []byte { + enc, err := symbioticOevSolver.abi.Pack("liquidate", bidAmount, arg1, operationData) + if err != nil { + panic(err) + } + return enc +} + +// TryPackLiquidate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x7ebcdf30. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function liquidate(uint256 bidAmount, address , bytes operationData) returns() +func (symbioticOevSolver *SymbioticOevSolver) TryPackLiquidate(bidAmount *big.Int, arg1 common.Address, operationData []byte) ([]byte, error) { + return symbioticOevSolver.abi.Pack("liquidate", bidAmount, arg1, operationData) +} + +// PackOnMorphoLiquidate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xcf7ea196. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function onMorphoLiquidate(uint256 repaidAssets, bytes data) returns() +func (symbioticOevSolver *SymbioticOevSolver) PackOnMorphoLiquidate(repaidAssets *big.Int, data []byte) []byte { + enc, err := symbioticOevSolver.abi.Pack("onMorphoLiquidate", repaidAssets, data) + if err != nil { + panic(err) + } + return enc +} + +// TryPackOnMorphoLiquidate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xcf7ea196. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function onMorphoLiquidate(uint256 repaidAssets, bytes data) returns() +func (symbioticOevSolver *SymbioticOevSolver) TryPackOnMorphoLiquidate(repaidAssets *big.Int, data []byte) ([]byte, error) { + return symbioticOevSolver.abi.Pack("onMorphoLiquidate", repaidAssets, data) +} + +// PackOwner is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8da5cb5b. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function owner() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) PackOwner() []byte { + enc, err := symbioticOevSolver.abi.Pack("owner") + if err != nil { + panic(err) + } + return enc +} + +// TryPackOwner is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8da5cb5b. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function owner() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) TryPackOwner() ([]byte, error) { + return symbioticOevSolver.abi.Pack("owner") +} + +// UnpackOwner is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x8da5cb5b. +// +// Solidity: function owner() view returns(address) +func (symbioticOevSolver *SymbioticOevSolver) UnpackOwner(data []byte) (common.Address, error) { + out, err := symbioticOevSolver.abi.Unpack("owner", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackPayBid is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x1e1769ed. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function payBid(uint256 bidAmount) returns() +func (symbioticOevSolver *SymbioticOevSolver) PackPayBid(bidAmount *big.Int) []byte { + enc, err := symbioticOevSolver.abi.Pack("payBid", bidAmount) + if err != nil { + panic(err) + } + return enc +} + +// TryPackPayBid is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x1e1769ed. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function payBid(uint256 bidAmount) returns() +func (symbioticOevSolver *SymbioticOevSolver) TryPackPayBid(bidAmount *big.Int) ([]byte, error) { + return symbioticOevSolver.abi.Pack("payBid", bidAmount) +} + +// PackTransferOwnership is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf2fde38b. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function transferOwnership(address newOwner) returns() +func (symbioticOevSolver *SymbioticOevSolver) PackTransferOwnership(newOwner common.Address) []byte { + enc, err := symbioticOevSolver.abi.Pack("transferOwnership", newOwner) + if err != nil { + panic(err) + } + return enc +} + +// TryPackTransferOwnership is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf2fde38b. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function transferOwnership(address newOwner) returns() +func (symbioticOevSolver *SymbioticOevSolver) TryPackTransferOwnership(newOwner common.Address) ([]byte, error) { + return symbioticOevSolver.abi.Pack("transferOwnership", newOwner) +} + +// PackUsedAuctionKey is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x0f9e1b51. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function usedAuctionKey(bytes32 auctionKey) view returns(bool used) +func (symbioticOevSolver *SymbioticOevSolver) PackUsedAuctionKey(auctionKey [32]byte) []byte { + enc, err := symbioticOevSolver.abi.Pack("usedAuctionKey", auctionKey) + if err != nil { + panic(err) + } + return enc +} + +// TryPackUsedAuctionKey is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x0f9e1b51. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function usedAuctionKey(bytes32 auctionKey) view returns(bool used) +func (symbioticOevSolver *SymbioticOevSolver) TryPackUsedAuctionKey(auctionKey [32]byte) ([]byte, error) { + return symbioticOevSolver.abi.Pack("usedAuctionKey", auctionKey) +} + +// UnpackUsedAuctionKey is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x0f9e1b51. +// +// Solidity: function usedAuctionKey(bytes32 auctionKey) view returns(bool used) +func (symbioticOevSolver *SymbioticOevSolver) UnpackUsedAuctionKey(data []byte) (bool, error) { + out, err := symbioticOevSolver.abi.Unpack("usedAuctionKey", data) + if err != nil { + return *new(bool), err + } + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + return out0, nil +} + +// PackWithdrawERC20 is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x44004cc1. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function withdrawERC20(address token, address to, uint256 amount) returns() +func (symbioticOevSolver *SymbioticOevSolver) PackWithdrawERC20(token common.Address, to common.Address, amount *big.Int) []byte { + enc, err := symbioticOevSolver.abi.Pack("withdrawERC20", token, to, amount) + if err != nil { + panic(err) + } + return enc +} + +// TryPackWithdrawERC20 is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x44004cc1. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function withdrawERC20(address token, address to, uint256 amount) returns() +func (symbioticOevSolver *SymbioticOevSolver) TryPackWithdrawERC20(token common.Address, to common.Address, amount *big.Int) ([]byte, error) { + return symbioticOevSolver.abi.Pack("withdrawERC20", token, to, amount) +} + +// PackWithdrawNative is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x07b18bde. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function withdrawNative(address to, uint256 amount) returns() +func (symbioticOevSolver *SymbioticOevSolver) PackWithdrawNative(to common.Address, amount *big.Int) []byte { + enc, err := symbioticOevSolver.abi.Pack("withdrawNative", to, amount) + if err != nil { + panic(err) + } + return enc +} + +// TryPackWithdrawNative is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x07b18bde. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function withdrawNative(address to, uint256 amount) returns() +func (symbioticOevSolver *SymbioticOevSolver) TryPackWithdrawNative(to common.Address, amount *big.Int) ([]byte, error) { + return symbioticOevSolver.abi.Pack("withdrawNative", to, amount) +} + +// SymbioticOevSolverBundleResult represents a BundleResult event raised by the SymbioticOevSolver contract. +type SymbioticOevSolverBundleResult struct { + AuctionKey [32]byte + TotalProfitLoan *big.Int + MinProfitLoan *big.Int + GasUsed *big.Int + BidAuthorized bool + Raw *types.Log // Blockchain specific contextual infos +} + +const SymbioticOevSolverBundleResultEventName = "BundleResult" + +// ContractEventName returns the user-defined event name. +func (SymbioticOevSolverBundleResult) ContractEventName() string { + return SymbioticOevSolverBundleResultEventName +} + +// UnpackBundleResultEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event BundleResult(bytes32 indexed auctionKey, uint256 totalProfitLoan, uint256 minProfitLoan, uint256 gasUsed, bool bidAuthorized) +func (symbioticOevSolver *SymbioticOevSolver) UnpackBundleResultEvent(log *types.Log) (*SymbioticOevSolverBundleResult, error) { + event := "BundleResult" + if log.Topics[0] != symbioticOevSolver.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(SymbioticOevSolverBundleResult) + if len(log.Data) > 0 { + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range symbioticOevSolver.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// SymbioticOevSolverLegResult represents a LegResult event raised by the SymbioticOevSolver contract. +type SymbioticOevSolverLegResult struct { + AuctionKey [32]byte + MarketId [32]byte + Borrower common.Address + Code *big.Int + SeizedAssets *big.Int + RepaidAssets *big.Int + ProfitLoan *big.Int + GasUsed *big.Int + Raw *types.Log // Blockchain specific contextual infos +} + +const SymbioticOevSolverLegResultEventName = "LegResult" + +// ContractEventName returns the user-defined event name. +func (SymbioticOevSolverLegResult) ContractEventName() string { + return SymbioticOevSolverLegResultEventName +} + +// UnpackLegResultEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event LegResult(bytes32 indexed auctionKey, bytes32 indexed marketId, address indexed borrower, uint256 code, uint256 seizedAssets, uint256 repaidAssets, uint256 profitLoan, uint256 gasUsed) +func (symbioticOevSolver *SymbioticOevSolver) UnpackLegResultEvent(log *types.Log) (*SymbioticOevSolverLegResult, error) { + event := "LegResult" + if log.Topics[0] != symbioticOevSolver.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(SymbioticOevSolverLegResult) + if len(log.Data) > 0 { + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range symbioticOevSolver.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// SymbioticOevSolverOwnerUpdated represents a OwnerUpdated event raised by the SymbioticOevSolver contract. +type SymbioticOevSolverOwnerUpdated struct { + Previous common.Address + Next common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const SymbioticOevSolverOwnerUpdatedEventName = "OwnerUpdated" + +// ContractEventName returns the user-defined event name. +func (SymbioticOevSolverOwnerUpdated) ContractEventName() string { + return SymbioticOevSolverOwnerUpdatedEventName +} + +// UnpackOwnerUpdatedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event OwnerUpdated(address indexed previous, address indexed next) +func (symbioticOevSolver *SymbioticOevSolver) UnpackOwnerUpdatedEvent(log *types.Log) (*SymbioticOevSolverOwnerUpdated, error) { + event := "OwnerUpdated" + if log.Topics[0] != symbioticOevSolver.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(SymbioticOevSolverOwnerUpdated) + if len(log.Data) > 0 { + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range symbioticOevSolver.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// SymbioticOevSolverPayBidResult represents a PayBidResult event raised by the SymbioticOevSolver contract. +type SymbioticOevSolverPayBidResult struct { + AuctionKey [32]byte + BidAmount *big.Int + Paid bool + Raw *types.Log // Blockchain specific contextual infos +} + +const SymbioticOevSolverPayBidResultEventName = "PayBidResult" + +// ContractEventName returns the user-defined event name. +func (SymbioticOevSolverPayBidResult) ContractEventName() string { + return SymbioticOevSolverPayBidResultEventName +} + +// UnpackPayBidResultEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event PayBidResult(bytes32 indexed auctionKey, uint256 bidAmount, bool paid) +func (symbioticOevSolver *SymbioticOevSolver) UnpackPayBidResultEvent(log *types.Log) (*SymbioticOevSolverPayBidResult, error) { + event := "PayBidResult" + if log.Topics[0] != symbioticOevSolver.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(SymbioticOevSolverPayBidResult) + if len(log.Data) > 0 { + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range symbioticOevSolver.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} + +// UnpackError attempts to decode the provided error data using user-defined +// error definitions. +func (symbioticOevSolver *SymbioticOevSolver) UnpackError(raw []byte) (any, error) { + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["ECDSAInvalidSignature"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackECDSAInvalidSignatureError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["ECDSAInvalidSignatureLength"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackECDSAInvalidSignatureLengthError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["ECDSAInvalidSignatureS"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackECDSAInvalidSignatureSError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["InsufficientLoanProceeds"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackInsufficientLoanProceedsError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["InvalidAuth"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackInvalidAuthError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["NotExecutor"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackNotExecutorError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["NotMorpho"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackNotMorphoError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["NotOwner"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackNotOwnerError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["ProfitBelowMin"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackProfitBelowMinError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["ReentrancyGuardReentrantCall"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackReentrancyGuardReentrantCallError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["SafeERC20FailedOperation"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackSafeERC20FailedOperationError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["SwapOutputBelowMin"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackSwapOutputBelowMinError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["TransferFailed"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackTransferFailedError(raw[4:]) + } + if bytes.Equal(raw[:4], symbioticOevSolver.abi.Errors["ZeroAddress"].ID.Bytes()[:4]) { + return symbioticOevSolver.UnpackZeroAddressError(raw[4:]) + } + return nil, errors.New("Unknown error") +} + +// SymbioticOevSolverECDSAInvalidSignature represents a ECDSAInvalidSignature error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverECDSAInvalidSignature struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error ECDSAInvalidSignature() +func SymbioticOevSolverECDSAInvalidSignatureErrorID() common.Hash { + return common.HexToHash("0xf645eedf0193584640b6b90cb9477e4c95b98636c148a891d4c0a146dc46e75a") +} + +// UnpackECDSAInvalidSignatureError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error ECDSAInvalidSignature() +func (symbioticOevSolver *SymbioticOevSolver) UnpackECDSAInvalidSignatureError(raw []byte) (*SymbioticOevSolverECDSAInvalidSignature, error) { + out := new(SymbioticOevSolverECDSAInvalidSignature) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "ECDSAInvalidSignature", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverECDSAInvalidSignatureLength represents a ECDSAInvalidSignatureLength error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverECDSAInvalidSignatureLength struct { + Length *big.Int +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error ECDSAInvalidSignatureLength(uint256 length) +func SymbioticOevSolverECDSAInvalidSignatureLengthErrorID() common.Hash { + return common.HexToHash("0xfce698f7e8e5342cd615f641317bc45fe7e1e4a8b0a14dd1383ff8dc9c41917f") +} + +// UnpackECDSAInvalidSignatureLengthError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error ECDSAInvalidSignatureLength(uint256 length) +func (symbioticOevSolver *SymbioticOevSolver) UnpackECDSAInvalidSignatureLengthError(raw []byte) (*SymbioticOevSolverECDSAInvalidSignatureLength, error) { + out := new(SymbioticOevSolverECDSAInvalidSignatureLength) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "ECDSAInvalidSignatureLength", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverECDSAInvalidSignatureS represents a ECDSAInvalidSignatureS error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverECDSAInvalidSignatureS struct { + S [32]byte +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error ECDSAInvalidSignatureS(bytes32 s) +func SymbioticOevSolverECDSAInvalidSignatureSErrorID() common.Hash { + return common.HexToHash("0xd78bce0cccb935155ed6428d1c13e50b7f3550fd2b66b9fe266006fea4a5e1eb") +} + +// UnpackECDSAInvalidSignatureSError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error ECDSAInvalidSignatureS(bytes32 s) +func (symbioticOevSolver *SymbioticOevSolver) UnpackECDSAInvalidSignatureSError(raw []byte) (*SymbioticOevSolverECDSAInvalidSignatureS, error) { + out := new(SymbioticOevSolverECDSAInvalidSignatureS) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "ECDSAInvalidSignatureS", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverInsufficientLoanProceeds represents a InsufficientLoanProceeds error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverInsufficientLoanProceeds struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InsufficientLoanProceeds() +func SymbioticOevSolverInsufficientLoanProceedsErrorID() common.Hash { + return common.HexToHash("0x8dff298421d9adf12071798b4a2ba2b222fcafe9e2d1885468bb553b5152ddaf") +} + +// UnpackInsufficientLoanProceedsError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InsufficientLoanProceeds() +func (symbioticOevSolver *SymbioticOevSolver) UnpackInsufficientLoanProceedsError(raw []byte) (*SymbioticOevSolverInsufficientLoanProceeds, error) { + out := new(SymbioticOevSolverInsufficientLoanProceeds) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "InsufficientLoanProceeds", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverInvalidAuth represents a InvalidAuth error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverInvalidAuth struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InvalidAuth() +func SymbioticOevSolverInvalidAuthErrorID() common.Hash { + return common.HexToHash("0x60907fd1eaf0aeb8678cf1ed7e0848c38b81ff6b751719093cce13e43c4aa3a7") +} + +// UnpackInvalidAuthError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InvalidAuth() +func (symbioticOevSolver *SymbioticOevSolver) UnpackInvalidAuthError(raw []byte) (*SymbioticOevSolverInvalidAuth, error) { + out := new(SymbioticOevSolverInvalidAuth) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "InvalidAuth", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverNotExecutor represents a NotExecutor error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverNotExecutor struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotExecutor() +func SymbioticOevSolverNotExecutorErrorID() common.Hash { + return common.HexToHash("0xc32d1d764229d81292df6f25b9d1e0888374ee366ac172b5c5162f2d6fcf3ce2") +} + +// UnpackNotExecutorError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotExecutor() +func (symbioticOevSolver *SymbioticOevSolver) UnpackNotExecutorError(raw []byte) (*SymbioticOevSolverNotExecutor, error) { + out := new(SymbioticOevSolverNotExecutor) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "NotExecutor", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverNotMorpho represents a NotMorpho error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverNotMorpho struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotMorpho() +func SymbioticOevSolverNotMorphoErrorID() common.Hash { + return common.HexToHash("0xe51b512366538cee8c853e063e54221c196d4d7f44b7cc806f3763062d129db9") +} + +// UnpackNotMorphoError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotMorpho() +func (symbioticOevSolver *SymbioticOevSolver) UnpackNotMorphoError(raw []byte) (*SymbioticOevSolverNotMorpho, error) { + out := new(SymbioticOevSolverNotMorpho) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "NotMorpho", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverNotOwner represents a NotOwner error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverNotOwner struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotOwner() +func SymbioticOevSolverNotOwnerErrorID() common.Hash { + return common.HexToHash("0x30cd74712f59d478562d48e2d35de830db72c60a63dd08ae59199eec990b5bc4") +} + +// UnpackNotOwnerError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotOwner() +func (symbioticOevSolver *SymbioticOevSolver) UnpackNotOwnerError(raw []byte) (*SymbioticOevSolverNotOwner, error) { + out := new(SymbioticOevSolverNotOwner) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "NotOwner", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverProfitBelowMin represents a ProfitBelowMin error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverProfitBelowMin struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error ProfitBelowMin() +func SymbioticOevSolverProfitBelowMinErrorID() common.Hash { + return common.HexToHash("0xe42f715d4b8066279da9ab3b7d708b4d7702d6769277c477f0f130466b02a066") +} + +// UnpackProfitBelowMinError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error ProfitBelowMin() +func (symbioticOevSolver *SymbioticOevSolver) UnpackProfitBelowMinError(raw []byte) (*SymbioticOevSolverProfitBelowMin, error) { + out := new(SymbioticOevSolverProfitBelowMin) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "ProfitBelowMin", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverReentrancyGuardReentrantCall represents a ReentrancyGuardReentrantCall error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverReentrancyGuardReentrantCall struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error ReentrancyGuardReentrantCall() +func SymbioticOevSolverReentrancyGuardReentrantCallErrorID() common.Hash { + return common.HexToHash("0x3ee5aeb571de7fc460830b4d0017439a1ca56fb0bc39062227ade4fe4a24c1ca") +} + +// UnpackReentrancyGuardReentrantCallError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error ReentrancyGuardReentrantCall() +func (symbioticOevSolver *SymbioticOevSolver) UnpackReentrancyGuardReentrantCallError(raw []byte) (*SymbioticOevSolverReentrancyGuardReentrantCall, error) { + out := new(SymbioticOevSolverReentrancyGuardReentrantCall) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "ReentrancyGuardReentrantCall", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverSafeERC20FailedOperation represents a SafeERC20FailedOperation error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverSafeERC20FailedOperation struct { + Token common.Address +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error SafeERC20FailedOperation(address token) +func SymbioticOevSolverSafeERC20FailedOperationErrorID() common.Hash { + return common.HexToHash("0x5274afe73c98b4749fc91ffae6b7b574e7842cb2144a159e9377a5f20b32edf9") +} + +// UnpackSafeERC20FailedOperationError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error SafeERC20FailedOperation(address token) +func (symbioticOevSolver *SymbioticOevSolver) UnpackSafeERC20FailedOperationError(raw []byte) (*SymbioticOevSolverSafeERC20FailedOperation, error) { + out := new(SymbioticOevSolverSafeERC20FailedOperation) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "SafeERC20FailedOperation", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverSwapOutputBelowMin represents a SwapOutputBelowMin error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverSwapOutputBelowMin struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error SwapOutputBelowMin() +func SymbioticOevSolverSwapOutputBelowMinErrorID() common.Hash { + return common.HexToHash("0x4f3f768fa41bcfdbeb273b7d91fd78101b766e92d4b008ec24772933ae5c425c") +} + +// UnpackSwapOutputBelowMinError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error SwapOutputBelowMin() +func (symbioticOevSolver *SymbioticOevSolver) UnpackSwapOutputBelowMinError(raw []byte) (*SymbioticOevSolverSwapOutputBelowMin, error) { + out := new(SymbioticOevSolverSwapOutputBelowMin) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "SwapOutputBelowMin", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverTransferFailed represents a TransferFailed error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverTransferFailed struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error TransferFailed() +func SymbioticOevSolverTransferFailedErrorID() common.Hash { + return common.HexToHash("0x90b8ec1877afffd816d05d9b13947f3ff18ec5851c38bad15ec2b710f92391b1") +} + +// UnpackTransferFailedError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error TransferFailed() +func (symbioticOevSolver *SymbioticOevSolver) UnpackTransferFailedError(raw []byte) (*SymbioticOevSolverTransferFailed, error) { + out := new(SymbioticOevSolverTransferFailed) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "TransferFailed", raw); err != nil { + return nil, err + } + return out, nil +} + +// SymbioticOevSolverZeroAddress represents a ZeroAddress error raised by the SymbioticOevSolver contract. +type SymbioticOevSolverZeroAddress struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error ZeroAddress() +func SymbioticOevSolverZeroAddressErrorID() common.Hash { + return common.HexToHash("0xd92e233df2717d4a40030e20904abd27b68fcbeede117eaaccbbdac9618c8c73") +} + +// UnpackZeroAddressError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error ZeroAddress() +func (symbioticOevSolver *SymbioticOevSolver) UnpackZeroAddressError(raw []byte) (*SymbioticOevSolverZeroAddress, error) { + out := new(SymbioticOevSolverZeroAddress) + if err := symbioticOevSolver.abi.UnpackIntoInterface(out, "ZeroAddress", raw); err != nil { + return nil, err + } + return out, nil +} diff --git a/api/bindings/oev/executor/RedStoneExecutor.go b/api/bindings/oev/executor/RedStoneExecutor.go new file mode 100644 index 00000000..b3137467 --- /dev/null +++ b/api/bindings/oev/executor/RedStoneExecutor.go @@ -0,0 +1,220 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package executor + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// RedStoneExecutorMetaData contains all meta data concerning the RedStoneExecutor contract. +var RedStoneExecutorMetaData = bind.MetaData{ + ABI: "[{\"inputs\":[{\"type\":\"address\"}],\"name\":\"nonces\",\"outputs\":[{\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"type\":\"address\"}],\"name\":\"deposits\",\"outputs\":[{\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"type\":\"address\"}],\"name\":\"locked\",\"outputs\":[{\"type\":\"bool\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"deposit\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"name\":\"solver\",\"type\":\"address\"},{\"indexed\":false,\"name\":\"nonce\",\"type\":\"uint256\"}],\"name\":\"LiquidationFailed\",\"type\":\"event\"}]", + ID: "RedStoneExecutor", +} + +// RedStoneExecutor is an auto generated Go binding around an Ethereum contract. +type RedStoneExecutor struct { + abi abi.ABI +} + +// NewRedStoneExecutor creates a new instance of RedStoneExecutor. +func NewRedStoneExecutor() *RedStoneExecutor { + parsed, err := RedStoneExecutorMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &RedStoneExecutor{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *RedStoneExecutor) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackDeposit is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xd0e30db0. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function deposit() payable returns() +func (redStoneExecutor *RedStoneExecutor) PackDeposit() []byte { + enc, err := redStoneExecutor.abi.Pack("deposit") + if err != nil { + panic(err) + } + return enc +} + +// TryPackDeposit is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xd0e30db0. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function deposit() payable returns() +func (redStoneExecutor *RedStoneExecutor) TryPackDeposit() ([]byte, error) { + return redStoneExecutor.abi.Pack("deposit") +} + +// PackDeposits is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xfc7e286d. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function deposits(address ) view returns(uint256) +func (redStoneExecutor *RedStoneExecutor) PackDeposits(arg0 common.Address) []byte { + enc, err := redStoneExecutor.abi.Pack("deposits", arg0) + if err != nil { + panic(err) + } + return enc +} + +// TryPackDeposits is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xfc7e286d. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function deposits(address ) view returns(uint256) +func (redStoneExecutor *RedStoneExecutor) TryPackDeposits(arg0 common.Address) ([]byte, error) { + return redStoneExecutor.abi.Pack("deposits", arg0) +} + +// UnpackDeposits is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xfc7e286d. +// +// Solidity: function deposits(address ) view returns(uint256) +func (redStoneExecutor *RedStoneExecutor) UnpackDeposits(data []byte) (*big.Int, error) { + out, err := redStoneExecutor.abi.Unpack("deposits", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackLocked is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xcbf9fe5f. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function locked(address ) view returns(bool) +func (redStoneExecutor *RedStoneExecutor) PackLocked(arg0 common.Address) []byte { + enc, err := redStoneExecutor.abi.Pack("locked", arg0) + if err != nil { + panic(err) + } + return enc +} + +// TryPackLocked is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xcbf9fe5f. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function locked(address ) view returns(bool) +func (redStoneExecutor *RedStoneExecutor) TryPackLocked(arg0 common.Address) ([]byte, error) { + return redStoneExecutor.abi.Pack("locked", arg0) +} + +// UnpackLocked is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xcbf9fe5f. +// +// Solidity: function locked(address ) view returns(bool) +func (redStoneExecutor *RedStoneExecutor) UnpackLocked(data []byte) (bool, error) { + out, err := redStoneExecutor.abi.Unpack("locked", data) + if err != nil { + return *new(bool), err + } + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + return out0, nil +} + +// PackNonces is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x7ecebe00. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function nonces(address ) view returns(uint256) +func (redStoneExecutor *RedStoneExecutor) PackNonces(arg0 common.Address) []byte { + enc, err := redStoneExecutor.abi.Pack("nonces", arg0) + if err != nil { + panic(err) + } + return enc +} + +// TryPackNonces is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x7ecebe00. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function nonces(address ) view returns(uint256) +func (redStoneExecutor *RedStoneExecutor) TryPackNonces(arg0 common.Address) ([]byte, error) { + return redStoneExecutor.abi.Pack("nonces", arg0) +} + +// UnpackNonces is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x7ecebe00. +// +// Solidity: function nonces(address ) view returns(uint256) +func (redStoneExecutor *RedStoneExecutor) UnpackNonces(data []byte) (*big.Int, error) { + out, err := redStoneExecutor.abi.Unpack("nonces", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// RedStoneExecutorLiquidationFailed represents a LiquidationFailed event raised by the RedStoneExecutor contract. +type RedStoneExecutorLiquidationFailed struct { + Solver common.Address + Nonce *big.Int + Raw *types.Log // Blockchain specific contextual infos +} + +const RedStoneExecutorLiquidationFailedEventName = "LiquidationFailed" + +// ContractEventName returns the user-defined event name. +func (RedStoneExecutorLiquidationFailed) ContractEventName() string { + return RedStoneExecutorLiquidationFailedEventName +} + +// UnpackLiquidationFailedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event LiquidationFailed(address indexed solver, uint256 nonce) +func (redStoneExecutor *RedStoneExecutor) UnpackLiquidationFailedEvent(log *types.Log) (*RedStoneExecutorLiquidationFailed, error) { + event := "LiquidationFailed" + if log.Topics[0] != redStoneExecutor.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(RedStoneExecutorLiquidationFailed) + if len(log.Data) > 0 { + if err := redStoneExecutor.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range redStoneExecutor.abi.Events[event].Inputs { + if arg.Indexed { + indexed = append(indexed, arg) + } + } + if err := abi.ParseTopics(out, indexed, log.Topics[1:]); err != nil { + return nil, err + } + out.Raw = log + return out, nil +} diff --git a/api/bindings/oev/irm/AdaptiveCurveIrm.go b/api/bindings/oev/irm/AdaptiveCurveIrm.go new file mode 100644 index 00000000..d75ea232 --- /dev/null +++ b/api/bindings/oev/irm/AdaptiveCurveIrm.go @@ -0,0 +1,105 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package irm + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// Struct0 is an auto generated low-level Go binding around an user-defined struct. +type Struct0 struct { + LoanToken common.Address + CollateralToken common.Address + Oracle common.Address + Irm common.Address + Lltv *big.Int +} + +// Struct1 is an auto generated low-level Go binding around an user-defined struct. +type Struct1 struct { + TotalSupplyAssets *big.Int + TotalSupplyShares *big.Int + TotalBorrowAssets *big.Int + TotalBorrowShares *big.Int + LastUpdate *big.Int + Fee *big.Int +} + +// AdaptiveCurveIrmMetaData contains all meta data concerning the AdaptiveCurveIrm contract. +var AdaptiveCurveIrmMetaData = bind.MetaData{ + ABI: "[{\"inputs\":[{\"name\":\"marketParams\",\"type\":\"tuple\",\"components\":[{\"name\":\"loanToken\",\"type\":\"address\"},{\"name\":\"collateralToken\",\"type\":\"address\"},{\"name\":\"oracle\",\"type\":\"address\"},{\"name\":\"irm\",\"type\":\"address\"},{\"name\":\"lltv\",\"type\":\"uint256\"}]},{\"name\":\"market\",\"type\":\"tuple\",\"components\":[{\"name\":\"totalSupplyAssets\",\"type\":\"uint128\"},{\"name\":\"totalSupplyShares\",\"type\":\"uint128\"},{\"name\":\"totalBorrowAssets\",\"type\":\"uint128\"},{\"name\":\"totalBorrowShares\",\"type\":\"uint128\"},{\"name\":\"lastUpdate\",\"type\":\"uint128\"},{\"name\":\"fee\",\"type\":\"uint128\"}]}],\"name\":\"borrowRateView\",\"outputs\":[{\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ID: "AdaptiveCurveIrm", +} + +// AdaptiveCurveIrm is an auto generated Go binding around an Ethereum contract. +type AdaptiveCurveIrm struct { + abi abi.ABI +} + +// NewAdaptiveCurveIrm creates a new instance of AdaptiveCurveIrm. +func NewAdaptiveCurveIrm() *AdaptiveCurveIrm { + parsed, err := AdaptiveCurveIrmMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &AdaptiveCurveIrm{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *AdaptiveCurveIrm) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackBorrowRateView is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8c00bf6b. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function borrowRateView((address,address,address,address,uint256) marketParams, (uint128,uint128,uint128,uint128,uint128,uint128) market) view returns(uint256) +func (adaptiveCurveIrm *AdaptiveCurveIrm) PackBorrowRateView(marketParams Struct0, market Struct1) []byte { + enc, err := adaptiveCurveIrm.abi.Pack("borrowRateView", marketParams, market) + if err != nil { + panic(err) + } + return enc +} + +// TryPackBorrowRateView is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8c00bf6b. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function borrowRateView((address,address,address,address,uint256) marketParams, (uint128,uint128,uint128,uint128,uint128,uint128) market) view returns(uint256) +func (adaptiveCurveIrm *AdaptiveCurveIrm) TryPackBorrowRateView(marketParams Struct0, market Struct1) ([]byte, error) { + return adaptiveCurveIrm.abi.Pack("borrowRateView", marketParams, market) +} + +// UnpackBorrowRateView is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x8c00bf6b. +// +// Solidity: function borrowRateView((address,address,address,address,uint256) marketParams, (uint128,uint128,uint128,uint128,uint128,uint128) market) view returns(uint256) +func (adaptiveCurveIrm *AdaptiveCurveIrm) UnpackBorrowRateView(data []byte) (*big.Int, error) { + out, err := adaptiveCurveIrm.abi.Unpack("borrowRateView", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} diff --git a/api/bindings/oev/morpho/Morpho.go b/api/bindings/oev/morpho/Morpho.go new file mode 100644 index 00000000..f27249ad --- /dev/null +++ b/api/bindings/oev/morpho/Morpho.go @@ -0,0 +1,199 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package morpho + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// MorphoMetaData contains all meta data concerning the Morpho contract. +var MorphoMetaData = bind.MetaData{ + ABI: "[{\"inputs\":[{\"type\":\"bytes32\"}],\"name\":\"market\",\"outputs\":[{\"name\":\"totalSupplyAssets\",\"type\":\"uint128\"},{\"name\":\"totalSupplyShares\",\"type\":\"uint128\"},{\"name\":\"totalBorrowAssets\",\"type\":\"uint128\"},{\"name\":\"totalBorrowShares\",\"type\":\"uint128\"},{\"name\":\"lastUpdate\",\"type\":\"uint128\"},{\"name\":\"fee\",\"type\":\"uint128\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"type\":\"bytes32\"},{\"type\":\"address\"}],\"name\":\"position\",\"outputs\":[{\"name\":\"supplyShares\",\"type\":\"uint256\"},{\"name\":\"borrowShares\",\"type\":\"uint128\"},{\"name\":\"collateral\",\"type\":\"uint128\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"type\":\"bytes32\"}],\"name\":\"idToMarketParams\",\"outputs\":[{\"name\":\"loanToken\",\"type\":\"address\"},{\"name\":\"collateralToken\",\"type\":\"address\"},{\"name\":\"oracle\",\"type\":\"address\"},{\"name\":\"irm\",\"type\":\"address\"},{\"name\":\"lltv\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ID: "Morpho", +} + +// Morpho is an auto generated Go binding around an Ethereum contract. +type Morpho struct { + abi abi.ABI +} + +// NewMorpho creates a new instance of Morpho. +func NewMorpho() *Morpho { + parsed, err := MorphoMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &Morpho{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *Morpho) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackIdToMarketParams is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x2c3c9157. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function idToMarketParams(bytes32 ) view returns(address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) +func (morpho *Morpho) PackIdToMarketParams(arg0 [32]byte) []byte { + enc, err := morpho.abi.Pack("idToMarketParams", arg0) + if err != nil { + panic(err) + } + return enc +} + +// TryPackIdToMarketParams is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x2c3c9157. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function idToMarketParams(bytes32 ) view returns(address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) +func (morpho *Morpho) TryPackIdToMarketParams(arg0 [32]byte) ([]byte, error) { + return morpho.abi.Pack("idToMarketParams", arg0) +} + +// IdToMarketParamsOutput serves as a container for the return parameters of contract +// method IdToMarketParams. +type IdToMarketParamsOutput struct { + LoanToken common.Address + CollateralToken common.Address + Oracle common.Address + Irm common.Address + Lltv *big.Int +} + +// UnpackIdToMarketParams is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x2c3c9157. +// +// Solidity: function idToMarketParams(bytes32 ) view returns(address loanToken, address collateralToken, address oracle, address irm, uint256 lltv) +func (morpho *Morpho) UnpackIdToMarketParams(data []byte) (IdToMarketParamsOutput, error) { + out, err := morpho.abi.Unpack("idToMarketParams", data) + outstruct := new(IdToMarketParamsOutput) + if err != nil { + return *outstruct, err + } + outstruct.LoanToken = *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + outstruct.CollateralToken = *abi.ConvertType(out[1], new(common.Address)).(*common.Address) + outstruct.Oracle = *abi.ConvertType(out[2], new(common.Address)).(*common.Address) + outstruct.Irm = *abi.ConvertType(out[3], new(common.Address)).(*common.Address) + outstruct.Lltv = abi.ConvertType(out[4], new(big.Int)).(*big.Int) + return *outstruct, nil +} + +// PackMarket is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x5c60e39a. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function market(bytes32 ) view returns(uint128 totalSupplyAssets, uint128 totalSupplyShares, uint128 totalBorrowAssets, uint128 totalBorrowShares, uint128 lastUpdate, uint128 fee) +func (morpho *Morpho) PackMarket(arg0 [32]byte) []byte { + enc, err := morpho.abi.Pack("market", arg0) + if err != nil { + panic(err) + } + return enc +} + +// TryPackMarket is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x5c60e39a. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function market(bytes32 ) view returns(uint128 totalSupplyAssets, uint128 totalSupplyShares, uint128 totalBorrowAssets, uint128 totalBorrowShares, uint128 lastUpdate, uint128 fee) +func (morpho *Morpho) TryPackMarket(arg0 [32]byte) ([]byte, error) { + return morpho.abi.Pack("market", arg0) +} + +// MarketOutput serves as a container for the return parameters of contract +// method Market. +type MarketOutput struct { + TotalSupplyAssets *big.Int + TotalSupplyShares *big.Int + TotalBorrowAssets *big.Int + TotalBorrowShares *big.Int + LastUpdate *big.Int + Fee *big.Int +} + +// UnpackMarket is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x5c60e39a. +// +// Solidity: function market(bytes32 ) view returns(uint128 totalSupplyAssets, uint128 totalSupplyShares, uint128 totalBorrowAssets, uint128 totalBorrowShares, uint128 lastUpdate, uint128 fee) +func (morpho *Morpho) UnpackMarket(data []byte) (MarketOutput, error) { + out, err := morpho.abi.Unpack("market", data) + outstruct := new(MarketOutput) + if err != nil { + return *outstruct, err + } + outstruct.TotalSupplyAssets = abi.ConvertType(out[0], new(big.Int)).(*big.Int) + outstruct.TotalSupplyShares = abi.ConvertType(out[1], new(big.Int)).(*big.Int) + outstruct.TotalBorrowAssets = abi.ConvertType(out[2], new(big.Int)).(*big.Int) + outstruct.TotalBorrowShares = abi.ConvertType(out[3], new(big.Int)).(*big.Int) + outstruct.LastUpdate = abi.ConvertType(out[4], new(big.Int)).(*big.Int) + outstruct.Fee = abi.ConvertType(out[5], new(big.Int)).(*big.Int) + return *outstruct, nil +} + +// PackPosition is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x93c52062. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function position(bytes32 , address ) view returns(uint256 supplyShares, uint128 borrowShares, uint128 collateral) +func (morpho *Morpho) PackPosition(arg0 [32]byte, arg1 common.Address) []byte { + enc, err := morpho.abi.Pack("position", arg0, arg1) + if err != nil { + panic(err) + } + return enc +} + +// TryPackPosition is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x93c52062. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function position(bytes32 , address ) view returns(uint256 supplyShares, uint128 borrowShares, uint128 collateral) +func (morpho *Morpho) TryPackPosition(arg0 [32]byte, arg1 common.Address) ([]byte, error) { + return morpho.abi.Pack("position", arg0, arg1) +} + +// PositionOutput serves as a container for the return parameters of contract +// method Position. +type PositionOutput struct { + SupplyShares *big.Int + BorrowShares *big.Int + Collateral *big.Int +} + +// UnpackPosition is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x93c52062. +// +// Solidity: function position(bytes32 , address ) view returns(uint256 supplyShares, uint128 borrowShares, uint128 collateral) +func (morpho *Morpho) UnpackPosition(data []byte) (PositionOutput, error) { + out, err := morpho.abi.Unpack("position", data) + outstruct := new(PositionOutput) + if err != nil { + return *outstruct, err + } + outstruct.SupplyShares = abi.ConvertType(out[0], new(big.Int)).(*big.Int) + outstruct.BorrowShares = abi.ConvertType(out[1], new(big.Int)).(*big.Int) + outstruct.Collateral = abi.ConvertType(out[2], new(big.Int)).(*big.Int) + return *outstruct, nil +} diff --git a/api/bindings/oev/oracle/MorphoOracle.go b/api/bindings/oev/oracle/MorphoOracle.go new file mode 100644 index 00000000..eb749d42 --- /dev/null +++ b/api/bindings/oev/oracle/MorphoOracle.go @@ -0,0 +1,86 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package oracle + +import ( + "bytes" + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = bytes.Equal + _ = errors.New + _ = big.NewInt + _ = common.Big1 + _ = types.BloomLookup + _ = abi.ConvertType +) + +// MorphoOracleMetaData contains all meta data concerning the MorphoOracle contract. +var MorphoOracleMetaData = bind.MetaData{ + ABI: "[{\"inputs\":[],\"name\":\"price\",\"outputs\":[{\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + ID: "MorphoOracle", +} + +// MorphoOracle is an auto generated Go binding around an Ethereum contract. +type MorphoOracle struct { + abi abi.ABI +} + +// NewMorphoOracle creates a new instance of MorphoOracle. +func NewMorphoOracle() *MorphoOracle { + parsed, err := MorphoOracleMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &MorphoOracle{abi: *parsed} +} + +// Instance creates a wrapper for a deployed contract instance at the given address. +// Use this to create the instance object passed to abigen v2 library functions Call, Transact, etc. +func (c *MorphoOracle) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackPrice is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xa035b1fe. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function price() view returns(uint256) +func (morphoOracle *MorphoOracle) PackPrice() []byte { + enc, err := morphoOracle.abi.Pack("price") + if err != nil { + panic(err) + } + return enc +} + +// TryPackPrice is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xa035b1fe. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function price() view returns(uint256) +func (morphoOracle *MorphoOracle) TryPackPrice() ([]byte, error) { + return morphoOracle.abi.Pack("price") +} + +// UnpackPrice is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xa035b1fe. +// +// Solidity: function price() view returns(uint256) +func (morphoOracle *MorphoOracle) UnpackPrice(data []byte) (*big.Int, error) { + out, err := morphoOracle.abi.Unpack("price", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} diff --git a/api/graphql/morpho/README.md b/api/graphql/morpho/README.md new file mode 100644 index 00000000..cdc6c4df --- /dev/null +++ b/api/graphql/morpho/README.md @@ -0,0 +1,27 @@ +# Morpho GraphQL + +This directory is the contract-of-record for the generated Morpho GraphQL binding. + +- `schema.graphql` is the full Morpho GraphQL schema SDL fetched from the configured endpoint. +- `operations/*.graphql` contains the operations this repo actually calls. +- `operations.json` is generated by `genqlient` and records the exact query strings sent on the wire. +- `../../morphographql/generated.go` is generated Go code. Do not edit it by hand. + +To add a Morpho API read for any solver, add a named operation under `operations/`, then run: + +```bash +make refresh-morpho-graphql-client +``` + +To refresh the upstream schema, run: + +```bash +make refresh-morpho-graphql-schema +make refresh-morpho-graphql-client +``` + +This mirrors Morpho's TypeScript `@morpho-org/blue-api-sdk` pattern: the full schema is vendored, while +typed bindings are generated from explicit operation documents. + +Custom GraphQL scalars are bound to strings at the generated boundary (`Address`, `MarketId`, `BigInt`, +`HexString`). Solver-local adapters parse them into addresses, hashes, or integers after validation. diff --git a/api/graphql/morpho/genqlient.yaml b/api/graphql/morpho/genqlient.yaml new file mode 100644 index 00000000..0846b715 --- /dev/null +++ b/api/graphql/morpho/genqlient.yaml @@ -0,0 +1,16 @@ +schema: schema.graphql +operations: + - operations/*.graphql +generated: ../../morphographql/generated.go +export_operations: operations.json +package: morphographql +optional: pointer +bindings: + Address: + type: string + BigInt: + type: github.com/symbioticfi/vault-solver/api/morphographql/scalars.BigIntString + HexString: + type: string + MarketId: + type: string diff --git a/api/graphql/morpho/operations.json b/api/graphql/morpho/operations.json new file mode 100644 index 00000000..977b233d --- /dev/null +++ b/api/graphql/morpho/operations.json @@ -0,0 +1,14 @@ +{ + "operations": [ + { + "operationName": "MorphoDiscoverMarkets", + "query": "\nquery MorphoDiscoverMarkets ($loan: [String!]!, $coll: [String!]!, $chains: [Int!]!, $first: Int!) {\n\tmarkets(first: $first, where: {loanAssetAddress_in:$loan,collateralAssetAddress_in:$coll,chainId_in:$chains}) {\n\t\titems {\n\t\t\tmarketId\n\t\t\toracleAddress\n\t\t\tirmAddress\n\t\t\tlltv\n\t\t\tloanAsset {\n\t\t\t\taddress\n\t\t\t}\n\t\t\tcollateralAsset {\n\t\t\t\taddress\n\t\t\t}\n\t\t\tstate {\n\t\t\t\tblockNumber\n\t\t\t\tborrowAssets\n\t\t\t\tborrowShares\n\t\t\t\tsupplyAssets\n\t\t\t\tsupplyShares\n\t\t\t\ttimestamp\n\t\t\t\tprice\n\t\t\t}\n\t\t}\n\t}\n}\n", + "sourceLocation": "operations/discovery.graphql" + }, + { + "operationName": "MorphoPositionsByMarket", + "query": "\nquery MorphoPositionsByMarket ($ids: [String!]!, $first: Int!, $skip: Int!, $maxHf: Float) {\n\tmarketPositions(first: $first, skip: $skip, orderBy: HealthFactor, orderDirection: Asc, where: {marketUniqueKey_in:$ids,healthFactor_lte:$maxHf}) {\n\t\titems {\n\t\t\tuser {\n\t\t\t\taddress\n\t\t\t}\n\t\t\tmarket {\n\t\t\t\tmarketId\n\t\t\t}\n\t\t\tstate {\n\t\t\t\tborrowShares\n\t\t\t\tcollateral\n\t\t\t}\n\t\t\thealthFactor\n\t\t}\n\t}\n}\n", + "sourceLocation": "operations/discovery.graphql" + } + ] +} \ No newline at end of file diff --git a/api/graphql/morpho/operations/discovery.graphql b/api/graphql/morpho/operations/discovery.graphql new file mode 100644 index 00000000..fdb295c7 --- /dev/null +++ b/api/graphql/morpho/operations/discovery.graphql @@ -0,0 +1,52 @@ +query MorphoDiscoverMarkets($loan: [String!]!, $coll: [String!]!, $chains: [Int!]!, $first: Int!) { + markets( + first: $first + where: { loanAssetAddress_in: $loan, collateralAssetAddress_in: $coll, chainId_in: $chains } + ) { + items { + marketId + oracleAddress + irmAddress + lltv + loanAsset { + address + } + collateralAsset { + address + } + state { + blockNumber + borrowAssets + borrowShares + supplyAssets + supplyShares + timestamp + price + } + } + } +} + +query MorphoPositionsByMarket($ids: [String!]!, $first: Int!, $skip: Int!, $maxHf: Float) { + marketPositions( + first: $first + skip: $skip + orderBy: HealthFactor + orderDirection: Asc + where: { marketUniqueKey_in: $ids, healthFactor_lte: $maxHf } + ) { + items { + user { + address + } + market { + marketId + } + state { + borrowShares + collateral + } + healthFactor + } + } +} diff --git a/api/graphql/morpho/schema.graphql b/api/graphql/morpho/schema.graphql new file mode 100644 index 00000000..25cd5e1a --- /dev/null +++ b/api/graphql/morpho/schema.graphql @@ -0,0 +1,5854 @@ +""" +Directs the executor to include this field or fragment only when the `if` argument is true. +""" +directive @include( +""" +Included when true. +""" + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +""" +Directs the executor to skip this field or fragment when the `if` argument is true. +""" +directive @skip( +""" +Skipped when true. +""" + if: Boolean! +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT + +""" +Marks an element of a GraphQL schema as no longer supported. +""" +directive @deprecated( +""" +Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org/). +""" + reason: String +) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE + +""" +Exposes a URL that specifies the behavior of this scalar. +""" +directive @specifiedBy( +""" +The URL that specifies the behavior of this scalar. +""" + url: String! +) on SCALAR + +""" +Indicates exactly one field must be supplied and this field must not be `null`. +""" +directive @oneOf on INPUT_OBJECT + +directive @cacheControl( + maxAge: Int + scope: CacheControlScope + inheritMaxAge: Boolean +) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | QUERY + +""" +Define a relation between the field and other nodes +""" +directive @complexity( +""" +The complexity value for the field +""" + value: Int! + multipliers: [String!] +) on FIELD_DEFINITION + +type PageInfo { +""" +Total number of items +""" + countTotal: Int! +""" +Number of items as scoped by pagination. +""" + count: Int! +""" +Number of items requested. +""" + limit: Int! +""" +Number of items skipped. +""" + skip: Int! +} + +""" +The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1. +""" +scalar Int + +type AddressMetadata { + type: AddressMetadataType! + metadata: Metadata! +} + +enum AddressMetadataType { + safe + aragon +} + +union Metadata =SafeAddressMetadata | AragonAddressMetadata + +""" +Safe address metadata +""" +type SafeAddressMetadata { + owners: [String!]! + threshold: Int! +} + +""" +The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text. +""" +scalar String + +""" +Aragon address metadata +""" +type AragonAddressMetadata { + ensDomain: String + name: String + description: String +} + +type PaginatedAddressMetadata { + items: [AddressMetadata!] + pageInfo: PageInfo +} + +""" +Account +""" +type Account { +""" +Account adress. +""" + address: Address! +""" +Additional information about the account. +""" + metadata( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedAddressMetadata! +} + +""" +42 character long hex address +""" +scalar Address + +""" +Asset yield +""" +type AssetYield { +""" +Asset yield (APR) +""" + apr: Float! +""" +Lookback period used to compute the APR, in seconds. +""" + lookback: Int! +} + +""" +The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point). +""" +scalar Float + +""" +Asset price +""" +type AssetPrice { +""" +Asset price in USD, for display purpose. +""" + usd: Float! +""" +Timestamp of the price returned. +""" + timestamp: BigInt! +} + +""" +The `BigInt` scalar type represents non-fractional signed whole numeric values. +""" +scalar BigInt + +""" +Asset +""" +type Asset implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! +""" +ERC-20 token contract address +""" + address: Address! + decimals: Float! + name: String! + symbol: String! + tags: [String!] +""" +Token logo URI, for display purpose +""" + logoURI: String +""" +Either the asset is listed or not +""" + isListed: Boolean! +""" +Either the asset is whitelisted or not +""" + isWhitelisted: Boolean! @deprecated(reason: "Use isListed instead.") +""" +Current price in USD together with the timestamp of the price returned. +""" + price( +""" +Maximum lookback in hours when resolving the latest available price. Accepted range: 0-24. +""" + maxLag: Int + ): AssetPrice +""" +Current price in USD, for display purpose. +""" + priceUsd: Float @deprecated(reason: "Use price.usd instead.") +""" +Historical price in USD, for display purpose +""" + historicalPriceUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Current spot price in ETH. +""" + spotPriceEth( + timestamp: Float + ): Float @deprecated(reason: "Use historicalPriceUsd instead.") +""" +ERC-20 token total supply +""" + totalSupply: BigInt! @deprecated(reason: "Deprecated.") +""" +Historical spot price in ETH +""" + historicalSpotPriceEth( + options: TimeseriesOptions + ): [FloatDataPoint!]! @deprecated(reason: "Use historicalPriceUsd instead.") + oraclePriceUsd( + timestamp: Float + ): Float @deprecated(reason: "Use price.usd instead.") +""" +Morpho Vault V1 +""" + vault: Vault + yield: AssetYield +} + +interface ChainReference { + chain: Chain! +} + +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID. +""" +scalar ID + +""" +The `Boolean` scalar type represents `true` or `false`. +""" +scalar Boolean + +input TimeseriesOptions { + startTimestamp: Int + endTimestamp: Int + interval: TimeseriesInterval +} + +enum TimeseriesInterval { + MINUTE @deprecated(reason: "Deprecated.") + FIVE_MINUTES @deprecated(reason: "Deprecated.") + FIFTEEN_MINUTES @deprecated(reason: "Deprecated.") + HALF_HOUR @deprecated(reason: "Deprecated.") + HOUR + DAY + WEEK + MONTH + QUARTER + YEAR + ALL @deprecated(reason: "Use startTimestamp and endTimestamp instead.") +} + +""" +Vault Liquidity +""" +type VaultLiquidity { +""" +Vault withdrawable liquidity in underlying. +""" + underlying: BigInt! +""" +Vault withdrawable liquidity in USD. +""" + usd: Float! +} + +""" +Vault allocator +""" +type VaultAllocator { +""" +Allocator address. +""" + address: Address! +""" +Allocator since block number +""" + blockNumber: BigInt! +""" +Allocator since timestamp +""" + timestamp: BigInt! +""" +Additional information about the address. +""" + metadata( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedAddressMetadata +} + +""" +Vault metadata +""" +type VaultMetadata { + description: String! + image: String! + forumLink: String @deprecated(reason: "Deprecated and always returns null.") +} + +""" +MetaMorpho Vaults +""" +type Vault implements AssetReference & ChainReference{ +""" +The asset. +""" + asset: Asset! +""" +The chain on which the entity is deployed. +""" + chain: Chain! + address: Address! + symbol: String! + creationBlockNumber: Int! + creationTimestamp: BigInt! + creatorAddress: Address + id: ID! @deprecated(reason: "Use address and chainId instead.") +""" +The vault's displayed name. +""" + name: String! +""" +A vault V1 is listed as soon as it is promoted OR is listed as an underlying vault of a vault v2 (via a MorphoVaultV1Adapter). +""" + listed: Boolean! +""" +Curated listing history for this vault: the chronological sequence of `Added` and `Removed` transitions from morpho-blue-api-metadata. Empty if the vault was never listed. +""" + listingHistory: [VaultListingHistoryEvent!]! +""" +A vault V1 is featured via internal, manual review. +""" + featured: Boolean! +""" +The vault's factory. +""" + factory: VaultFactory! +""" +The current state of the vault. +""" + state: VaultState +""" +The historical state of the vault. +""" + historicalState: VaultHistory! + liquidity: VaultLiquidity + warnings: [VaultWarning!]! +""" +Public allocator configuration +""" + publicAllocatorConfig: PublicAllocatorConfig +""" +Vault allocators +""" + allocators: [VaultAllocator!]! +""" +Vault admin events on the vault +""" + adminEvents( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: VaultAdminEventsFilters + ): PaginatedVaultAdminEvent + metadata: VaultMetadata +} + +interface AssetReference { + asset: Asset! +} + +""" +Filtering options for vault admin events. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input VaultAdminEventsFilters { +""" +Filtering options for vault admin events. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + type_in: [String!] +} + +type PaginatedAssets { + items: [Asset!] + pageInfo: PageInfo +} + +type BigIntDataPoint { + x: Float! + y: BigInt +} + +type FloatDataPoint { + x: Float! + y: Float +} + +type IntDataPoint { + x: Float! + y: Int +} + +""" +Block +""" +type Block { + id: ID! + number: BigInt! + timestamp: BigInt! +} + +""" +Chain +""" +type Chain { + id: Int! + network: String! + currency: String! +""" +Block time in milliseconds +""" + blockTimeMs: Int +""" +Latest block of the chain +""" + headBlock: Block +} + +""" +Vault curator state +""" +type CuratorState { + curatorId: ID! +""" +Assets Under Management. Total assets managed by the curator, in USD for display purpose. +""" + aum: Float! +} + +""" +Curator Address +""" +type CuratorAddress { + chainId: Int! + address: String! +""" +Additional information about the address. +""" + metadata( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedAddressMetadata +} + +""" +Vault curator +""" +type Curator { + id: ID! + name: String! + description: String + verified: Boolean! +""" +Curator logo URI, for display purpose +""" + image: String +""" +Link to curator website +""" + url: String @deprecated(reason: "Use socials instead.") + socials: [CuratorSocial!]! + addresses: [CuratorAddress!]! + ownerOnly: Boolean! +""" +Current state +""" + state: CuratorState +} + +type CuratorSocial { + type: String! + url: String! +} + +type PaginatedCurators { + items: [Curator!] + pageInfo: PageInfo +} + +""" +Morpho Blue market state rewards +""" +type MarketStateReward implements AssetReference{ +""" +The asset. +""" + asset: Asset! +""" +Amount of reward tokens per year on the supply side. Scaled to reward asset decimals. +""" + yearlySupplyTokens: BigInt! @deprecated(reason: "Deprecated.") +""" +Amount of reward tokens per year on the borrow side. Scaled to reward asset decimals. +""" + yearlyBorrowTokens: BigInt! @deprecated(reason: "Deprecated.") +""" +Supply rewards APR. +""" + supplyApr: Float +""" +Borrow rewards APR. +""" + borrowApr: Float +""" +Amount of reward tokens per supplied token (annualized). Scaled to reward asset decimals. +""" + amountPerSuppliedToken: BigInt! @deprecated(reason: "Deprecated.") +""" +Amount of reward tokens per borrowed token (annualized). Scaled to reward asset decimals. +""" + amountPerBorrowedToken: BigInt! @deprecated(reason: "Deprecated.") + id: ID! +} + +""" +Morpho Blue market state +""" +type MarketState { +""" +Block number of the state +""" + blockNumber: BigInt! +""" +Amount borrowed on the market, in underlying units. Amount increases as interests accrue. +""" + borrowAssets: BigInt! +""" +Amount supplied on the market, in underlying units. Amount increases as interests accrue. +""" + supplyAssets: BigInt! +""" +Amount borrowed on the market, in USD for display purpose +""" + borrowAssetsUsd: Float +""" +Amount supplied on the market, in USD for display purpose +""" + supplyAssetsUsd: Float +""" +Amount borrowed on the market, in market share units. Amount does not increase as interest accrue. +""" + borrowShares: BigInt! +""" +Amount supplied on the market, in market share units. Amount does not increase as interest accrue. +""" + supplyShares: BigInt! +""" +Amount of collateral in the market, in underlying units +""" + collateralAssets: BigInt +""" +Amount of collateral in the market, in USD for display purpose +""" + collateralAssetsUsd: Float +""" +Utilization rate +""" + utilization: Float! +""" +Apy at target utilization +""" + apyAtTarget: Float! +""" +Rate at target utilization +""" + rateAtTarget: BigInt +""" +Instantaneous Supply APY +""" + supplyApy: Float! +""" +Instantaneous Borrow APY +""" + borrowApy: Float! +""" +Instantaneous Supply APY including rewards +""" + netSupplyApy: Float +""" +Instantaneous Borrow APY including rewards +""" + netBorrowApy: Float +""" +Last update timestamp. +""" + timestamp: BigInt! +""" +6h average supply APY excluding rewards (6h timeframe is subject to change). +""" + avgSupplyApy: Float +""" +6h average borrow APY excluding rewards (6h timeframe is subject to change). +""" + avgBorrowApy: Float +""" +Daily Supply APY excluding rewards +""" + dailySupplyApy: Float +""" +Daily Borrow APY excluding rewards +""" + dailyBorrowApy: Float + id: ID! +""" +Block information +""" + block: Block! +""" +Collateral price +""" + price: BigInt +""" +Market state rewards +""" + rewards: [MarketStateReward!]! +""" +Market collateral price change percentage (24h). Null if there is no historical data +""" + dailyPriceVariation: Float +""" +Fee rate +""" + fee: Float! +""" +Amount available to borrow on the market, in underlying units +""" + liquidityAssets: BigInt! +""" +Amount available to borrow on the market, in USD for display purpose +""" + liquidityAssetsUsd: Float +""" +Total size of the market. This is the sum of all assets that are allocated or can be reallocated to this market. +""" + size: BigInt! +""" +Total size of the market. This is the sum of all assets that are allocated or can be reallocated to this market, in USD for display purpose. +""" + sizeUsd: Float +""" +Amount available to borrow on the market, including shared liquidity. +""" + totalLiquidity: BigInt! +""" +Amount available to borrow on the market, including shared liquidity, in USD for display purpose. +""" + totalLiquidityUsd: Float +""" +6h average supply APY including rewards (6h timeframe is subject to change). +""" + avgNetSupplyApy: Float +""" +6h average borrow APY including rewards (6h timeframe is subject to change). +""" + avgNetBorrowApy: Float +""" +Daily Supply APY including rewards +""" + dailyNetSupplyApy: Float +""" +Daily Borrow APY including rewards +""" + dailyNetBorrowApy: Float +""" +Weekly Supply APY excluding rewards +""" + weeklySupplyApy: Float +""" +Weekly Supply APY including rewards +""" + weeklyNetSupplyApy: Float +""" +Weekly Borrow APY excluding rewards +""" + weeklyBorrowApy: Float +""" +Weekly Borrow APY including rewards +""" + weeklyNetBorrowApy: Float +""" +Biweekly Supply APY excluding rewards +""" + biweeklySupplyApy: Float +""" +Biweekly Supply APY including rewards +""" + biweeklyNetSupplyApy: Float +""" +Biweekly Borrow APY excluding rewards +""" + biweeklyBorrowApy: Float +""" +Biweekly Borrow APY including rewards +""" + biweeklyNetBorrowApy: Float +""" +Monthly Supply APY excluding rewards +""" + monthlySupplyApy: Float +""" +Monthly Supply APY including rewards +""" + monthlyNetSupplyApy: Float +""" +Monthly Borrow APY excluding rewards +""" + monthlyBorrowApy: Float +""" +Monthly Borrow APY including rewards +""" + monthlyNetBorrowApy: Float +""" +Quarterly Supply APY excluding rewards +""" + quarterlySupplyApy: Float +""" +Quarterly Supply APY including rewards +""" + quarterlyNetSupplyApy: Float +""" +Quarterly Borrow APY excluding rewards +""" + quarterlyBorrowApy: Float +""" +Quarterly Borrow APY including rewards +""" + quarterlyNetBorrowApy: Float +""" +Yearly Supply APY excluding rewards +""" + yearlySupplyApy: Float +""" +Yearly Supply APY including rewards +""" + yearlyNetSupplyApy: Float +""" +Yearly Borrow APY excluding rewards +""" + yearlyBorrowApy: Float +""" +Yearly Borrow APY including rewards +""" + yearlyNetBorrowApy: Float +""" +All Time Supply APY excluding rewards +""" + allTimeSupplyApy: Float +""" +All Time Supply APY including rewards +""" + allTimeNetSupplyApy: Float +""" +All Time Borrow APY excluding rewards +""" + allTimeBorrowApy: Float +""" +All Time Borrow APY including rewards +""" + allTimeNetBorrowApy: Float +} + +""" +Market state history +""" +type MarketHistory { + id: ID! +""" +Amount borrowed on the market, in underlying units. Amount increases as interests accrue. +""" + borrowAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Amount supplied on the market, in underlying units. Amount increases as interests accrue. +""" + supplyAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Amount borrowed on the market, in USD for display purpose +""" + borrowAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Amount supplied on the market, in USD for display purpose +""" + supplyAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Amount borrowed on the market, in market share units. Amount does not increase as interest accrue. +""" + borrowShares( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Amount supplied on the market, in market share units. Amount does not increase as interest accrue. +""" + supplyShares( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Utilization rate +""" + utilization( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Amount available to borrow on the market, in underlying units +""" + liquidityAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Amount available to borrow on the market, in USD for display purpose +""" + liquidityAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Amount of collateral in the market, in underlying units +""" + collateralAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Amount of collateral in the market, in USD for display purpose +""" + collateralAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +AdaptiveCurveIRM rate per second if utilization was at target +""" + rateAtTarget( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +AdaptiveCurveIRM APY if utilization was at target +""" + apyAtTarget( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Supply APY excluding rewards +""" + supplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Supply APY including rewards +""" + netSupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Borrow APY including rewards +""" + netBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Borrow APY excluding rewards +""" + borrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Fee rate +""" + fee( + options: TimeseriesOptions + ): [FloatDataPoint!] @deprecated(reason: "Deprecated.") +""" +Collateral price +""" + price( + options: TimeseriesOptions + ): [FloatDataPoint!]! @deprecated(reason: "Deprecated.") +""" +Daily Supply APY excluding rewards +""" + dailySupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Daily Supply APY including rewards +""" + dailyNetSupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Daily Borrow APY excluding rewards +""" + dailyBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Daily Borrow APY including rewards +""" + dailyNetBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Weekly Supply APY excluding rewards +""" + weeklySupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Weekly Supply APY including rewards +""" + weeklyNetSupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Weekly Borrow APY excluding rewards +""" + weeklyBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Weekly Borrow APY including rewards +""" + weeklyNetBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Monthly Supply APY excluding rewards +""" + monthlySupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Monthly Supply APY including rewards +""" + monthlyNetSupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Monthly Borrow APY excluding rewards +""" + monthlyBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Monthly Borrow APY including rewards +""" + monthlyNetBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Quarterly Supply APY excluding rewards +""" + quarterlySupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Quarterly Supply APY including rewards +""" + quarterlyNetSupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Quarterly Borrow APY excluding rewards +""" + quarterlyBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Quarterly Borrow APY including rewards +""" + quarterlyNetBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Yearly Supply APY excluding rewards +""" + yearlySupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Yearly Supply APY including rewards +""" + yearlyNetSupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Yearly Borrow APY excluding rewards +""" + yearlyBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Yearly Borrow APY including rewards +""" + yearlyNetBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +All Time Supply APY excluding rewards +""" + allTimeSupplyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +All Time Borrow APY excluding rewards +""" + allTimeBorrowApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +} + +""" +Morpho Blue state history +""" +type MorphoBlueStateHistory { +""" +Amount of collateral in all markets, in USD for display purpose. +""" + totalCollateralUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Amount supplied in all markets, in USD for display purpose +""" + totalSupplyUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Amount deposited in all markets, in USD for display purpose +""" + totalDepositUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Amount borrowed in all markets, in USD for display purpose +""" + totalBorrowUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +TVL (collateral + supply - borrows), in USD for display purpose +""" + tvlUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Number of unique users that have interacted with the protocol +""" + userCount( + options: TimeseriesOptions + ): [IntDataPoint!]! +""" +Number of markets in the protocol +""" + marketCount( + options: TimeseriesOptions + ): [IntDataPoint!]! +""" +Number of meta morpho vaults in the protocol +""" + vaultCount( + options: TimeseriesOptions + ): [IntDataPoint!]! +} + +""" +Morpho Blue global state +""" +type MorphoBlueState { + id: ID! +""" +Last update timestamp. +""" + timestamp: BigInt! +""" +Amount of collateral in all markets, in USD for display purpose +""" + totalCollateralUsd: Float! +""" +Amount supplied in all markets, in USD for display purpose +""" + totalSupplyUsd: Float! +""" +Amount deposited in all markets, in USD for display purpose +""" + totalDepositUsd: Float! +""" +Amount borrowed in all markets, in USD for display purpose +""" + totalBorrowUsd: Float! +""" +TVL (collateral + supply - borrows), in USD for display purpose +""" + tvlUsd: Float! +""" +Number of unique users that have interacted with the protocol +""" + userCount: Int! +""" +Number of markets in the protocol +""" + marketCount: Int! +""" +Number of meta morpho vaults in the protocol +""" + vaultCount: Int! +} + +""" +Morpho Blue deployment +""" +type MorphoBlue implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! + creationBlockNumber: Int! +""" +Current state +""" + state: MorphoBlueState +""" +State history +""" + historicalState: MorphoBlueStateHistory +} + +""" +Oracle creation tx +""" +type ChainlinkOracleV2Event { + txHash: HexString! + timestamp: BigInt! + blockNumber: BigInt! + chainId: Int! +""" +Transaction caller address +""" + caller: Address! +} + +""" +Hexadecimal string +""" +scalar HexString + +""" +Oracle Feed +""" +type OracleFeed implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! +""" +Feed contract address +""" + address: Address! + description: String @deprecated(reason: "Deprecated.") + vendor: String @deprecated(reason: "Deprecated.") + pair: [String!] @deprecated(reason: "Deprecated.") + decimals: Int + historicalPrice: [BigIntDataPoint!] @deprecated(reason: "Deprecated.") + price: BigIntDataPoint @deprecated(reason: "Deprecated.") +} + +""" +Oracle Vault +""" +type OracleVault implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! +""" +Vault contract address +""" + address: Address! + vendor: String @deprecated(reason: "Deprecated.") + pair: [String!] @deprecated(reason: "Deprecated.") + decimals: Int @deprecated(reason: "Deprecated.") + price: BigIntDataPoint + historicalPrice: [BigIntDataPoint!] +""" +Underlying asset id. +""" + assetId: String +""" +Linked vault id. +""" + metamorphoId: String +} + +""" +Oracle +""" +type Oracle implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! +""" +Oracle contract address +""" + address: Address! +""" +Oracle type +""" + type: OracleType! + data: OracleData + creationEvent: ChainlinkOracleV2Event + markets: [Market!]! +} + +enum OracleType { + ChainlinkOracle + ChainlinkOracleV2 + CustomOracle + Unknown +} + +union OracleData =MorphoChainlinkOracleData | MorphoChainlinkOracleV2Data + +""" +Morpho chainlink oracle data +""" +type MorphoChainlinkOracleData { + baseFeedOne: OracleFeed + baseFeedTwo: OracleFeed + quoteFeedOne: OracleFeed + quoteFeedTwo: OracleFeed + scaleFactor: BigInt! + chainId: Int! + baseOracleVault: OracleVault + vaultConversionSample: BigInt! +} + +""" +Morpho chainlink oracle v2 data +""" +type MorphoChainlinkOracleV2Data { + baseFeedOne: OracleFeed + baseFeedTwo: OracleFeed + quoteFeedOne: OracleFeed + quoteFeedTwo: OracleFeed + scaleFactor: BigInt! + baseOracleVault: OracleVault + quoteOracleVault: OracleVault + chainId: Int! + baseVaultConversionSample: BigInt! + quoteVaultConversionSample: BigInt! +} + +""" +Public allocator shared liquidity +""" +type PublicAllocatorSharedLiquidity { + assets: BigInt! + id: ID! + publicAllocator: PublicAllocator! + withdrawMarket: Market! + supplyMarket: Market! + vault: Vault! +} + +""" +MetaMorpho vault state rewards +""" +type VaultStateReward implements AssetReference{ +""" +The asset. +""" + asset: Asset! +""" +Amount of reward tokens distributed to MetaMorpho vault suppliers (annualized). Scaled to reward asset decimals. +""" + yearlySupplyTokens: BigInt! @deprecated(reason: "Deprecated.") +""" +Rewards APR. +""" + supplyApr: Float +""" +Amount of reward tokens earned per supplied token (annualized). Scaled to reward asset decimals. +""" + amountPerSuppliedToken: BigInt! @deprecated(reason: "Deprecated.") +} + +""" +Market position +""" +type MarketPosition { + id: ID! +""" +Health factor of the position, computed as collateral value divided by borrow value. +""" + healthFactor: Float + listed: Boolean! +""" +Price variation required for the given position to reach its liquidation threshold (scaled by WAD) +""" + priceVariationToLiquidationPrice: Float + market: Market! + user: User! +""" +Current state +""" + state: MarketPositionState +""" +State history +""" + historicalState: MarketPositionHistory +} + +type PaginatedMarketPositions { + items: [MarketPosition!] + pageInfo: PageInfo +} + +""" +MetaMorpho vault position +""" +type VaultPosition { + id: ID! + listed: Boolean! + vault: Vault! + user: User! +""" +Current state +""" + state: VaultPositionState + historicalState: VaultPositionHistory +} + +type MetaMorphoAdapterFactory implements VaultV2AdapterFactory & ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! + creationBlockNumber: BigInt! +} + +interface VaultV2AdapterFactory { + chain: Chain! + id: ID! + address: Address! + creationBlockNumber: BigInt! +} + +type MetaMorphoAdapter implements VaultV2Adapter & ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! +""" +Block number at which the adapter was added to the vault. If the adapter was removed and re-added, this reflects the most recent addition. +""" + creationBlockNumber: BigInt! +""" +Timestamp at which the adapter was added to the vault. If the adapter was removed and re-added, this reflects the most recent addition. +""" + creationTimestamp: BigInt! + type: VaultV2AdapterType! + vault: VaultV2! + factory: VaultV2AdapterFactory! +""" +The assets managed by the adapter (includes virtually accrued interest). +""" + assets: BigInt! +""" +The USD value of assets managed by the adapter (includes virtually accrued interest). +""" + assetsUsd: Float +""" +The current active force deallocate penalty for this adapter. Returns 0 if unset. +""" + forceDeallocatePenalty: BigInt! + metaMorpho: Vault! + position: VaultPosition +} + +interface VaultV2Adapter { + chain: Chain! + id: ID! + address: Address! + creationBlockNumber: BigInt! + creationTimestamp: BigInt! + type: VaultV2AdapterType! + vault: VaultV2! + factory: VaultV2AdapterFactory! + assets: BigInt! + assetsUsd: Float + forceDeallocatePenalty: BigInt! +} + +enum VaultV2AdapterType { + MetaMorpho + MorphoVaultV2 + MorphoMarketV1 +} + +type MorphoVaultV2Adapter implements VaultV2Adapter & ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! +""" +Block number at which the adapter was added to the vault. If the adapter was removed and re-added, this reflects the most recent addition. +""" + creationBlockNumber: BigInt! +""" +Timestamp at which the adapter was added to the vault. If the adapter was removed and re-added, this reflects the most recent addition. +""" + creationTimestamp: BigInt! + type: VaultV2AdapterType! + vault: VaultV2! + factory: VaultV2AdapterFactory! +""" +The assets managed by the adapter (includes virtually accrued interest). +""" + assets: BigInt! +""" +The USD value of assets managed by the adapter (includes virtually accrued interest). +""" + assetsUsd: Float +""" +The current active force deallocate penalty for this adapter. Returns 0 if unset. +""" + forceDeallocatePenalty: BigInt! +""" +The inner VaultV2 that this adapter wraps. +""" + innerVault: VaultV2! +} + +type MorphoMarketV1Adapter implements VaultV2Adapter & ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! +""" +Block number at which the adapter was added to the vault. If the adapter was removed and re-added, this reflects the most recent addition. +""" + creationBlockNumber: BigInt! +""" +Timestamp at which the adapter was added to the vault. If the adapter was removed and re-added, this reflects the most recent addition. +""" + creationTimestamp: BigInt! + type: VaultV2AdapterType! + vault: VaultV2! + factory: VaultV2AdapterFactory! +""" +The assets managed by the adapter (includes virtually accrued interest). +""" + assets: BigInt! +""" +The USD value of assets managed by the adapter (includes virtually accrued interest). +""" + assetsUsd: Float +""" +The current active force deallocate penalty for this adapter. Returns 0 if unset. +""" + forceDeallocatePenalty: BigInt! + positions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedMarketPositions! +} + +type PaginatedVaultV2Adapters { + items: [VaultV2Adapter!] + pageInfo: PageInfo +} + +""" +Market parameters +""" +type MarketParams { + id: HexString! + loanToken: Address! + collateralToken: Address! + oracle: Address! + irm: Address! + lltv: BigInt! +} + +type VaultV2CapConfig { + id: HexString! + idData: HexString! + type: VaultV2CapType! + data: VaultV2CapData +} + +enum VaultV2CapType { + Adapter + Collateral + MarketV1 + Unknown +} + +union VaultV2CapData =AdapterCapData | CollateralCapData | MarketV1CapData + +""" +Adapter cap data +""" +type AdapterCapData implements ActiveAdapterData{ + adapterAddress: Address! +""" +The adapter. Null if the adapter is not recognized. +""" + adapter: VaultV2Adapter +} + +interface ActiveAdapterData { + adapterAddress: Address! + adapter: VaultV2Adapter +} + +""" +Collateral cap data +""" +type CollateralCapData { + collateralAddress: Address! +""" +The collateral asset to which this cap is associated. Null if the asset is not recognized. +""" + collateralToken: Asset +} + +""" +Market V1 cap data +""" +type MarketV1CapData implements ActiveAdapterData{ + adapterAddress: Address! +""" +The adapter. Null if the adapter is not recognized. +""" + adapter: VaultV2Adapter + marketParams: MarketParams! +""" +The market to which this cap is associated. Null if the market is not recognized. +""" + market: Market +} + +""" +Vault V2 caps +""" +type VaultV2Caps { + id: HexString! + idData: HexString! + type: VaultV2CapType! + data: VaultV2CapData + absoluteCap: BigInt! + relativeCap: BigInt! +""" +Assets allocation of the Cap. Note that the allocation is not always up to date, because interest and losses are accounted only when (de)allocating in the corresponding adapters. +""" + allocation: BigInt! +} + +type PaginatedVaultV2Caps { + items: [VaultV2Caps!] + pageInfo: PageInfo +} + +""" +Transaction +""" +type Transaction implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + timestamp: BigInt! + hash: HexString! + logIndex: Int! + blockNumber: BigInt! + type: TransactionType! + data: TransactionData! + user: User! +} + +enum TransactionType { + MetaMorphoDeposit + MetaMorphoWithdraw + MetaMorphoTransfer + MetaMorphoFee + MarketBorrow + MarketLiquidation + MarketRepay + MarketSupply + MarketSupplyCollateral + MarketWithdraw + MarketWithdrawCollateral +} + +union TransactionData =VaultTransactionData | MarketCollateralTransferTransactionData | MarketTransferTransactionData | MarketLiquidationTransactionData + +""" +Morpho Vault V1 transaction data +""" +type VaultTransactionData { + shares: BigInt! + assets: BigInt! + timestamp: BigInt! + vault: Vault! + assetsUsd: Float +} + +""" +Market collateral transfer transaction data +""" +type MarketCollateralTransferTransactionData { + assets: BigInt! + timestamp: BigInt! + market: Market! + assetsUsd: Float +} + +""" +Market transfer transaction data +""" +type MarketTransferTransactionData { + shares: BigInt! + assets: BigInt! + timestamp: BigInt! + market: Market! + assetsUsd: Float +} + +""" +Market liquidation transaction data +""" +type MarketLiquidationTransactionData { + repaidAssets: BigInt! + repaidShares: BigInt! + seizedAssets: BigInt! + badDebtShares: BigInt! + badDebtAssets: BigInt! + liquidator: Address! + timestamp: BigInt! + market: Market! + repaidAssetsUsd: Float + seizedAssetsUsd: Float + badDebtAssetsUsd: Float +} + +""" +User state history +""" +type UserHistory { +""" +Total value of all the user's vault positions, in USD. +""" + vaultsAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Total value of all the user's VaultV2 positions, in USD. +""" + vaultV2sAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Total collateral of all the user's market positions, in USD. +""" + marketsCollateralUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Total supply assets of all the user's market positions, in USD. +""" + marketsSupplyAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Total borrow assets of all the user's market positions, in USD. +""" + marketsBorrowAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Total margin of all the user's market positions, in USD. +""" + marketsMarginUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +} + +""" +User state +""" +type UserState { +""" +Total value of all the user's vault positions, in USD. +""" + vaultsAssetsUsd: Float! +""" +Total value of all the user's VaultV2 positions, in USD. +""" + vaultV2sAssetsUsd: Float! +""" +Total collateral value of all the user's market positions, in USD. +""" + marketsCollateralUsd: Float! +""" +Total supply assets value of all the user's market positions, in USD. +""" + marketsSupplyAssetsUsd: Float! +""" +Total borrow assets value of all the user's market positions, in USD. +""" + marketsBorrowAssetsUsd: Float! +""" +Total margin (collateral - borrow) of all the user's market positions, in USD. +""" + marketsMarginUsd: Float! +} + +""" +User +""" +type User implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! @deprecated(reason: "Use address and chainId instead.") + address: Address! + tag: String @deprecated(reason: "Deprecated.") + marketPositions: [MarketPosition!]! + vaultPositions: [VaultPosition!]! + vaultV2Positions: [VaultV2Position!]! + transactions: [Transaction!]! @deprecated(reason: "Use vaultV1Transactions or marketTransactions instead.") + state: UserState! + historicalState: UserHistory! +} + +""" +Vault V2 position history +""" +type VaultV2PositionHistory { +""" +Vault shares history. +""" + shares( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Assets history, in underlying token. +""" + assets( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Assets history, in USD. +""" + assetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!] +} + +type VaultV2Position implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + user: User! + vault: VaultV2! +""" +Amount of vault shares +""" + shares: BigInt! +""" +Value of vault shares held, in underlying token units. +""" + assets: BigInt! +""" +Value of vault shares held, in USD. +""" + assetsUsd: Float +""" +Timeseries history for each of this position's stats. +""" + history: VaultV2PositionHistory! +""" +Profit & Loss of the position (due to interest and bad debt) since its inception, in loan assets. +""" + pnl: BigInt +""" +Profit & Loss of the position since its inception, quoted in USD using the asset's latest price. +""" + pnlUsd: Float +""" +Time-Weighted Average Return of the position since its inception (non-annualized). +""" + roe: Float +} + +type PaginatedVaultV2Positions { + items: [VaultV2Position!] + pageInfo: PageInfo +} + +""" +Vault V2 historical allocation data per cap +""" +type VaultV2HistoricalCaps { +""" +The cap this allocation refers to +""" + cap: VaultV2Caps +""" +Allocated assets in this cap, in vault asset units +""" + allocation: [BigIntDataPoint!]! +""" +Allocated assets in USD for display purpose +""" + allocationUsd: [FloatDataPoint!]! +""" +Absolute cap limit for this cap, in vault asset units +""" + absoluteCap: [BigIntDataPoint!]! +""" +Relative cap limit for this cap, in vault asset units +""" + relativeCap: [BigIntDataPoint!]! +""" +Relative allocation (allocation / totalAssets) +""" + relativeAllocation: [FloatDataPoint!]! +} + +type PaginatedVaultV2HistoricalCaps { + items: [VaultV2HistoricalCaps!] + pageInfo: PageInfo +} + +""" +Vault V2 history +""" +type VaultV2History { +""" +Total value of vault holdings, in underlying token units. +""" + totalAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Total value of vault holdings, in USD for display purpose. +""" + totalAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Vault shares total supply. +""" + totalSupply( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Real assets in the vault (excluding virtual accrual). +""" + realAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Real assets in USD for display purpose. +""" + realAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +The assets deposited to the vault that are not generating interest. +""" + idleAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Idle assets in USD for display purpose. +""" + idleAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Value of shares quoted in assets +""" + sharePrice( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Average APY computed from share price evolution over a lookback period (1-24 hours). Returns annualized compound rate. +""" + avgApy( + options: TimeseriesOptions +""" +Number of hours to look back for APY calculation (1-24) +""" + lookbackHours: Int + ): [FloatDataPoint!]! +""" +Average Net APY computed from share price evolution over a lookback period (1-24 hours). Returns annualized compound rate. Includes rewards and deductedfees. +""" + avgNetApy( + options: TimeseriesOptions +""" +Number of hours to look back for APY calculation (1-24) +""" + lookbackHours: Int + ): [FloatDataPoint!]! +""" +Historical allocation data grouped by caps. Returns allocation timeseries for requested caps within the requested timerange. +""" + caps( + options: TimeseriesOptions +""" +Filter allocations by cap types. Defaults to all cap types if not provided. +""" + capType_in: [VaultV2CapType!] + ): PaginatedVaultV2HistoricalCaps! +} + +type TimelockFailedCheckVaultV2WarningMetadata { + functionName: String! + currentTimelock: BigInt! + requiredTimelock: BigInt! +} + +type VaultV2ListingMetadataHistoryChange { + action: String! + timestamp: Float! +} + +type VaultV2Warning { + type: String! + level: VaultV2WarningLevel! + metadata: VaultV2WarningMetadata +} + +""" +Warning level for Vault V2 warnings. GREEN indicates passing checks, YELLOW indicates caution, RED indicates danger. +""" +enum VaultV2WarningLevel { + YELLOW + RED + GREEN +} + +union VaultV2WarningMetadata =UnrecognizedAssetVaultWarningMetadata | TimelockVaultV2WarningMetadata | NotWhitelistedVaultV2WarningMetadata | CustomMetadata + +type UnrecognizedAssetVaultWarningMetadata implements AssetReference{ +""" +The asset. +""" + asset: Asset! +} + +type TimelockVaultV2WarningMetadata { + failedChecks: [TimelockFailedCheckVaultV2WarningMetadata!]! +} + +type NotWhitelistedVaultV2WarningMetadata { + history: [VaultV2ListingMetadataHistoryChange!]! +} + +type CustomMetadata { + content: String +} + +""" +Vault V2 allocator +""" +type VaultV2Allocator { +""" +Allocator account. +""" + allocator: Account! +""" +Allocator since block number +""" + blockNumber: BigInt! +""" +Allocator since timestamp +""" + timestamp: BigInt! +} + +type VaultV2PendingConfig { +""" +Timestamp at which the pending config can be applied +""" + validAt: BigInt! + functionName: VaultV2TimelockedFunctionName! +""" +Raw timelocked function data +""" + data: HexString! + decodedData: VaultV2PendingConfigDecodedData! +""" +Transaction hash that submitted the pending action +""" + txHash: HexString! +} + +enum VaultV2TimelockedFunctionName { + SetIsAllocator + SetReceiveSharesGate + SetSendSharesGate + SetReceiveAssetsGate + SetSendAssetsGate + SetAdapterRegistry + AddAdapter + RemoveAdapter + IncreaseTimelock + DecreaseTimelock + SetPerformanceFee + SetManagementFee + SetPerformanceFeeRecipient + SetManagementFeeRecipient + IncreaseAbsoluteCap + IncreaseRelativeCap + SetForceDeallocatePenalty + Abdicate +} + +union VaultV2PendingConfigDecodedData =VaultV2SetIsAllocatorPendingData | VaultV2SetReceiveSharesGatePendingData | VaultV2SetSendSharesGatePendingData | VaultV2SetReceiveAssetsGatePendingData | VaultV2SetSendAssetsGatePendingData | VaultV2SetAdapterRegistryPendingData | VaultV2AdapterPendingData | VaultV2TimelockPendingData | VaultV2SetPerformanceFeePendingData | VaultV2SetManagementFeePendingData | VaultV2SetPerformanceFeeRecipientPendingData | VaultV2SetManagementFeeRecipientPendingData | VaultV2IncreaseCapPendingData | VaultV2SetForceDeallocatePenaltyPendingData | VaultV2AbdicatePendingData + +type VaultV2SetIsAllocatorPendingData { +""" +Pending allocator status +""" + isAllocator: Boolean! +""" +Allocator account. +""" + account: Account! +} + +type VaultV2SetReceiveSharesGatePendingData { +""" +Pending receive shares gate +""" + receiveSharesGate: Address! +} + +type VaultV2SetSendSharesGatePendingData { +""" +Pending send shares gate +""" + sendSharesGate: Address! +} + +type VaultV2SetReceiveAssetsGatePendingData { +""" +Pending receive assets gate +""" + receiveAssetsGate: Address! +} + +type VaultV2SetSendAssetsGatePendingData { +""" +Pending send assets gate +""" + sendAssetsGate: Address! +} + +type VaultV2SetAdapterRegistryPendingData { +""" +Pending adapter registry +""" + adapterRegistry: Address! +} + +type VaultV2AdapterPendingData implements ActiveAdapterData{ + adapterAddress: Address! +""" +The adapter. Null if the adapter is not recognized. +""" + adapter: VaultV2Adapter +} + +type VaultV2TimelockPendingData { +""" +Pending timelock duration +""" + timelock: BigInt! +""" +Function selector +""" + selector: HexString! +""" +Function name +""" + functionName: String! +} + +type VaultV2SetPerformanceFeePendingData { +""" +Pending performance fee +""" + performanceFee: BigInt! +} + +type VaultV2SetManagementFeePendingData { +""" +Pending management fee +""" + managementFee: BigInt! +} + +type VaultV2SetPerformanceFeeRecipientPendingData { +""" +Pending performance fee recipient +""" + performanceFeeRecipient: Address! +} + +type VaultV2SetManagementFeeRecipientPendingData { +""" +Management fee recipient +""" + managementFeeRecipient: Address! +} + +type VaultV2IncreaseCapPendingData { +""" +Pending absolute/relative cap +""" + cap: BigInt! + config: VaultV2CapConfig! +} + +type VaultV2SetForceDeallocatePenaltyPendingData implements ActiveAdapterData{ + adapterAddress: Address! +""" +The adapter. Null if the adapter is not recognized. +""" + adapter: VaultV2Adapter +""" +Pending force deallocate penalty +""" + forceDeallocatePenalty: BigInt! +} + +type VaultV2AbdicatePendingData { +""" +Function selector +""" + selector: HexString! +""" +Function name +""" + functionName: String! +} + +type PaginatedVaultV2PendingConfig { + items: [VaultV2PendingConfig!] + pageInfo: PageInfo +} + +""" +Vault V2 sentinel +""" +type VaultV2Sentinel { +""" +Sentinel account. +""" + sentinel: Account! +""" +Sentinel since block number +""" + blockNumber: BigInt! +""" +Sentinel since timestamp +""" + timestamp: BigInt! +} + +""" +Vault V2 allocator +""" +type VaultV2Timelock { +""" +Targeted selector +""" + selector: HexString! +""" +Targeted function +""" + functionName: String! +""" +Duration of the timelock +""" + duration: BigInt! +""" +Last updated at block number +""" + blockNumber: BigInt! +""" +Last updated at timestamp +""" + timestamp: BigInt! +""" +The timestamp the function was abdicated at, null if not abdicated +""" + abdicatedAt: BigInt +} + +type VaultV2 implements AssetReference & ChainReference{ +""" +The asset. +""" + asset: Asset! +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! + name: String! + symbol: String! +""" +Curators operating on this vault +""" + curators( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedCurators! + curator: Account! + owner: Account! + creationBlockNumber: BigInt! + creationTimestamp: BigInt! + adapters( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedVaultV2Adapters! + liquidityAdapter: VaultV2Adapter +""" +Total assets deposited to the vault. At the moment, interest is not virtually accrued +""" + totalAssets: BigInt + totalSupply: BigInt! +""" +Total assets deposited to the vault. At the moment, interest is not virtually accrued +""" + totalAssetsUsd: Float +""" +The assets deposited to the vault that are not generating interest. +""" + idleAssets: BigInt! +""" +The USD value of assets deposited to the vault that are not generating interest. +""" + idleAssetsUsd: Float +""" +The liquidity available from the liquidity adapter + idle assets. +""" + liquidity: BigInt! +""" +The USD value of liquidity available from the liquidity adapter + idle assets. +""" + liquidityUsd: Float +""" +Value of shares quoted in assets +""" + sharePrice: Float! +""" +Rewards aggregated from all underlying adapters + vault specific campaigns. Each underlying rewards are weighted by the adapter's asset allocation. +""" + rewards: [VaultStateReward!]! + performanceFee: Float! + performanceFeeRecipient: Address! +""" +Annual management fee rate (unitless fraction, e.g., 0.025 for 2.5%) +""" + managementFee: Float! + managementFeeRecipient: Address! +""" +Max rate per second +""" + maxRate: BigInt! +""" +Max APY +""" + maxApy: Float! + allocators: [VaultV2Allocator!]! + caps( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedVaultV2Caps! + sentinels: [VaultV2Sentinel!]! + timelocks: [VaultV2Timelock!]! +""" +Historical state data of the vault +""" + historicalState: VaultV2History! + warnings( + where: VaultV2WarningsFilters + ): [VaultV2Warning!]! + positions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: VaultV2PositionFilters + ): PaginatedVaultV2Positions! + pendingConfigs( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int +""" +Filter pending config by function name. +""" + functionName_in: [VaultV2TimelockedFunctionName!] + ): PaginatedVaultV2PendingConfig! +""" +A VaultV2 is listed if the curator is listed and the vault passes our sanity checks. +""" + listed: Boolean! +""" +Curated listing history for this vault: the chronological sequence of `Added` and `Removed` transitions from morpho-blue-api-metadata. Empty if the vault was never listed. +""" + listingHistory: [VaultListingHistoryEvent!]! + factory: VaultV2Factory! + type: VaultV2Type +""" +Decoded liquidity data associated with the vault's liquidity adapter. +""" + liquidityData: VaultV2LiquidityData +""" +The free force-deallocatable liquidity (sum of direct liquidity from non-liquidity adapters with zero penalty). +""" + forceDeallocatableLiquidity: BigInt! +""" +The USD value of free force-deallocatable liquidity. +""" + forceDeallocatableLiquidityUsd: Float +""" +Realized average APY of the vault, calculated from share price evolution over a predefined lookback period. Uses normalized timestamps (rounded to hour/day/week boundaries) for optimal caching and performance. Available periods: 1h, 6h (default), 1d, 7d, 30d, 90d, 1y, or 'inception' for all-time APY. +""" + avgApy( +""" +Predefined lookback period for APY calculation. Using preset periods improves query performance through timestamp normalization. +""" + lookback: VaultV2LookbackPeriod + ): Float @deprecated(reason: "Use avgNetApyExcludingRewards instead.") +""" +Realized average net APY of the vault (after fees, with rewards), calculated from share price evolution over a predefined lookback period. Uses normalized timestamps (rounded to hour/day/week boundaries) for optimal caching and performance. Available periods: 1h, 6h (default), 1d, 7d, 30d, 90d, 1y, or 'inception' for all-time net APY. +""" + avgNetApy( +""" +Predefined lookback period for APY calculation. Using preset periods improves query performance through timestamp normalization. +""" + lookback: VaultV2LookbackPeriod + ): Float +""" +Realized average net APY of the vault after all fees (performance + management), excluding rewards. Derived from share price evolution over a predefined lookback period. +""" + avgNetApyExcludingRewards( +""" +Predefined lookback period for APY calculation. Using preset periods improves query performance through timestamp normalization. +""" + lookback: VaultV2LookbackPeriod + ): Float +""" +Current APY of the vault (before fees), derived from liquidity adapter rates. +""" + apy: Float +""" +Current net APY of the vault (after fees, including rewards), derived from liquidity adapter rates. +""" + netApy: Float +""" +Instantaneous net APY of the vault after all fees (performance + management), excluding rewards. +""" + netApyExcludingRewards: Float +""" +Full gate configuration including abdication and pending state. +""" + gatesConfig: VaultV2GatesConfig +""" +Performance fee configuration with abdication and pending state. +""" + performanceFeeConfig: VaultV2SelectorValueConfig! +""" +Management fee configuration with abdication and pending state. +""" + managementFeeConfig: VaultV2SelectorValueConfig! +""" +Performance fee recipient configuration with abdication and pending state. +""" + performanceFeeRecipientConfig: VaultV2SelectorAddressConfig! +""" +Management fee recipient configuration with abdication and pending state. +""" + managementFeeRecipientConfig: VaultV2SelectorAddressConfig! + metadata: VaultV2Metadata +} + +""" +Filtering options for vault V2 warnings. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input VaultV2WarningsFilters { +""" +Filtering options for vault V2 warnings. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + type_in: [String!] +""" +Filtering options for vault V2 warnings. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + level_in: [VaultV2WarningLevel!] +} + +""" +Filtering options for Vault V2 positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input VaultV2PositionFilters { +""" +Filtering options for Vault V2 positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_gte: BigInt +} + +""" +Type of VaultV2 +""" +enum VaultV2Type { + MorphoVault + FeeWrapper +} + +union VaultV2LiquidityData =MarketV1LiquidityData | MetaMorphoLiquidityData + +""" +Liquidity data for a MorphoMarketV1 adapter +""" +type MarketV1LiquidityData { +""" +The Morpho Blue market identified by this liquidity data. Null if the market is not recognized. +""" + market: Market +} + +""" +Liquidity data for a MetaMorpho (Vault V1) adapter +""" +type MetaMorphoLiquidityData { +""" +The MetaMorpho vault associated with this adapter. Null if the vault is not recognized. +""" + metaMorpho: Vault +} + +""" +Predefined lookback periods for vault APY calculations. Using these periods ensures better query performance through timestamp normalization and caching. +""" +enum VaultV2LookbackPeriod { +""" +1 hour lookback period +""" + ONE_HOUR +""" +6 hours lookback period (default) +""" + SIX_HOURS +""" +1 day (24 hours) lookback period +""" + ONE_DAY +""" +7 days (1 week) lookback period +""" + SEVEN_DAYS +""" +30 days (~1 month) lookback period +""" + THIRTY_DAYS +""" +90 days (~3 months) lookback period +""" + NINETY_DAYS +""" +1 year (365 days) lookback period +""" + ONE_YEAR +""" +Since vault inception (all-time) +""" + INCEPTION +} + +type MarketWarning { + type: String! + level: WarningLevel! + metadata: MarketWarningMetadata +} + +enum WarningLevel { + YELLOW + RED +} + +union MarketWarningMetadata =BadDebtRealizedMarketWarningMetadata | BadDebtUnrealizedMarketWarningMetadata | IncorrectOracleConfigurationMarketWarningMetadata | OraclePriceDerivationMarketWarningMetadata | UnrecognizedCollateralAssetMarketWarningMetadata | UnrecognizedLoanAssetMarketWarningMetadata | CustomMetadata + +type BadDebtRealizedMarketWarningMetadata { + badDebtUsd: Float + badDebtAssets: BigInt! + totalSupplyAssets: BigInt! + badDebtShare: Float! +} + +type BadDebtUnrealizedMarketWarningMetadata { + badDebtUsd: Float + badDebtAssets: BigInt! + totalSupplyAssets: BigInt! + badDebtShare: Float! +} + +type IncorrectOracleConfigurationMarketWarningMetadata { + type: String! + scaleFactor: BigInt + expectedScaleFactor: BigInt + expectedScaleFactorExponent: BigInt +} + +type OraclePriceDerivationMarketWarningMetadata { +""" +Oracle derivation warning subtype. +""" + type: String! +""" +Current on-chain oracle price, serialized as a bigint. +""" + onChainPrice: BigInt! +""" +Expected oracle price derived from USD reference prices. +""" + expectedPrice: BigInt! +""" +Ratio between on-chain and expected prices. +""" + deviationFactor: Float! +} + +type UnrecognizedCollateralAssetMarketWarningMetadata implements AssetReference{ +""" +The asset. +""" + asset: Asset! +} + +type UnrecognizedLoanAssetMarketWarningMetadata implements AssetReference{ +""" +The asset. +""" + asset: Asset! +} + +""" +Morpho Blue supply and borrow side concentrations +""" +type MarketConcentration { +""" +Borrowers Herfindahl-Hirschman Index +""" + supplyHhi: Float @deprecated(reason: "Deprecated.") +""" +Borrowers Herfindahl-Hirschman Index +""" + borrowHhi: Float @deprecated(reason: "Deprecated.") +} + +""" +Market APY aggregates +""" +type MarketApyAggregates { +""" +Average market supply APY excluding rewards +""" + supplyApy: Float +""" +Average market borrow APY excluding rewards +""" + borrowApy: Float +""" +Average market supply APY including rewards +""" + netSupplyApy: Float +""" +Average market borrow APY including rewards +""" + netBorrowApy: Float +} + +""" +IRM curve data point +""" +type IRMCurveDataPoint { +""" +Market utilization rate +""" + utilization: Float! +""" +Supply APY at utilization rate +""" + supplyApy: Float! +""" +Borrow APY at utilization rate +""" + borrowApy: Float! +} + +""" +Bad debt realized in the market +""" +type MarketBadDebt { +""" +Amount of bad debt realized in the market in underlying units. +""" + underlying: BigInt! +""" +Amount of bad debt realized in the market in USD. +""" + usd: Float +} + +""" +Market oracle information +""" +type MarketOracleInfo { + type: OracleType! +} + +""" +Market oracle feeds +""" +type MarketOracleFeed { + baseFeedOneAddress: Address! + baseFeedOneDescription: String + baseFeedOneVendor: String + baseFeedTwoAddress: Address! + baseFeedTwoDescription: String + baseFeedTwoVendor: String + baseVault: Address + baseVaultDescription: String + baseVaultVendor: String + baseVaultConversionSample: BigInt + quoteFeedOneAddress: Address! + quoteFeedOneDescription: String + quoteFeedOneVendor: String + quoteFeedTwoAddress: Address! + quoteFeedTwoDescription: String + quoteFeedTwoVendor: String + quoteVault: Address + quoteVaultDescription: String + quoteVaultVendor: String + quoteVaultConversionSample: BigInt + scaleFactor: BigInt +} + +""" +Morpho Blue market +""" +type Market implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! +""" +On-chain market ID +""" + marketId: MarketId! +""" +IRM contract address +""" + irmAddress: Address! +""" +Liquidation LTV +""" + lltv: BigInt! +""" +Block number at which the market was created +""" + creationBlockNumber: Int! +""" +Timestamp at which the market was created +""" + creationTimestamp: BigInt! + id: ID! @deprecated(reason: "Use marketId and chainId instead.") + targetBorrowUtilization: BigInt! @deprecated(reason: "Deprecated. This field always returns 90%.") + targetWithdrawUtilization: BigInt! @deprecated(reason: "Deprecated. This field always returns 90%.") +""" +State history +""" + historicalState: MarketHistory + listed: Boolean! + creatorAddress: Address @deprecated(reason: "Deprecated.") + collateralAsset: Asset + loanAsset: Asset! + morphoBlue: MorphoBlue! +""" +Current state +""" + state: MarketState + oracleInfo: MarketOracleInfo @deprecated(reason: "Use oracle entity instead.") + oracleFeed: MarketOracleFeed @deprecated(reason: "Use oracle entity instead.") + oracle: Oracle + oracleAddress: Address! @deprecated(reason: "Use oracle.address instead.") + concentration: MarketConcentration @deprecated(reason: "Deprecated.") +""" +Market bad debt values +""" + badDebt: MarketBadDebt +""" +Market realized bad debt values +""" + realizedBadDebt: MarketBadDebt + dailyApys: MarketApyAggregates @deprecated(reason: "Use market.state daily average APYs instead.") + monthlyApys: MarketApyAggregates @deprecated(reason: "Use market.state monthly average APYs instead.") +""" +Current IRM curve at different utilization thresholds for display purpose +""" + currentIrmCurve( + numberOfPoints: Int + ): [IRMCurveDataPoint!] +""" +Underlying amount of assets that can be reallocated to this market +""" + collateralPrice: BigInt @deprecated(reason: "Use state.price instead.") + reallocatableLiquidityAssets: BigInt! + warnings: [MarketWarning!]! +""" +Public allocator shared liquidity available reallocations +""" + publicAllocatorSharedLiquidity: [PublicAllocatorSharedLiquidity!] +""" +Whitelisted vaults having the market enabled with a non-zero cap. +""" + supplyingVaults: [Vault!]! +""" +Whitelisted vaults having the market enabled or still allocated. +""" + supplyingVaultV2s: [VaultV2!]! +""" +Pre-liquidation contracts deployed for this market with known default parameters +""" + preLiquidations( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedPreLiquidations! +} + +""" +66 character long hexadecimal market ID +""" +scalar MarketId + +type PaginatedMarkets { + items: [Market!] + pageInfo: PageInfo +} + +""" +Pre-liquidation contract deployed for a market +""" +type PreLiquidationModel { +""" +Pre-liquidation contract address +""" + address: Address! +""" +Pre-liquidation LTV threshold +""" + preLltv: BigInt! +""" +Pre-liquidation close factor parameter 1 +""" + preLCF1: BigInt! +""" +Pre-liquidation close factor parameter 2 +""" + preLCF2: BigInt! +""" +Pre-liquidation incentive factor parameter 1 +""" + preLIF1: BigInt! +""" +Pre-liquidation incentive factor parameter 2 +""" + preLIF2: BigInt! +""" +Oracle used for pre-liquidation price +""" + preLiquidationOracle: Address! +} + +type PaginatedPreLiquidations { + items: [PreLiquidationModel!] + pageInfo: PageInfo +} + +""" +Amount of collateral at risk of liquidation at collateralPriceRatio * oracle price +""" +type CollateralAtRiskDataPoint { + collateralPriceRatio: Float! + collateralAssets: BigInt! + collateralUsd: Float! +} + +""" +Market collateral at risk of liquidation +""" +type MarketCollateralAtRisk { +""" +Total collateral at risk of liquidation at certain prices thresholds. +""" + collateralAtRisk: [CollateralAtRiskDataPoint!] + market: Market! +} + +""" +Market oracle accuracy versus spot price +""" +type MarketOracleAccuracy { + market: Market! +""" +Average oracle/spot prices deviation +""" + averagePercentDifference: Float @deprecated(reason: "Deprecated.") +""" +Maximum oracle/spot prices deviation +""" + maxPercentDifference: Float @deprecated(reason: "Deprecated.") +} + +""" +Market position state history +""" +type MarketPositionHistory { +""" +Collateral history. +""" + collateral( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Collateral value history, in loan assets. +""" + collateralValue( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Collateral value history, in USD. +""" + collateralUsd( + options: TimeseriesOptions + ): [FloatDataPoint!] +""" +Supply assets history. +""" + supplyAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Supply assets history, in USD. +""" + supplyAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!] +""" +Supply shares history. +""" + supplyShares( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Borrow assets history. +""" + borrowAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Borrow assets history, in USD. +""" + borrowAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!] +""" +Borrow shares history. +""" + borrowShares( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Margin history, in loan assets. +""" + margin( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Margin history, in USD. +""" + marginUsd( + options: TimeseriesOptions + ): [FloatDataPoint!] +} + +""" +Market position state +""" +type MarketPositionState { + id: ID! +""" +The latest update timestamp. +""" + timestamp: BigInt! +""" +The latest collateral assets indexed for this position. +""" + collateral: BigInt! +""" +The latest collateral assets indexed for this position, in USD. +""" + collateralUsd: Float +""" +The latest supply assets indexed for this position. +""" + supplyAssets: BigInt +""" +The latest supply assets indexed for this position, in USD. +""" + supplyAssetsUsd: Float +""" +The latest supply shares indexed for this position. +""" + supplyShares: BigInt! +""" +The latest borrow assets indexed for this position. +""" + borrowAssets: BigInt +""" +The latest borrow assets indexed for this position, in USD. +""" + borrowAssetsUsd: Float +""" +The latest borrow shares indexed for this position. +""" + borrowShares: BigInt! +""" +Value of the collateral in loan asset units, as computed by the market oracle. +""" + collateralValue: BigInt +""" +Margin of the position (collateralValue - borrowAssets). +""" + margin: BigInt +""" +Profit & Loss of the position's borrow side (due to the loan interest) since its inception, in loan assets. +""" + borrowPnl: BigInt +""" +Profit & Loss of the position's borrow side since its inception, quoted in USD using the loan asset's latest price. +""" + borrowPnlUsd: Float +""" +Time-Weighted Average Return of the position's borrow side since its inception. +""" + borrowRoe: Float +""" +Margin of the position in USD (collateralUsd - borrowAssetsUsd). +""" + marginUsd: Float +} + +""" +Market transaction +""" +type MarketTransaction implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + txHash: HexString! + timestamp: BigInt! + blockNumber: BigInt! + txIndex: Int! + logIndex: Int! + type: MarketTransactionType! + data: MarketTransactionData! + market: Market! + user: User! +} + +enum MarketTransactionType { + Supply + Withdraw + Borrow + Repay + SupplyCollateral + WithdrawCollateral + Liquidation +} + +union MarketTransactionData =MarketTransactionTransferData | MarketTransactionCollateralTransferData | MarketTransactionLiquidationData + +""" +Market supply, withdraw, borrow, or repay data +""" +type MarketTransactionTransferData { + assets: BigInt! + shares: BigInt! +} + +""" +Market supply-collateral or withdraw-collateral data +""" +type MarketTransactionCollateralTransferData { + assets: BigInt! +} + +""" +Market liquidation data +""" +type MarketTransactionLiquidationData { + liquidator: String! + repaidAssets: BigInt! + repaidShares: BigInt! + seizedAssets: BigInt! + badDebtAssets: BigInt! + badDebtShares: BigInt! +} + +type PaginatedMarketTransactions { + items: [MarketTransaction!] + pageInfo: PageInfo +} + +type PaginatedMetaMorphoAdapterFactories { + items: [MetaMorphoAdapterFactory!] + pageInfo: PageInfo +} + +type PaginatedMorphoBlue { + items: [MorphoBlue!] + pageInfo: PageInfo +} + +type PaginatedOracles { + items: [Oracle!] + pageInfo: PageInfo +} + +type PaginatedOracleFeeds { + items: [OracleFeed!] + pageInfo: PageInfo +} + +type PaginatedOracleVaults { + items: [OracleVault!] + pageInfo: PageInfo +} + +""" +Public allocator flow caps +""" +type PublicAllocatorFlowCaps { +""" +Public allocator flow cap in USD +""" + maxIn: BigInt! +""" +Public allocator flow cap in underlying +""" + maxOut: BigInt! + market: Market! +} + +""" +Public allocator configuration +""" +type PublicAllocatorConfig { +""" +Fee charged per reallocation (in chain native asset) +""" + fee: BigInt! +""" +Accumulated fees not yet claimed (in chain native asset) +""" + accruedFee: BigInt! +""" +Total fees collected over time (in chain native asset) +""" + overallFee: BigInt! +""" +Address authorized to manage this public allocator config +""" + admin: Address! +""" +Flow caps defining max in/out amounts per market +""" + flowCaps: [PublicAllocatorFlowCaps!]! +} + +""" +Public allocator +""" +type PublicAllocator { + id: ID! + address: Address! + creationBlockNumber: Int! + morphoBlue: MorphoBlue! +} + +type PaginatedPublicAllocator { + items: [PublicAllocator!] + pageInfo: PageInfo +} + +""" +Public allocator reallocate +""" +type PublicAllocatorReallocate { + id: ID! + timestamp: BigInt! + hash: HexString! + logIndex: Int! + blockNumber: BigInt! + sender: Address! + assets: BigInt! + type: PublicAllocatorReallocateType! + market: Market! + vault: Vault! + publicAllocator: PublicAllocator! +} + +enum PublicAllocatorReallocateType { + Deposit + Withdraw +} + +type PaginatedPublicAllocatorReallocates { + items: [PublicAllocatorReallocate!] + pageInfo: PageInfo +} + +type PaginatedTransactions { + items: [Transaction!] + pageInfo: PageInfo +} + +type PaginatedUsers { + items: [User!] + pageInfo: PageInfo +} + +""" +Meta Morpho vault event data +""" +type VaultAdminEvent { + hash: HexString! + timestamp: BigInt! + type: String! + data: VaultAdminEventData +} + +union VaultAdminEventData =SetCuratorEventData | SetFeeEventData | SetFeeRecipientEventData | SetGuardianEventData | SetIsAllocatorEventData | SetSkimRecipientEventData | SetSupplyQueueEventData | SetWithdrawQueueEventData | SkimEventData | CapEventData | TimelockEventData | ReallocateSupplyEventData | ReallocateWithdrawEventData | OwnershipEventData | RevokeCapEventData | RevokePendingMarketRemovalEventData + +""" +SetCurator event data +""" +type SetCuratorEventData { + curatorAddress: Address! +} + +""" +SetFee event data +""" +type SetFeeEventData { + fee: BigInt! +} + +""" +SetFeeRecipient event data +""" +type SetFeeRecipientEventData { + feeRecipient: Address! +} + +""" +SetGuardian event data +""" +type SetGuardianEventData { + guardian: Address! +} + +""" +SetIsAllocator event data +""" +type SetIsAllocatorEventData { + allocator: Address! + isAllocator: Boolean! +} + +""" +SetSkimRecipient event data +""" +type SetSkimRecipientEventData { + skimRecipient: Address! +} + +""" +SetSupplyQueue event data +""" +type SetSupplyQueueEventData { + supplyQueue: [Market!]! +} + +""" +SetWithdrawQueue event data +""" +type SetWithdrawQueueEventData { + withdrawQueue: [Market!]! +} + +""" +Skim event data +""" +type SkimEventData implements AssetReference{ +""" +The asset. +""" + asset: Asset! + amount: BigInt! +} + +""" +Event data for cap-related operation +""" +type CapEventData { + market: Market! + cap: BigInt! +} + +""" +Event data for timelock-related operation +""" +type TimelockEventData { + timelock: BigInt! +} + +""" +ReallocateSupply event data +""" +type ReallocateSupplyEventData { + market: Market! + suppliedAssets: BigInt! + suppliedShares: BigInt! +} + +""" +ReallocateWithdraw event data +""" +type ReallocateWithdrawEventData { + market: Market! + withdrawnAssets: BigInt! + withdrawnShares: BigInt! +} + +""" +Event data for ownership-related operations +""" +type OwnershipEventData { + owner: Address! +} + +""" +Event data for revokeCap operation +""" +type RevokeCapEventData { + market: Market! +} + +""" +Event data for revokePendingMarketRemoval operation +""" +type RevokePendingMarketRemovalEventData { + market: Market! +} + +type PaginatedVaultAdminEvent { + items: [VaultAdminEvent!] + pageInfo: PageInfo +} + +""" +MetaMorpho Vault Factories +""" +type VaultFactory implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! + creationBlockNumber: Int! +} + +""" +A single transition in the curated listing history of a vault (sourced from morpho-blue-api-metadata). +""" +type VaultListingHistoryEvent { + action: VaultListingAction! +""" +Unix timestamp (seconds) of the action. +""" + timestamp: Float! +} + +""" +Action type for a vault listing transition: `Added` when the vault was added to the curated listing, `Removed` when it was delisted. +""" +enum VaultListingAction { + Added + Removed +} + +""" +MetaMorpho vault state +""" +type VaultState { +""" +Block number of the state +""" + blockNumber: BigInt! +""" +Total value of vault holdings, in underlying token units. +""" + totalAssets: BigInt! +""" +Total value of vault holdings, in USD for display purpose. +""" + totalAssetsUsd: Float +""" +Vault shares total supply. +""" + totalSupply: BigInt! +""" +Vault APY excluding rewards, before deducting the performance fee. +""" + apy: Float! +""" +Vault APY including rewards and underlying yield, after deducting the performance fee. +""" + netApy: Float! +""" +Last update timestamp. +""" + timestamp: BigInt! +""" +Block information +""" + block: Block! +""" +Vault allocation on Morpho Blue markets. +""" + allocation: [VaultAllocation!]! +""" +Vault state ID +""" + id: ID! @deprecated(reason: "Use Vault.address and Vault.chainId instead.") +""" +Value of shares quoted in assets +""" + sharePriceNumber: Float +""" +Value of WAD shares in USD +""" + sharePriceUsd: Float +""" +Vault performance fee. +""" + fee: Float! +""" +Stores the total assets managed by this vault when the fee was last accrued, in underlying token units. +""" + lastTotalAssets: BigInt! @deprecated(reason: "Use totalAssets instead.") +""" +Vault curator address. +""" + curator: Address! +""" +Fee recipient address. +""" + feeRecipient: Address! +""" +Guardian address. +""" + guardian: Address! +""" +Owner address. +""" + owner: Address! +""" +Skim recipient address. +""" + skimRecipient: Address! +""" +Timelock in seconds. +""" + timelock: BigInt! +""" +Pending owner address. +""" + pendingOwner: Address +""" +Deprecated direct-only vault state rewards. +""" + rewards: [VaultStateReward!]! @deprecated(reason: "Use allRewards instead to include forwarded rewards.") +""" +Vault state rewards including forwarded rewards when the new Vault V1 rewards clients are enabled. +""" + allRewards: [VaultStateReward!]! +""" +Vault APY excluding rewards, after deducting the performance fee. +""" + netApyWithoutRewards: Float! @deprecated(reason: "Use netApyExcludingRewards instead.") +""" +Instantaneous vault APY excluding rewards, after deducting the performance fee. +""" + netApyExcludingRewards: Float! +""" +Realized average net APY of the vault after performance fee, excluding rewards. Derived from share price evolution over a predefined lookback period. +""" + avgNetApyExcludingRewards( +""" +Predefined lookback period for APY calculation. Using preset periods improves query performance through timestamp normalization. +""" + lookback: VaultV1LookbackPeriod + ): Float +""" +Curators operating on this vault +""" + curators: [Curator!]! +""" +Additional information about the curator address. +""" + curatorMetadata( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedAddressMetadata +""" +Additional information about the owner address. +""" + ownerMetadata( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedAddressMetadata +""" +Additional information about the guardian address. +""" + guardianMetadata( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + ): PaginatedAddressMetadata +""" +Pending config +""" + pendingConfigs( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int +""" +Filter pending config by function name. +""" + functionName_in: [VaultTimelockedFunctionName!] + ): PaginatedVaultPendingConfig! +""" +Average vault APY including rewards, after deducting the performance fee. Supports parameterized lookback periods (default: 6h). +""" + avgNetApy( +""" +Predefined lookback period for APY calculation. Using preset periods improves query performance through timestamp normalization. +""" + lookback: VaultV1LookbackPeriod + ): Float +""" +Daily Vault APY excluding rewards, before deducting the performance fee. +""" + dailyApy: Float @deprecated(reason: "Use avgNetApyExcludingRewards(lookback: ONE_DAY) instead.") +""" +Daily Vault APY including rewards, after deducting the performance fee. +""" + dailyNetApy: Float @deprecated(reason: "Use avgNetApy with lookback parameter instead.") +""" +Weekly Vault APY excluding rewards, before deducting the performance fee. +""" + weeklyApy: Float @deprecated(reason: "Use avgNetApyExcludingRewards(lookback: SEVEN_DAYS) instead.") +""" +Weekly Vault APY including rewards, after deducting the performance fee. +""" + weeklyNetApy: Float @deprecated(reason: "Use avgNetApy with lookback parameter instead.") +} + +""" +Predefined lookback periods for V1 vault APY calculations. Using these periods ensures better query performance through timestamp normalization and caching. +""" +enum VaultV1LookbackPeriod { +""" +1 hour lookback period +""" + ONE_HOUR +""" +6 hours lookback period (default) +""" + SIX_HOURS +""" +1 day (24 hours) lookback period +""" + ONE_DAY +""" +7 days (1 week) lookback period +""" + SEVEN_DAYS +""" +30 days (~1 month) lookback period +""" + THIRTY_DAYS +""" +90 days (~3 months) lookback period +""" + NINETY_DAYS +""" +1 year (365 days) lookback period +""" + ONE_YEAR +""" +Since vault inception (all-time) +""" + INCEPTION +} + +enum VaultTimelockedFunctionName { + SetCap + SetTimelock + SetGuardian + RemoveMarket +} + +""" +Meta-Morpho vault history +""" +type VaultHistory { +""" +Total value of vault holdings, in underlying token units. +""" + totalAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Vault shares total supply. +""" + totalSupply( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Total value of vault holdings, in USD for display purpose. +""" + totalAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Vault APY excluding rewards, before deducting the performance fee. +""" + apy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Vault APY excluding rewards, after deducting the performance fee. +""" + netApyWithoutRewards( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Vault APY including rewards, after deducting the performance fee. +""" + netApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Vault performance fee. +""" + fee( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Vault allocation on Morpho Blue markets. +""" + allocation: [VaultAllocationHistory!]! +""" +Value of shares quoted in assets +""" + sharePriceNumber( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Value of WAD shares in USD +""" + sharePriceUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Daily Vault APY excluding rewards, before deducting the performance fee. +""" + dailyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Daily Vault APY including rewards, after deducting the performance fee. +""" + dailyNetApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Weekly Vault APY excluding rewards, before deducting the performance fee. +""" + weeklyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Weekly Vault APY including rewards, after deducting the performance fee. +""" + weeklyNetApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Monthly Vault APY excluding rewards, before deducting the performance fee. +""" + monthlyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Monthly Vault APY including rewards, after deducting the performance fee. +""" + monthlyNetApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Quarterly Vault APY excluding rewards, before deducting the performance fee. +""" + quarterlyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Quarterly Vault APY including rewards, after deducting the performance fee. +""" + quarterlyNetApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Yearly Vault APY excluding rewards, before deducting the performance fee. +""" + yearlyApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Yearly Vault APY including rewards, after deducting the performance fee. +""" + yearlyNetApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +All Time Vault APY excluding rewards, before deducting the performance fee. +""" + allTimeApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +All Time Vault APY including rewards, after deducting the performance fee. +""" + allTimeNetApy( + options: TimeseriesOptions + ): [FloatDataPoint!]! +} + +type VaultListingMetadataHistoryChange { + action: String! + timestamp: Float! +} + +type VaultWarning { + type: String! + level: WarningLevel! + metadata: VaultWarningMetadata +} + +union VaultWarningMetadata =InvalidNameVaultWarningMetadata | InvalidSymbolVaultWarningMetadata | ShortTimelockVaultWarningMetadata | UnrecognizedDepositAssetVaultWarningMetadata | NotWhitelistedVaultWarningMetadata | CustomMetadata + +type InvalidNameVaultWarningMetadata { + reason: String! +} + +type InvalidSymbolVaultWarningMetadata { + reason: String! +} + +type ShortTimelockVaultWarningMetadata { + timelock: BigInt! +} + +type UnrecognizedDepositAssetVaultWarningMetadata implements AssetReference{ +""" +The asset. +""" + asset: Asset! +} + +type NotWhitelistedVaultWarningMetadata { + history: [VaultListingMetadataHistoryChange!]! +} + +type PaginatedMetaMorphos { + items: [Vault!] + pageInfo: PageInfo +} + +""" +MetaMorpho vault allocation +""" +type VaultAllocation { +""" +Block number in which the allocation was computed +""" + blockNumber: BigInt! +""" +Amount of asset supplied on market, in market underlying token units +""" + supplyAssets: BigInt! +""" +Amount of asset supplied on market, in USD for display purpose. +""" + supplyAssetsUsd: Float +""" +Amount of supplied shares on market. +""" + supplyShares: BigInt! +""" +Maximum amount of asset that can be supplied on market by the vault, in market underlying token units +""" + supplyCap: BigInt! +""" +Maximum amount of asset that can be supplied on market by the vault, in USD for display purpose. +""" + supplyCapUsd: Float +""" +Supply queue index +""" + supplyQueueIndex: Int +""" +Withdraw queue index +""" + withdrawQueueIndex: Int + id: ID! +""" +Pending maximum amount of asset that can be supplied on market by the vault, in market underlying token units +""" + pendingSupplyCap: BigInt +""" +Pending supply cap apply timestamp +""" + pendingSupplyCapValidAt: BigInt +""" +Pending maximum amount of asset that can be supplied on market by the vault, in USD for display purpose. +""" + pendingSupplyCapUsd: Float + removableAt: BigInt +""" +Whether realtime allocation is enabled for this market +""" + enabled: Boolean! @deprecated(reason: "Deprecated.") +""" +Block information +""" + block: Block + market: Market! +} + +""" +MetaMorpho vault allocation history +""" +type VaultAllocationHistory { + market: Market! +""" +Amount of asset supplied on market, in market underlying token units +""" + supplyAssets( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Amount of asset supplied on market, in USD for display purpose. +""" + supplyAssetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +""" +Maximum amount of asset that can be supplied on market by the vault, in market underlying token units +""" + supplyCap( + options: TimeseriesOptions + ): [BigIntDataPoint!]! +""" +Maximum amount of asset that can be supplied on market by the vault, in USD for display purpose. +""" + supplyCapUsd( + options: TimeseriesOptions + ): [FloatDataPoint!]! +} + +type PaginatedMetaMorphoFactories { + items: [VaultFactory!] + pageInfo: PageInfo +} + +""" +Vault position state history +""" +type VaultPositionHistory { +""" +Vault shares history. +""" + shares( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Assets history, in underlying token. +""" + assets( + options: TimeseriesOptions + ): [BigIntDataPoint!] +""" +Assets history, in USD. +""" + assetsUsd( + options: TimeseriesOptions + ): [FloatDataPoint!] +} + +""" +Vault position state +""" +type VaultPositionState { + id: ID! +""" +The latest update timestamp. +""" + timestamp: BigInt! +""" +The latest supply assets indexed for this position. +""" + assets: BigInt +""" +The latest supply assets indexed for this position, in USD. +""" + assetsUsd: Float +""" +The latest supply shares indexed for this position. +""" + shares: BigInt! +""" +Profit & Loss of the position (due to interest and bad debt) since its inception, in loan assets. +""" + pnl: BigInt +""" +Profit & Loss of the position since its inception, quoted in USD using the asset's latest price. +""" + pnlUsd: Float +""" +Time-Weighted Average Return of the position since its inception (non-annualized). +""" + roe: Float +} + +type PaginatedMetaMorphoPositions { + items: [VaultPosition!] + pageInfo: PageInfo +} + +""" +Vault reallocate +""" +type VaultReallocate { + id: ID! + timestamp: BigInt! + hash: HexString! + logIndex: Int! + blockNumber: BigInt! + caller: Address! + shares: BigInt! + assets: BigInt! + type: VaultReallocateType! + market: Market! + vault: Vault! +} + +enum VaultReallocateType { + ReallocateSupply + ReallocateWithdraw +} + +type PaginatedVaultReallocates { + items: [VaultReallocate!] + pageInfo: PageInfo +} + +""" +MetaMorpho vault pending config +""" +type VaultPendingConfig { +""" +Timestamp at which the pending config can be applied +""" + validAt: BigInt! + functionName: VaultTimelockedFunctionName! + decodedData: VaultPendingConfigDecodedData! +""" +Transaction hash that submitted the pending action +""" + txHash: HexString! +} + +union VaultPendingConfigDecodedData =VaultSetCapPendingData | VaultSetTimelockPendingData | VaultSetGuardianPendingData | VaultRemoveMarketPendingData + +""" +Vault pending cap +""" +type VaultSetCapPendingData { +""" +Pending supply cap +""" + supplyCap: BigInt! + market: Market +} + +""" +Vault pending timelock +""" +type VaultSetTimelockPendingData { +""" +Pending timelock duration +""" + timelock: BigInt! +} + +""" +Vault pending guardian +""" +type VaultSetGuardianPendingData { +""" +Pending guardian +""" + guardian: Account! +} + +""" +Vault pending market removal +""" +type VaultRemoveMarketPendingData { + market: Market + caller: Account! +} + +type PaginatedVaultPendingConfig { + items: [VaultPendingConfig!] + pageInfo: PageInfo +} + +""" +Vault V1 transaction +""" +type VaultV1Transaction implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + txHash: HexString! + timestamp: BigInt! + blockNumber: BigInt! + shares: BigInt! + txIndex: Int! + logIndex: Int! + type: VaultV1TransactionType! + data: VaultV1TransactionData! +""" +Underlying assets amount. For transfers this is an approximation derived from the hour bucket share price or the latest current-hour state, and may be null when no valuation state is available. +""" + assets: BigInt + vault: Vault! +} + +enum VaultV1TransactionType { + Transfer + Deposit + Withdraw +} + +union VaultV1TransactionData =VaultV1DepositData | VaultV1WithdrawData | VaultV1TransferData + +""" +Vault V1 deposit data +""" +type VaultV1DepositData { + assets: BigInt! + sender: String! + onBehalf: String! +} + +""" +Vault V1 withdraw data +""" +type VaultV1WithdrawData { + assets: BigInt! + sender: String! + receiver: String! + onBehalf: String! +} + +""" +Vault V1 transfer data +""" +type VaultV1TransferData { + from: String! + to: String! +} + +""" +Cursor anchor for Vault V1 transactions. +""" +type VaultV1TransactionCursor { +""" +Transaction hash of the cursor anchor. +""" + txHash: HexString! +""" +Log index of the cursor anchor. +""" + logIndex: Int! +} + +""" +Page info for Vault V1 transactions cursor pagination. +""" +type VaultV1TransactionsPageInfo { +""" +Whether more items exist after this page. +""" + hasNextPage: Boolean! +""" +Cursor anchor of the last item in this page. Pass to `where.cursor` to fetch the next page when `hasNextPage` is true. Null only when this page is empty. +""" + endCursor: VaultV1TransactionCursor +""" +Number of items returned in this page (== items.length). +""" + count: Int! +} + +type PaginatedVaultV1Transactions { + items: [VaultV1Transaction!] + pageInfo: VaultV1TransactionsPageInfo +} + +type VaultV2Factory implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + id: ID! + address: Address! + creationBlockNumber: BigInt! +} + +type PaginatedVaultV2s { + items: [VaultV2!] + pageInfo: PageInfo +} + +""" +Configuration for an address-based vault selector, including abdication and pending state +""" +type VaultV2SelectorAddressConfig { + address: Address + abdicated: Boolean! + pendingAbdicationExecutableAt: BigInt + pendingAddress: Address + pendingExecutableAt: BigInt +} + +""" +Configuration for a value-based vault selector, including abdication and pending state +""" +type VaultV2SelectorValueConfig { + value: BigInt + abdicated: Boolean! + pendingAbdicationExecutableAt: BigInt + pendingValue: BigInt + pendingExecutableAt: BigInt +} + +""" +Full gate configuration for vault V2 operations +""" +type VaultV2GatesConfig { + sendSharesGate: VaultV2SelectorAddressConfig! + receiveAssetsGate: VaultV2SelectorAddressConfig! + receiveSharesGate: VaultV2SelectorAddressConfig! + sendAssetsGate: VaultV2SelectorAddressConfig! +} + +""" +Vault V2 metadata +""" +type VaultV2Metadata { + description: String + image: String + forumLink: String @deprecated(reason: "Deprecated and always returns null.") +} + +""" +Vault V2 allocation event emitted when the vault allocates assets to (Allocate) or withdraws assets from (Deallocate) one of its adapters. +""" +type VaultV2AllocationTransaction implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + txHash: HexString! + logIndex: Int! + txIndex: Int! + blockNumber: BigInt! + timestamp: BigInt! + type: VaultV2AllocationEventType! +""" +Adapter receiving (Allocate) or returning (Deallocate) assets. +""" + adapter: String! +""" +Address that triggered the allocation change. +""" + sender: String! +""" +Amount of underlying assets moved between the vault and the adapter. +""" + assets: BigInt! +""" +Signed change in the vault's allocation to this adapter after the event. +""" + change: BigInt! +""" +Adapter-defined bytes32 identifiers describing the routed allocation. +""" + ids: [HexString!]! + vault: VaultV2! +} + +""" +Allocate or Deallocate event emitted by a vault V2 adapter. +""" +enum VaultV2AllocationEventType { + Allocate + Deallocate +} + +type PaginatedVaultV2AllocationTransactions { + items: [VaultV2AllocationTransaction!] + pageInfo: PageInfo +} + +type PaginatedVaultV2Factories { + items: [VaultV2Factory!] + pageInfo: PageInfo +} + +""" +Vault V2 transaction +""" +type VaultV2Transaction implements ChainReference{ +""" +The chain on which the entity is deployed. +""" + chain: Chain! + txHash: HexString! + timestamp: BigInt! + blockNumber: BigInt! + shares: BigInt! + txIndex: Int! + logIndex: Int! + type: VaultV2TransactionType! + data: VaultV2TransactionData! +""" +Underlying assets amount. For transfers this is an approximation derived from the hour bucket share price or the latest current-hour state, and may be null when no valuation state is available. +""" + assets: BigInt + vault: VaultV2! +} + +enum VaultV2TransactionType { + Deposit + Withdraw + Transfer +} + +union VaultV2TransactionData =VaultV2DepositData | VaultV2WithdrawData | VaultV2TransferData + +""" +Vault V2 deposit data +""" +type VaultV2DepositData { + assets: BigInt! + sender: String! + onBehalf: String! +} + +""" +Vault V2 withdraw data +""" +type VaultV2WithdrawData { + assets: BigInt! + sender: String! + receiver: String! + onBehalf: String! +} + +""" +Vault V2 transfer data +""" +type VaultV2TransferData { + from: String! + to: String! +} + +type PaginatedVaultV2Transactions { + items: [VaultV2Transaction!] + pageInfo: PageInfo +} + +type Query { + chain( + id: Int! + ): Chain! + chains: [Chain!]! + assetByAddress( + address: String! + chainId: Int + ): Asset! + assets( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: AssetsFilters + orderBy: AssetOrderBy + orderDirection: OrderDirection + ): PaginatedAssets! + transactions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: TransactionsOrderBy + orderDirection: OrderDirection + where: TransactionFilters + ): PaginatedTransactions! @deprecated(reason: "Use vaultV1Transactions or marketTransactions instead.") + userByAddress( + address: String! + chainId: Int + ): User! + users( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: UsersOrderBy + orderDirection: OrderDirection + where: UsersFilters + ): PaginatedUsers! @deprecated(reason: "Use userByAddress or address-scoped queries instead.") + marketCollateralAtRisk( + uniqueKey: String! + chainId: Int + numberOfPoints: Int + ): MarketCollateralAtRisk! + marketById( + marketId: String! + chainId: Int! + ): Market! + markets( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: MarketOrderBy + orderDirection: OrderDirection + where: MarketFilters + ): PaginatedMarkets! + curator( + id: String! + ): Curator! + curators( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: CuratorFilters + ): PaginatedCurators! + marketOracleAccuracy( + marketId: String! + options: TimeseriesOptions + ): MarketOracleAccuracy! @deprecated(reason: "Use marketById instead.") + morphoBlueByAddress( + address: String! + chainId: Int + ): MorphoBlue! + morphoBlues( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: MorphoBlueOrderBy + orderDirection: OrderDirection + where: MorphoBlueFilters + ): PaginatedMorphoBlue! + marketPosition( + userAddress: String! + marketUniqueKey: String! + chainId: Int + ): MarketPosition! + marketPositions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: MarketPositionOrderBy + orderDirection: OrderDirection + where: MarketPositionFilters + ): PaginatedMarketPositions! + oracleFeedByAddress( + address: String! + chainId: Int + ): OracleFeed! + oracleFeeds( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: OracleFeedsFilters + ): PaginatedOracleFeeds! + oracleVaultByAddress( + address: String! + chainId: Int + ): OracleVault! + oracleVaults( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: OracleVaultsFilters + ): PaginatedOracleVaults! + oracleByAddress( + address: String! + chainId: Int + ): Oracle! + oracles( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: OraclesFilters + ): PaginatedOracles! + publicAllocator( + address: String! + chainId: Int + ): PublicAllocator! + publicAllocators( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: PublicAllocatorOrderBy + orderDirection: OrderDirection + where: PublicAllocatorFilters + ): PaginatedPublicAllocator! + publicAllocatorReallocates( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: PublicAllocatorReallocateOrderBy + orderDirection: OrderDirection + where: PublicallocatorReallocateFilters + ): PaginatedPublicAllocatorReallocates! + vaultFactoryByAddress( + address: String! + chainId: Int + ): VaultFactory! + vaultFactories: PaginatedMetaMorphoFactories! + vaultByAddress( + address: String! + chainId: Int + ): Vault! + vaults( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: VaultOrderBy + orderDirection: OrderDirection + where: VaultFilters + ): PaginatedMetaMorphos! + vaultPosition( + userAddress: String! + vaultAddress: String! + chainId: Int + ): VaultPosition! + vaultPositions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: VaultPositionOrderBy + orderDirection: OrderDirection + where: VaultPositionFilters + ): PaginatedMetaMorphoPositions! + vaultReallocates( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: VaultReallocateOrderBy + orderDirection: OrderDirection + where: VaultReallocateFilters + ): PaginatedVaultReallocates! + vaultV1Transactions( +""" +Number of items requested. Must be at least 1. +""" + first: Int + orderBy: VaultV1TransactionOrderBy + orderDirection: OrderDirection + where: VaultV1TransactionFilters + ): PaginatedVaultV1Transactions! + marketTransactions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped. Maximum 10000. +""" + skip: Int + orderBy: MarketTransactionOrderBy + orderDirection: OrderDirection + where: MarketTransactionFilters + ): PaginatedMarketTransactions! + vaultV2Factories: PaginatedVaultV2Factories! + vaultV2MetaMorphoAdapterFactories: PaginatedMetaMorphoAdapterFactories! + vaultV2s( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + where: VaultV2sFilters + orderBy: VaultV2OrderBy + orderDirection: OrderDirection + ): PaginatedVaultV2s! + vaultV2ByAddress( + address: String! + chainId: Int! + ): VaultV2! + vaultV2AllocationTransactions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int +""" +Chain ID of the vault V2. +""" + chainId: Int! +""" +Address of the vault V2. +""" + vaultAddress: String! + orderBy: VaultV2AllocationEventOrderBy + orderDirection: OrderDirection + where: VaultV2AllocationTransactionFilters + ): PaginatedVaultV2AllocationTransactions! + vaultV2PositionByAddress( + userAddress: String! + vaultAddress: String! + chainId: Int! + ): VaultV2Position! + vaultV2transactions( +""" +Number of items requested +""" + first: Int +""" +Number of items skipped +""" + skip: Int + orderBy: VaultV2TransactionOrderBy + orderDirection: OrderDirection + where: VaultV2TransactionFilters + ): PaginatedVaultV2Transactions! +} + +input AssetsFilters { + search: String + symbol_in: [String!] + address_in: [String!] + chainId_in: [Int!] + tags_in: [String!] + listed: Boolean + isVaultAsset: Boolean + isCollateralAsset: Boolean + isLoanAsset: Boolean + isMarketAsset: Boolean + curator_in: [String!] +} + +enum AssetOrderBy { + Address + CredoraRiskScore @deprecated(reason: "Deprecated.") +} + +enum OrderDirection { + Asc + Desc +} + +enum TransactionsOrderBy { + Timestamp + Shares + Assets + RepaidShares + RepaidAssets + SeizedAssets + BadDebtShares + BadDebtAssets +} + +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input TransactionFilters { +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + search: String +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assetAddress_in: [String!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultAddress_in: [String!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + marketUniqueKey_in: [String!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + userAddress_in: [String!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + type_in: [TransactionType!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + hash: String +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_gte: Int +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_lte: Int +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + repaidAssets_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + repaidAssets_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + repaidShares_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + repaidShares_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + seizedAssets_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + seizedAssets_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + badDebtShares_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + badDebtShares_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + badDebtAssets_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + badDebtAssets_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + liquidator_in: [String!] +} + +enum UsersOrderBy { + Address +} + +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input UsersFilters { +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + search: String +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + address_in: [String!] +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assetSymbol_in: [String!] +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assetAddress_in: [String!] +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultAddress_in: [String!] +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + marketUniqueKey_in: [String!] +""" +Filtering options for users. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +} + +enum MarketOrderBy { + UniqueKey + Lltv + BorrowAssets + BorrowAssetsUsd + SupplyAssets + SupplyAssetsUsd + BorrowShares + SupplyShares + Utilization + RateAtUTarget @deprecated(reason: "Use ApyAtTarget instead.") + ApyAtTarget + SupplyApy + NetSupplyApy + BorrowApy + NetBorrowApy + Fee + LoanAssetSymbol + CollateralAssetSymbol + TotalLiquidityUsd + AvgBorrowApy + AvgNetBorrowApy + DailyBorrowApy + DailyNetBorrowApy + CredoraRiskScore @deprecated(reason: "Deprecated.") + SizeUsd +} + +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input MarketFilters { +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + search: String +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + selector_in: [MarketSelectorInput!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + listed: Boolean +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + countryCode: String +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + isIdle: Boolean +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + uniqueKey_in: [String!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + loanAssetTags_in: [String!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + collateralAssetTags_in: [String!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + oracleAddress_in: [String!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + irmAddress_in: [String!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + collateralAssetAddress_in: [String!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + collateralAssetSelector_in: [AssetSelectorInput!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + loanAssetAddress_in: [String!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + loanAssetSelector_in: [AssetSelectorInput!] +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + lltv_gte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + lltv_lte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowAssets_gte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowAssets_lte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowAssetsUsd_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowAssetsUsd_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyAssets_gte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyAssets_lte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyAssetsUsd_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyAssetsUsd_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowShares_gte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowShares_lte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyShares_gte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyShares_lte: BigInt +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + utilization_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + utilization_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + apyAtTarget_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + apyAtTarget_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyApy_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyApy_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + netSupplyApy_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + netSupplyApy_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowApy_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowApy_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + netBorrowApy_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + netBorrowApy_lte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + fee_gte: Float +""" +Filtering options for markets. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + fee_lte: Float +} + +""" +Selector for a market by chain ID and market ID (unique key) +""" +input MarketSelectorInput { +""" +Selector for a market by chain ID and market ID (unique key) +""" + chainId: Int! +""" +Selector for a market by chain ID and market ID (unique key) +""" + marketId: MarketId! +} + +""" +Selector for an asset by chain ID and address +""" +input AssetSelectorInput { +""" +Selector for an asset by chain ID and address +""" + chainId: Int! +""" +Selector for an asset by chain ID and address +""" + address: Address! +} + +""" +Filtering options for curators. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input CuratorFilters { +""" +Filtering options for curators. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + search: String +""" +Filtering options for curators. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for curators. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + address_in: [String!] +""" +Filtering options for curators. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + verified: Boolean +""" +Filtering options for curators. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + ownerOnly: Boolean +} + +enum MorphoBlueOrderBy { + Address +} + +""" +Filtering options for morpho blue deployments. +""" +input MorphoBlueFilters { +""" +Filtering options for morpho blue deployments. +""" + address_in: [String!] +""" +Filtering options for morpho blue deployments. +""" + chainId_in: [Int!] +} + +enum MarketPositionOrderBy { + SupplyShares + BorrowShares + Collateral + HealthFactor +} + +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input MarketPositionFilters { +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + search: String +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + marketUniqueKey_in: [String!] +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + marketListed: Boolean +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + userAddress_in: [String!] +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + healthFactor_gte: Float +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + healthFactor_lte: Float +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyShares_gte: BigInt +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + supplyShares_lte: BigInt +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowShares_gte: BigInt +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + borrowShares_lte: BigInt +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + collateral_gte: BigInt +""" +Filtering options for market positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + collateral_lte: BigInt +} + +input OracleFeedsFilters { + address_in: [String!] + chainId_in: [Int!] +} + +input OracleVaultsFilters { + address_in: [String!] + chainId_in: [Int!] +} + +input OraclesFilters { + address_in: [String!] + chainId_in: [Int!] +} + +enum PublicAllocatorOrderBy { + Address +} + +""" +Filtering options for public allocators. +""" +input PublicAllocatorFilters { +""" +Filtering options for public allocators. +""" + address_in: [String!] +""" +Filtering options for public allocators. +""" + chainId_in: [Int!] +} + +enum PublicAllocatorReallocateOrderBy { + Timestamp + Assets +} + +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input PublicallocatorReallocateFilters { +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultSelector_in: [VaultSelectorInput!] +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + marketSelector_in: [MarketSelectorInput!] +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultAddress_in: [String!] +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + marketUniqueKey_in: [String!] +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + type_in: [PublicAllocatorReallocateType!] +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_gte: Int +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_lte: Int +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_gte: BigInt +""" +Filtering options for public allocator reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_lte: BigInt +} + +""" +Selector for a vault by chain ID and address +""" +input VaultSelectorInput { +""" +Selector for a vault by chain ID and address +""" + chainId: Int! +""" +Selector for a vault by chain ID and address +""" + vaultAddress: String! +} + +enum VaultOrderBy { + Address + TotalAssets + TotalAssetsUsd + TotalSupply + Fee + Apy + NetApy + Name + Curator + AvgApy + AvgNetApy + DailyApy + DailyNetApy + CredoraRiskScore @deprecated(reason: "Deprecated.") +} + +input VaultFilters { + search: String + listed: Boolean + featured: Boolean + countryCode: String + address_in: [String!] + ownerAddress_in: [String!] + address_not_in: [String!] + creatorAddress_in: [String!] + factoryAddress_in: [String!] + curatorAddress_in: [String!] + symbol_in: [String!] + chainId_in: [Int!] + assetAddress_in: [String!] + assetSymbol_in: [String!] + assetTags_in: [String!] + marketUniqueKey_in: [String!] + apy_gte: Float + apy_lte: Float + netApy_gte: Float + netApy_lte: Float + fee_gte: Float + fee_lte: Float + totalAssets_gte: BigInt + totalAssets_lte: BigInt + totalAssetsUsd_gte: Float + totalAssetsUsd_lte: Float + totalSupply_gte: BigInt + totalSupply_lte: BigInt + publicAllocatorFee_lte: Float + publicAllocatorFeeUsd_lte: Float +} + +enum VaultPositionOrderBy { + Shares +} + +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input VaultPositionFilters { +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + search: String +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultAddress_in: [String!] +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultListed: Boolean +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + userAddress_in: [String!] +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_gte: BigInt +""" +Filtering options for vault positions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_lte: BigInt +} + +enum VaultReallocateOrderBy { + Timestamp + Shares + Assets +} + +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input VaultReallocateFilters { +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultAddress_in: [String!] +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + marketUniqueKey_in: [String!] +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + type_in: [VaultReallocateType!] +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_gte: Int +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_lte: Int +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_gte: BigInt +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_lte: BigInt +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_gte: BigInt +""" +Filtering options for vault reallocates. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_lte: BigInt +} + +enum VaultV1TransactionOrderBy { + Time + Shares +} + +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input VaultV1TransactionFilters { +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultAddress_in: [String!] +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + userAddress_in: [String!] +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + type_in: [VaultV1TransactionType!] +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + hash: HexString +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_gte: Int +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_lte: Int +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_gte: BigInt +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_lte: BigInt +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_gte: BigInt +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_lte: BigInt +""" +Filtering options for Vault V1 transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + cursor: VaultV1TransactionCursorInput +} + +""" +Cursor anchor for Vault V1 transactions. +""" +input VaultV1TransactionCursorInput { +""" +Cursor anchor for Vault V1 transactions. +""" + txHash: HexString! +""" +Cursor anchor for Vault V1 transactions. +""" + logIndex: Int! +} + +enum MarketTransactionOrderBy { + Timestamp + Assets + Shares + RepaidAssets + RepaidShares + SeizedAssets + BadDebtAssets + BadDebtShares +} + +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" +input MarketTransactionFilters { +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + marketUniqueKey_in: [String!] +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + assetAddress_in: [String!] +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + userAddress_in: [String!] +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + type_in: [MarketTransactionType!] +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + hash: String +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + cursor: MarketTransactionCursorInput +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + timestamp_gte: Int +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + timestamp_lte: Int +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + assets_gte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + assets_lte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + shares_gte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + shares_lte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + repaidAssets_gte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + repaidAssets_lte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + seizedAssets_gte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + seizedAssets_lte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + badDebtAssets_gte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + badDebtAssets_lte: BigInt +""" +Filtering options for market transactions. AND is used across filters, while OR is used across values within the same filter. +""" + liquidatorAddress_in: [String!] +} + +""" +Cursor anchor for exact market transaction pagination. +""" +input MarketTransactionCursorInput { +""" +Cursor anchor for exact market transaction pagination. +""" + txHash: HexString! +""" +Cursor anchor for exact market transaction pagination. +""" + logIndex: Int! +} + +input VaultV2sFilters { + chainId_in: [Int!] + address_in: [String!] + listed: Boolean + type_in: [VaultV2Type!] + curatorAddress_in: [Address!] + assetAddress_in: [Address!] + ownerAddress_in: [Address!] + performanceFee_gte: BigInt + performanceFee_lte: BigInt + managementFee_gte: BigInt + managementFee_lte: BigInt + maxRate_gte: BigInt + maxRate_lte: BigInt + creationTimestamp_gte: BigInt + creationTimestamp_lte: BigInt + performanceFeeAbdicated: Boolean + managementFeeAbdicated: Boolean + totalAssetsUsd_gte: Float + totalAssetsUsd_lte: Float + totalAssets_gte: BigInt + totalAssets_lte: BigInt + totalSupply_gte: BigInt + totalSupply_lte: BigInt + liquidityUsd_gte: Float + liquidityUsd_lte: Float + liquidity_gte: BigInt + liquidity_lte: BigInt + apy_gte: Float + apy_lte: Float + netApy_gte: Float + netApy_lte: Float + realAssetsUsd_gte: Float + realAssetsUsd_lte: Float + realAssets_gte: BigInt + realAssets_lte: BigInt + idleAssetsUsd_gte: Float + idleAssetsUsd_lte: Float + idleAssets_gte: BigInt + idleAssets_lte: BigInt +} + +enum VaultV2OrderBy { + Address + TotalAssets + TotalAssetsUsd + TotalSupply + Liquidity + LiquidityUsd + Apy + NetApy + RealAssets + RealAssetsUsd + IdleAssets + IdleAssetsUsd +} + +enum VaultV2AllocationEventOrderBy { + Timestamp + Assets +} + +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" +input VaultV2AllocationTransactionFilters { +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" + type_in: [VaultV2AllocationEventType!] +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" + adapter_in: [String!] +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" + sender_in: [String!] +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" + timestamp_gte: Int +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" + timestamp_lte: Int +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" + assets_gte: BigInt +""" +Filters for a vault V2's allocation transactions. AND across filters, OR within a single filter. +""" + assets_lte: BigInt +} + +enum VaultV2TransactionOrderBy { + Time + Shares +} + +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" +input VaultV2TransactionFilters { +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + vaultAddress_in: [String!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + userAddress_in: [String!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + type_in: [VaultV2TransactionType!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + hash: String +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_gte: Int +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + timestamp_lte: Int +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + chainId_in: [Int!] +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + shares_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_gte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + assets_lte: BigInt +""" +Filtering options for transactions. AND operator is used for multiple filters, while OR operator is used for multiple values in the same filter. +""" + cursor: VaultV2TransactionCursorInput +} + +""" +Cursor anchor for Vault V2 transactions. +""" +input VaultV2TransactionCursorInput { +""" +Cursor anchor for Vault V2 transactions. +""" + txHash: HexString! +""" +Cursor anchor for Vault V2 transactions. +""" + logIndex: Int! +} + +enum CacheControlScope { + PUBLIC + PRIVATE +} diff --git a/api/morphographql/generated.go b/api/morphographql/generated.go new file mode 100644 index 00000000..53303fc0 --- /dev/null +++ b/api/morphographql/generated.go @@ -0,0 +1,420 @@ +// Code generated by github.com/Khan/genqlient, DO NOT EDIT. + +package morphographql + +import ( + "context" + + "github.com/Khan/genqlient/graphql" + "github.com/symbioticfi/vault-solver/api/morphographql/scalars" +) + +// MorphoDiscoverMarketsMarketsPaginatedMarkets includes the requested fields of the GraphQL type PaginatedMarkets. +type MorphoDiscoverMarketsMarketsPaginatedMarkets struct { + Items []MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket `json:"items"` +} + +// GetItems returns MorphoDiscoverMarketsMarketsPaginatedMarkets.Items, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarkets) GetItems() []MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket { + return v.Items +} + +// MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket includes the requested fields of the GraphQL type Market. +// The GraphQL type's documentation follows. +// +// Morpho Blue market +type MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket struct { + // On-chain market ID + MarketId string `json:"marketId"` + OracleAddress string `json:"oracleAddress"` + // IRM contract address + IrmAddress string `json:"irmAddress"` + // Liquidation LTV + Lltv scalars.BigIntString `json:"lltv"` + LoanAsset MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketLoanAsset `json:"loanAsset"` + CollateralAsset *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketCollateralAsset `json:"collateralAsset"` + // Current state + State *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState `json:"state"` +} + +// GetMarketId returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket.MarketId, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) GetMarketId() string { + return v.MarketId +} + +// GetOracleAddress returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket.OracleAddress, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) GetOracleAddress() string { + return v.OracleAddress +} + +// GetIrmAddress returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket.IrmAddress, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) GetIrmAddress() string { + return v.IrmAddress +} + +// GetLltv returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket.Lltv, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) GetLltv() scalars.BigIntString { + return v.Lltv +} + +// GetLoanAsset returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket.LoanAsset, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) GetLoanAsset() MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketLoanAsset { + return v.LoanAsset +} + +// GetCollateralAsset returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket.CollateralAsset, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) GetCollateralAsset() *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketCollateralAsset { + return v.CollateralAsset +} + +// GetState returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket.State, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) GetState() *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState { + return v.State +} + +// MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketCollateralAsset includes the requested fields of the GraphQL type Asset. +// The GraphQL type's documentation follows. +// +// Asset +type MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketCollateralAsset struct { + // ERC-20 token contract address + Address string `json:"address"` +} + +// GetAddress returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketCollateralAsset.Address, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketCollateralAsset) GetAddress() string { + return v.Address +} + +// MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketLoanAsset includes the requested fields of the GraphQL type Asset. +// The GraphQL type's documentation follows. +// +// Asset +type MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketLoanAsset struct { + // ERC-20 token contract address + Address string `json:"address"` +} + +// GetAddress returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketLoanAsset.Address, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketLoanAsset) GetAddress() string { + return v.Address +} + +// MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState includes the requested fields of the GraphQL type MarketState. +// The GraphQL type's documentation follows. +// +// Morpho Blue market state +type MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState struct { + // Block number of the state + BlockNumber scalars.BigIntString `json:"blockNumber"` + // Amount borrowed on the market, in underlying units. Amount increases as interests accrue. + BorrowAssets scalars.BigIntString `json:"borrowAssets"` + // Amount borrowed on the market, in market share units. Amount does not increase as interest accrue. + BorrowShares scalars.BigIntString `json:"borrowShares"` + // Amount supplied on the market, in underlying units. Amount increases as interests accrue. + SupplyAssets scalars.BigIntString `json:"supplyAssets"` + // Amount supplied on the market, in market share units. Amount does not increase as interest accrue. + SupplyShares scalars.BigIntString `json:"supplyShares"` + // Last update timestamp. + Timestamp scalars.BigIntString `json:"timestamp"` + // Collateral price + Price *scalars.BigIntString `json:"price"` +} + +// GetBlockNumber returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState.BlockNumber, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState) GetBlockNumber() scalars.BigIntString { + return v.BlockNumber +} + +// GetBorrowAssets returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState.BorrowAssets, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState) GetBorrowAssets() scalars.BigIntString { + return v.BorrowAssets +} + +// GetBorrowShares returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState.BorrowShares, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState) GetBorrowShares() scalars.BigIntString { + return v.BorrowShares +} + +// GetSupplyAssets returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState.SupplyAssets, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState) GetSupplyAssets() scalars.BigIntString { + return v.SupplyAssets +} + +// GetSupplyShares returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState.SupplyShares, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState) GetSupplyShares() scalars.BigIntString { + return v.SupplyShares +} + +// GetTimestamp returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState.Timestamp, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState) GetTimestamp() scalars.BigIntString { + return v.Timestamp +} + +// GetPrice returns MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState.Price, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarketState) GetPrice() *scalars.BigIntString { + return v.Price +} + +// MorphoDiscoverMarketsResponse is returned by MorphoDiscoverMarkets on success. +type MorphoDiscoverMarketsResponse struct { + Markets MorphoDiscoverMarketsMarketsPaginatedMarkets `json:"markets"` +} + +// GetMarkets returns MorphoDiscoverMarketsResponse.Markets, and is useful for accessing the field via an interface. +func (v *MorphoDiscoverMarketsResponse) GetMarkets() MorphoDiscoverMarketsMarketsPaginatedMarkets { + return v.Markets +} + +// MorphoPositionsByMarketMarketPositionsPaginatedMarketPositions includes the requested fields of the GraphQL type PaginatedMarketPositions. +type MorphoPositionsByMarketMarketPositionsPaginatedMarketPositions struct { + Items []MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition `json:"items"` +} + +// GetItems returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositions.Items, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositions) GetItems() []MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition { + return v.Items +} + +// MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition includes the requested fields of the GraphQL type MarketPosition. +// The GraphQL type's documentation follows. +// +// Market position +type MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition struct { + User MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionUser `json:"user"` + Market MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionMarket `json:"market"` + // Current state + State *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState `json:"state"` + // Health factor of the position, computed as collateral value divided by borrow value. + HealthFactor *float64 `json:"healthFactor"` +} + +// GetUser returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition.User, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition) GetUser() MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionUser { + return v.User +} + +// GetMarket returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition.Market, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition) GetMarket() MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionMarket { + return v.Market +} + +// GetState returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition.State, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition) GetState() *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState { + return v.State +} + +// GetHealthFactor returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition.HealthFactor, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPosition) GetHealthFactor() *float64 { + return v.HealthFactor +} + +// MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionMarket includes the requested fields of the GraphQL type Market. +// The GraphQL type's documentation follows. +// +// Morpho Blue market +type MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionMarket struct { + // On-chain market ID + MarketId string `json:"marketId"` +} + +// GetMarketId returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionMarket.MarketId, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionMarket) GetMarketId() string { + return v.MarketId +} + +// MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState includes the requested fields of the GraphQL type MarketPositionState. +// The GraphQL type's documentation follows. +// +// Market position state +type MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState struct { + // The latest borrow shares indexed for this position. + BorrowShares scalars.BigIntString `json:"borrowShares"` + // The latest collateral assets indexed for this position. + Collateral scalars.BigIntString `json:"collateral"` +} + +// GetBorrowShares returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState.BorrowShares, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState) GetBorrowShares() scalars.BigIntString { + return v.BorrowShares +} + +// GetCollateral returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState.Collateral, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionState) GetCollateral() scalars.BigIntString { + return v.Collateral +} + +// MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionUser includes the requested fields of the GraphQL type User. +// The GraphQL type's documentation follows. +// +// User +type MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionUser struct { + Address string `json:"address"` +} + +// GetAddress returns MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionUser.Address, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketMarketPositionsPaginatedMarketPositionsItemsMarketPositionUser) GetAddress() string { + return v.Address +} + +// MorphoPositionsByMarketResponse is returned by MorphoPositionsByMarket on success. +type MorphoPositionsByMarketResponse struct { + MarketPositions MorphoPositionsByMarketMarketPositionsPaginatedMarketPositions `json:"marketPositions"` +} + +// GetMarketPositions returns MorphoPositionsByMarketResponse.MarketPositions, and is useful for accessing the field via an interface. +func (v *MorphoPositionsByMarketResponse) GetMarketPositions() MorphoPositionsByMarketMarketPositionsPaginatedMarketPositions { + return v.MarketPositions +} + +// __MorphoDiscoverMarketsInput is used internally by genqlient +type __MorphoDiscoverMarketsInput struct { + Loan []string `json:"loan"` + Coll []string `json:"coll"` + Chains []int `json:"chains"` + First int `json:"first"` +} + +// GetLoan returns __MorphoDiscoverMarketsInput.Loan, and is useful for accessing the field via an interface. +func (v *__MorphoDiscoverMarketsInput) GetLoan() []string { return v.Loan } + +// GetColl returns __MorphoDiscoverMarketsInput.Coll, and is useful for accessing the field via an interface. +func (v *__MorphoDiscoverMarketsInput) GetColl() []string { return v.Coll } + +// GetChains returns __MorphoDiscoverMarketsInput.Chains, and is useful for accessing the field via an interface. +func (v *__MorphoDiscoverMarketsInput) GetChains() []int { return v.Chains } + +// GetFirst returns __MorphoDiscoverMarketsInput.First, and is useful for accessing the field via an interface. +func (v *__MorphoDiscoverMarketsInput) GetFirst() int { return v.First } + +// __MorphoPositionsByMarketInput is used internally by genqlient +type __MorphoPositionsByMarketInput struct { + Ids []string `json:"ids"` + First int `json:"first"` + Skip int `json:"skip"` + MaxHf *float64 `json:"maxHf"` +} + +// GetIds returns __MorphoPositionsByMarketInput.Ids, and is useful for accessing the field via an interface. +func (v *__MorphoPositionsByMarketInput) GetIds() []string { return v.Ids } + +// GetFirst returns __MorphoPositionsByMarketInput.First, and is useful for accessing the field via an interface. +func (v *__MorphoPositionsByMarketInput) GetFirst() int { return v.First } + +// GetSkip returns __MorphoPositionsByMarketInput.Skip, and is useful for accessing the field via an interface. +func (v *__MorphoPositionsByMarketInput) GetSkip() int { return v.Skip } + +// GetMaxHf returns __MorphoPositionsByMarketInput.MaxHf, and is useful for accessing the field via an interface. +func (v *__MorphoPositionsByMarketInput) GetMaxHf() *float64 { return v.MaxHf } + +// The query executed by MorphoDiscoverMarkets. +const MorphoDiscoverMarkets_Operation = ` +query MorphoDiscoverMarkets ($loan: [String!]!, $coll: [String!]!, $chains: [Int!]!, $first: Int!) { + markets(first: $first, where: {loanAssetAddress_in:$loan,collateralAssetAddress_in:$coll,chainId_in:$chains}) { + items { + marketId + oracleAddress + irmAddress + lltv + loanAsset { + address + } + collateralAsset { + address + } + state { + blockNumber + borrowAssets + borrowShares + supplyAssets + supplyShares + timestamp + price + } + } + } +} +` + +func MorphoDiscoverMarkets( + ctx_ context.Context, + client_ graphql.Client, + loan []string, + coll []string, + chains []int, + first int, +) (data_ *MorphoDiscoverMarketsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "MorphoDiscoverMarkets", + Query: MorphoDiscoverMarkets_Operation, + Variables: &__MorphoDiscoverMarketsInput{ + Loan: loan, + Coll: coll, + Chains: chains, + First: first, + }, + } + + data_ = &MorphoDiscoverMarketsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by MorphoPositionsByMarket. +const MorphoPositionsByMarket_Operation = ` +query MorphoPositionsByMarket ($ids: [String!]!, $first: Int!, $skip: Int!, $maxHf: Float) { + marketPositions(first: $first, skip: $skip, orderBy: HealthFactor, orderDirection: Asc, where: {marketUniqueKey_in:$ids,healthFactor_lte:$maxHf}) { + items { + user { + address + } + market { + marketId + } + state { + borrowShares + collateral + } + healthFactor + } + } +} +` + +func MorphoPositionsByMarket( + ctx_ context.Context, + client_ graphql.Client, + ids []string, + first int, + skip int, + maxHf *float64, +) (data_ *MorphoPositionsByMarketResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "MorphoPositionsByMarket", + Query: MorphoPositionsByMarket_Operation, + Variables: &__MorphoPositionsByMarketInput{ + Ids: ids, + First: first, + Skip: skip, + MaxHf: maxHf, + }, + } + + data_ = &MorphoPositionsByMarketResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} diff --git a/api/morphographql/scalars/scalars.go b/api/morphographql/scalars/scalars.go new file mode 100644 index 00000000..f07fd755 --- /dev/null +++ b/api/morphographql/scalars/scalars.go @@ -0,0 +1,35 @@ +package scalars + +import ( + "bytes" + "encoding/json" +) + +// BigIntString accepts Morpho's BigInt scalar as either a JSON string or number and stores decimal text. +type BigIntString string + +func (b *BigIntString) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 || bytes.Equal(data, []byte("null")) { + *b = "" + return nil + } + if data[0] == '"' { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *b = BigIntString(s) + return nil + } + var n json.Number + if err := json.Unmarshal(data, &n); err != nil { + return err + } + *b = BigIntString(n.String()) + return nil +} + +func (b BigIntString) String() string { + return string(b) +} diff --git a/cmd/vault-solver/root.go b/cmd/vault-solver/root.go index 65dfab7d..6bdcd7b0 100644 --- a/cmd/vault-solver/root.go +++ b/cmd/vault-solver/root.go @@ -6,6 +6,7 @@ import ( // Solver implementations self-register via init(); these blank imports are the only references to // concrete solvers. Adding another solver is an import here plus a config switch. _ "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator" + _ "github.com/symbioticfi/vault-solver/internal/solvers/redstoneoev" _ "github.com/symbioticfi/vault-solver/internal/solvers/rfq" ) diff --git a/cmd/vault-solver/run.go b/cmd/vault-solver/run.go index d3401975..f01c9cd3 100644 --- a/cmd/vault-solver/run.go +++ b/cmd/vault-solver/run.go @@ -72,8 +72,9 @@ func runBot(ctx context.Context, configPath string, debugFlag, debugFlagSet bool log.Info("observability server listening", "addr", cfg.Observability.Addr) // Chain client. rpcUrl is primary; rpcFallbackUrls (if any) are tried in order on failure. + // writeRpcUrl (if set) is a separate client used only to broadcast transactions. rpcURLs := append([]string{cfg.Chain.RPCURL}, cfg.Chain.RPCFallbackURLs...) - chainClient, err := chain.Dial(ctx, rpcURLs, cfg.Chain.MulticallAddress, log) + chainClient, err := chain.Dial(ctx, rpcURLs, cfg.Chain.WriteRPCURL, cfg.Chain.MulticallAddress, log) if err != nil { return err } diff --git a/config/3f.example.yaml b/config/3f.example.yaml new file mode 100644 index 00000000..097c1f84 --- /dev/null +++ b/config/3f.example.yaml @@ -0,0 +1,60 @@ +# vault-solver — 3F Bridge Facilitator (`3f-bridge-facilitator`), annotated example. +# +# Bids in 3F (Grunt) bridge-loan auctions on behalf of one or more Symbiotic BridgeFacilitatorAdapters, +# funds the loans it wins, and permissionlessly redeems repaid loans back to the vault. Addresses below +# are the 3F Sepolia dev deployment (the only 3F environment today). +# +# Secrets are referenced by env-var NAME and read at point of use; ${VAR} fields are expanded from the +# environment at load time. Never commit a real key or production endpoint. + +chain: + rpcUrl: ${ETH_RPC_URL_SEPOLIA} # primary EVM RPC endpoint (expanded from env, or a literal URL) + chainId: 11155111 # must match the RPC's chain id (asserted at startup) + # rpcFallbackUrls: # optional HTTP(S) read fallbacks, tried in order when rpcUrl is down + # - ${ETH_RPC_URL_SEPOLIA_BACKUP} + # writeRpcUrl: ${WRITE_RPC_URL} # optional; broadcasts transactions here while every read stays on + # # rpcUrl. Point at a private/MEV-protected relay to submit privately. + # wsUrl: wss://sepolia.example # optional; enables live log subscriptions (latency only) + # multicallAddress: "0x..." # optional; override the default Multicall3 address for this chain + +signer: + # The bot's EOA both sends transactions (via txManager) and signs EIP-712 offers (authorized on-chain + # by each adapter's EIP-1271 check) and the offer-listing Authorization header — no API key. Provide + # exactly one key source, by env-var NAME — the key never enters the parsed config. + keyEnv: SOLVER_PRIVATE_KEY # env var holding a hex private key + # keystorePath: ./keystore/bot.json # ...or a go-ethereum keystore file + # passphraseEnv: SOLVER_KEYSTORE_PASS # (required with keystorePath) + +txManager: + confirmations: 2 # blocks to wait past inclusion before treating a tx as final (default 2) + # maxFeeGwei: 50 # cap on max fee per gas; omit to derive from base fee + # tipGwei: 1 # priority fee; omit to use the node's suggestion + +observability: + addr: ":9090" # bind address for /metrics, /healthz, /readyz + debug: false # debug-level logging; the --debug CLI flag overrides this + # Optional error reporting: set env SENTRY_DSN (and SENTRY_ENVIRONMENT) to tee Error+ logs to Sentry. + +solvers: + - name: 3f-bridge-facilitator # registry key selecting the implementation + config: # opaque to the framework; typed by the 3F solver + apiBaseUrl: https://bf.dev.gcp.3f.xyz # 3F Bridge Facilitator API base URL (no API key — auth is by signature) + # httpTimeout: 30s # per-call timeout for 3F API requests (default 30s) + redeemBatchSize: 10 # max matured Requests redeemed per redeem() call (gas bound; default 10) + + strategy: # pluggable decision layer (omit ⇒ default); see docs/strategy-plan.md + name: default # "default" (in-process sizing/selection) | "webhook" (external decider) + config: {} # opaque to the framework; parsed by the named strategy + + # Adapters this facilitator serves. Each must be registered with 3F as a facilitator by its vault + # creator, with this solver's signer set as the adapter's EIP-1271 signer. Vault + collateral are + # resolved from each adapter on-chain at startup (adapter.vault() + vault.asset()). Per-request + # caps (min yield / min & max assets per request), funding headroom (getMaxAssets), and + # concurrency (MAX_REQUESTS) live on the adapter and are read on-chain — never configured here. + adapters: + - "0x0000000000000000000000000000000000000000" # TODO: a deployed BridgeFacilitatorAdapter + + intervals: + discover: 1h # how often to poll for open auctions and (re)offer coverage + redeemPoll: 5m # how often to check for matured loans to redeem + reconcile: 15m # how often to log each adapter's live open-position set (health tick) diff --git a/config/3f.sepolia.example.yaml b/config/3f.sepolia.example.yaml deleted file mode 100644 index 4d889cd5..00000000 --- a/config/3f.sepolia.example.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# vault-solver — 3F Sepolia dev profile. -# -# Addresses for the BridgeFacilitatorAdapter, its vault, and the RequestWhitelist are filled in at -# deploy time. On testnet the whitelist is a mock (3F's registry is prod-only); see -# 3F_BRIDGE_FACILITATOR_INTEGRATION.md §6. Provide the key via SOLVER_PRIVATE_KEY in the env. -# -# ${VAR} fields are expanded from the environment at load time. - -chain: - rpcUrl: ${ETH_RPC_URL_SEPOLIA} # expanded from env; do not commit a real URL - chainId: 11155111 - -signer: - keyEnv: SOLVER_PRIVATE_KEY - -txManager: - confirmations: 2 - -observability: - addr: ":9090" - -solvers: - - name: 3f-bridge-facilitator - config: - apiBaseUrl: https://bf.dev.gcp.3f.xyz - adapter: "0x0000000000000000000000000000000000000000" # TODO: deployed BridgeFacilitatorAdapter (vault + collateral derived from it at startup) - # Exposure/return caps live on the adapter (setExposureLimits), read on-chain — not config. - intervals: - discover: 1h - redeemPoll: 5m - reconcile: 15m diff --git a/config/config.example.yaml b/config/config.example.yaml deleted file mode 100644 index 515b905a..00000000 --- a/config/config.example.yaml +++ /dev/null @@ -1,58 +0,0 @@ -# vault-solver configuration (annotated example). -# -# The framework parses everything except `solver.config`, which is handed verbatim to the selected -# solver and typed by it. -# -# ${VAR} / $VAR are expanded from the environment at load time, for non-secret deploy-injected -# fields like rpcUrl. Do NOT put the signing key this way — use the *Env name fields below, which -# read the env var at point of use and keep the secret out of the parsed config (so a config dump -# or debug log can't leak it). Never commit real keys or production endpoints. - -chain: - rpcUrl: ${ETH_RPC_URL} # primary EVM RPC endpoint (expanded from env; or a literal URL) - chainId: 11155111 # must match the RPC's chain id (asserted at startup) - # rpcFallbackUrls: # optional HTTP(S) fallbacks, tried in order when rpcUrl is down - # - ${ETH_RPC_URL_BACKUP} - # - https://rpc.backup.example - # wsUrl: wss://sepolia.example.org # optional; enables live log subscriptions (latency only) - -signer: - # Exactly one key source. The bot's EOA both sends transactions (via txmanager) and signs - # EIP-712 payloads (3F API key + offers, validated on-chain via the adapter's EIP-1271). - # This is an env var NAME, not the key itself — the key never enters the parsed config. - keyEnv: SOLVER_PRIVATE_KEY # env var holding a hex private key - # keystorePath: ./keystore/bot.json # ...or a go-ethereum keystore file - # passphraseEnv: SOLVER_KEYSTORE_PASS # (required with keystorePath) - -txManager: - confirmations: 2 # blocks to wait past inclusion (default 2) - # maxFeeGwei: 50 # cap max fee per gas; omit to derive from base fee - # tipGwei: 1 # priority fee; omit to use the node's suggestion - -observability: - addr: ":9090" # /metrics, /healthz, /readyz - debug: false # debug-level logging; the --debug CLI flag overrides this - # Optional error reporting: set env SENTRY_DSN (and SENTRY_ENVIRONMENT) to tee Error+ logs to Sentry. - -# `solvers` runs one or more solvers in this process — at most one entry per solver type. Every solver -# shares the chain client, signer, and the single nonce-serialized txManager, so they never race on -# nonces. Add more entries (e.g. an `rfq-filler` alongside the 3F one) to run them together. -solvers: - - name: 3f-bridge-facilitator # registry key selecting the implementation - config: # opaque to the framework; typed by the 3F solver - apiBaseUrl: https://bf.dev.gcp.3f.xyz - # apiKeyEnv: SOLVER_3F_API_KEY # optional: pre-generated key. If unset, the bot - # generates one at startup (EIP-712) and re-auths on 401/403. - redeemBatchSize: 10 # max Requests redeemed per redeem() call (gas bound; default 10) - # httpTimeout: 30s # per-call timeout for 3F API requests (default 30s) - # The single adapter this facilitator serves (3F registers exactly one offer-address per - # facilitator, so this solver is single-pair). The vault and collateral are derived from the - # adapter on-chain at startup (adapter.vault() + vault.asset()), not configured here. - adapter: "0x0000000000000000000000000000000000000000" # BridgeFacilitatorAdapter - # Exposure/return caps (perRequestMaxCollateral, totalMaxCollateral, minRequestYieldBps, - # maxConcurrentLoans) are NOT config: they live on the adapter (setExposureLimits) and the bot - # reads them on-chain each poll. The contract enforces them authoritatively at consume time. - intervals: - discover: 1h - redeemPoll: 5m - reconcile: 15m diff --git a/config/redstone-oev.example.yaml b/config/redstone-oev.example.yaml new file mode 100644 index 00000000..e8e5009e --- /dev/null +++ b/config/redstone-oev.example.yaml @@ -0,0 +1,70 @@ +# vault-solver — RedStone OEV solver (`redstone-oev`), annotated example. +# +# An off-chain bidder for RedStone Atom OEV auctions: when a price update makes a Morpho Blue position +# liquidatable, it bids for the right to liquidate and exits the seized collateral through one Symbiotic +# LiquidLaneAdapter. It signs and bids but never submits the settlement tx (RedStone's auctioneer does), +# so no txManager block is needed. Addresses below are the Sepolia testbed (the only OEV environment). +# +# Secrets are referenced by env-var NAME and read at point of use; ${VAR} fields are expanded at load +# time. Never commit a real key or endpoint. + +chain: + rpcUrl: ${ETH_RPC_URL_SEPOLIA} # primary EVM RPC endpoint (adapter/Morpho/Executor reads) + chainId: 11155111 # must match the RPC's chain id (asserted at startup) + # rpcFallbackUrls: # optional HTTP(S) read fallbacks, tried in order when rpcUrl is down + # - ${ETH_RPC_URL_SEPOLIA_BACKUP} + +signer: + keyEnv: OEV_SIGNER_PRIVATE_KEY # env var holding the EXECUTOR_V6 signer key (also the Executor deposit wallet) + +observability: + addr: ":9090" # /metrics, /healthz, /readyz + debug: false # debug-level logging; the --debug CLI flag overrides this + # Optional: set env SENTRY_DSN (and SENTRY_ENVIRONMENT) to tee Error+ logs to Sentry. + +solvers: + - name: redstone-oev + config: + ws: + url: wss://dev-rwa-sepolia.oev.a.redstone.finance # RedStone Atom OEV WebSocket (liquidations feed) + apiKeyEnv: OEV_REDSTONE_API_KEY # env var NAME of the RedStone WS API key + + executor: "0xfdFB1862a53a974b166d1f0D012f524Ebd2e0EbD" # RedStone Atom Executor (proxy) — verifies our bid signature + callback: "0x065B612a182f360D4428cD00a8094049B3c92168" # SymbioticOevSolver — sells each seized leg through the adapter and pays the bid + adapter: "0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b" # the LiquidLane adapter the callback pins (TLOAN vault / TCOL collateral) + + # Morpho GraphQL endpoint polled for market state + at-risk positions (required by the prod monitor). + # Public api.morpho.org does not index this custom Sepolia Morpho, so the Sepolia harness leaves it + # empty and uses OEV_TEST_MONITOR=true + OEV_TEST_MARKETS/OEV_TEST_POSITIONS instead. + morphoApiUrl: "" + # discoveryMaxHealthFactor: 1.30 # only snapshot positions with health factor <= this (default 1.30); + # # local Morpho math then decides real liquidatability at the auction price + # maxTrackedPositions: 10000 # Morpho API page size + in-memory at-risk position cap (default 10000) + + # ETH price feeds used to value gas/bid/profit in USD. loanUsd prices the loan token; on the + # testbed TLOAN is a $1 token, so it reuses Chainlink's Sepolia USDC/USD feed. + loanEthFeed: + ethUsd: "0x694AA1769357215DE4FAC081bf1f309aDC325306" # Chainlink Sepolia ETH / USD + loanUsd: "0xA2F78ab2355fe2f984D808B5CeE7FD0A93D5270E" # Chainlink Sepolia USDC / USD (proxy for TLOAN) + maxAgeMs: 86400000 # max feed staleness before a read fails (default 3600000 = 1h) + + bid: + bidEth: "0.0005" # minimum bid in ETH; the final bid is max(this, gross-profit share) + authTtlMs: 60000 # callback-auth replay window (default 60000); independent of the auction timeout + totalBundleProfitBps: 0 # optional bid share of gross bundle profit, in native terms (default 0) + minBundleProfitBidBps: 1000 # required extra bundle margin after gas + bid, as bps of the final bid + maxTxGasPriceWei: "20000000000" # signed tx.gasprice ceiling + profitability/deposit assumption (default 60000000000 = 60 gwei) + + sizing: + allowFullLiquidation: true # seize full collateral when profitable (default true); false forces fixed 90% partial mode + swapHaircutBps: 100 # extra safety margin on the adapter's discounted rate for slippage/staleness (default 200 = 2%) + + breaker: + maxFailures: 3 # halt bidding after this many failed liquidations… (default 3) + windowMs: 3600000 # …within this window (default 3600000 = 1h); a `blacklisted` frame halts immediately + + intervals: + opsPollMs: 30000 # cadence of ops checks — balances, filler status (default 30000) + monitorPollMs: 10000 # cadence of the monitor snapshot poll (default 10000) + maxStateAgeMs: 90000 # max age of any background cache before bidding fails closed on stale_state + # (default 90000); MUST exceed every poll interval above diff --git a/config/rfq.example.yaml b/config/rfq.example.yaml new file mode 100644 index 00000000..8ce9f5bf --- /dev/null +++ b/config/rfq.example.yaml @@ -0,0 +1,72 @@ +# vault-solver — RFQ Filler (`rfq-filler`), annotated example. +# +# A quote server + order-filling poller for Symbiotic RFQ, on top of per-vault LiquidLaneAdapters. +# Addresses below are the Ethereum mainnet (chainId 1) deployment; for a testnet (e.g. Hoodi, +# chainId 560048) swap chainId and the executor/reactor/adapter addresses. +# +# 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 +# ${VAR} fields are expanded from the environment at load time. Never commit a real key or endpoint. + +chain: + rpcUrl: ${ETH_RPC_URL_MAINNET} # primary READ RPC (nonce, gas, receipts, and all contract reads) + chainId: 1 # must match the RPC's chain id (asserted at startup) + # writeRpcUrl carries ONLY transaction broadcasts (eth_sendRawTransaction); every read stays on + # rpcUrl. Point at a private/MEV-protected relay to submit fills privately. Omit to send via rpcUrl. + writeRpcUrl: ${WRITE_RPC_URL} # e.g. https://rpc.mevblocker.io/fullprivacy + # rpcFallbackUrls: # optional HTTP(S) read fallbacks, tried in order when rpcUrl is down + # - ${ETH_RPC_URL_MAINNET_BACKUP} + +signer: + keyEnv: SOLVER_PRIVATE_KEY # env var holding the caller EOA's hex private key (submits Executor.fill) + # keystorePath / passphraseEnv are also supported (see the 3f example) — provide exactly one key source. + +txManager: + confirmations: 2 # blocks to wait past inclusion before treating a fill as final (default 2) + # maxFeeGwei: 50 # cap on max fee per gas; omit to derive from base fee + # tipGwei: 1 # priority fee; omit to use the node's suggestion + +observability: + addr: ":9090" # /metrics, /healthz, /readyz (separate from the quote server below) + debug: false # debug-level logging; the --debug CLI flag overrides this + # Optional: set env SENTRY_DSN (and SENTRY_ENVIRONMENT) to tee Error+ logs to Sentry. + +solvers: + - name: rfq-filler + config: + strategy: # pluggable decision layer (omit ⇒ default); see docs/strategy-plan.md + name: default # "default" (in-process pricing/selection) | "webhook" (external decider) + 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) + pollIntervalMs: 3000 # how often to poll the backend for awarded open orders + orderLimit: 20 # max open orders fetched per poll + + # RFQ contract deployment (mainnet): + executor: "0xe60E84218BB81539cc599A1E213d6F67058C69Cf" # Executor — the bot calls Executor.fill to settle + reactor: "0x5eB54c47837cC84249F697e3CD8C5D88bCc35dac" # Reactor — invoked by the Executor at fill time + + # solverMode: "external" (default) | "internal". + # external — the open-source filler: never touches the discounts API; `adapters` is REQUIRED and + # scopes both quoting and filling (an empty list is a startup error). + # internal — Symbiotic-internal: uses the public discounts flow and accepts every advertised + # adapter; `adapters` is optional (extra permissioned recovery inventory). + solverMode: external + + # tokensToQuote scopes which input tokens the filler will quote, evaluated against + # permissionedTokens: "all" (default) | "permissioned" (only tokens in the list below) | + # "permissionless" (only tokens NOT in the list). Often set per instance via env. + # tokensToQuote: all + # permissionedTokens: # input-token addresses treated as permissioned for the scope above + # - "0x..." + + # LiquidLane adapter instances this filler serves — the concrete per-token adapters (not the + # factory). Each adapter's vault + asset are resolved on-chain at startup. In `external` mode + # these scope quoting/filling and are required; the Executor must additionally be an authorized + # filler on each adapter (its marketMaker or owner, or a delegated isFiller). + adapters: + - "0x..." # TODO: concrete per-token LiquidLane adapter instance(s) for this network diff --git a/config/rfq.hoodi.example.yaml b/config/rfq.hoodi.example.yaml deleted file mode 100644 index e54a35ef..00000000 --- a/config/rfq.hoodi.example.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# vault-solver — RFQ filler, Hoodi testnet (chainId 560048) profile. -# -# Addresses are the Hoodi deployment of the RFQ contracts. Provide secrets via env: -# SOLVER_PRIVATE_KEY — the caller EOA (must hold CALLER_ROLE on the Executor) -# RFQ_BACKEND_SHARED_SECRET — shared secret authenticating the backend peer on /quote -# ${VAR} fields are expanded from the environment at load time. - -chain: - rpcUrl: ${ETH_RPC_URL_HOODI} # primary; expanded from env; do not commit a real URL - # rpcFallbackUrls: # optional HTTP(S) fallbacks tried in order when rpcUrl is down - # - ${ETH_RPC_URL_HOODI_BACKUP} - chainId: 560048 - -signer: - keyEnv: SOLVER_PRIVATE_KEY # the CALLER_ROLE EOA that submits Executor.fill (P2) - -txManager: - confirmations: 2 - -observability: - addr: ":9090" # /metrics, /healthz, /readyz (separate from the quote server below) - # Optional: set env SENTRY_DSN (and SENTRY_ENVIRONMENT) to tee Error+ logs to Sentry. - -solvers: - - name: rfq-filler - config: - backendUrl: ${RFQ_BACKEND_URL} - backendSharedSecretEnv: RFQ_BACKEND_SHARED_SECRET # env var NAME (secret never in config) - listenAddr: ":42073" # quote HTTP server (poll-only; no /notify) - # Hoodi RFQ deployment: - executor: "0x8F32D7195fD1B99ba64BC9780f580db2A7055990" - reactor: "0xC1b4A404288F049785Be4aB424589E743456338D" # used at execution time - pollIntervalMs: 3000 # backend order poll cadence - orderLimit: 20 # max open orders fetched per poll - # solverMode: "external" (default) | "internal". - # external — no discounts API; `adapters` REQUIRED and scope quoting/filling (empty = startup error). - # internal — public discounts + every advertised adapter; `adapters` optional (extra recovery inventory). - solverMode: external - # LiquidLane adapters: external = required quoting/filling scope + recovery universe; internal = optional extras. - adapters: - - "0x8DCC2515dE40c62d09125db5551de02603C70190" # USDC LiquidLane adapter - - "0x797390393A8a5b65e93A0E4E4A5fCDe06B94Bd3F" # aUSD LiquidLane adapter diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 8ef32f4e..c028e2b1 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -2,7 +2,7 @@ # # Provides at runtime (both gitignored — never commit them): # - .env at the repo root with the signing key + any secret env vars referenced by the config, -# e.g. SOLVER_PRIVATE_KEY=0x... (see config/config.example.yaml for the *Env field names) +# e.g. SOLVER_PRIVATE_KEY=0x... (see config/3f.example.yaml for the *Env field names) # - a config file mounted at /etc/vault-solver/config.yaml (override CONFIG_FILE below). # # Usage (from this directory): docker compose up --build @@ -22,7 +22,7 @@ services: - ../.env # Operator config. Defaults to the committed Sepolia template; point CONFIG_FILE at your own. volumes: - - ${CONFIG_FILE:-../config/3f.sepolia.example.yaml}:/etc/vault-solver/config.yaml:ro + - ${CONFIG_FILE:-../config/3f.example.yaml}:/etc/vault-solver/config.yaml:ro command: ["run", "--config", "/etc/vault-solver/config.yaml"] ports: - "9090:9090" # /metrics, /healthz, /readyz diff --git a/docs/3F-PLAN.md b/docs/3F-PLAN.md index e9aaee89..d1b2f7d2 100644 --- a/docs/3F-PLAN.md +++ b/docs/3F-PLAN.md @@ -14,12 +14,14 @@ repo root) §4 for the functional blueprint of the 3F solver. ## 1. Scope -- **In scope:** the off-chain Go bot — auction discovery, offer pricing/sizing/signing, +- **In scope:** the off-chain Go bot, serving **multiple `BridgeFacilitatorAdapter`s** — auction + discovery, **per-auction multi-adapter coverage**, offer pricing/sizing/signing (signed payloads), on-chain reads for liquidity, position reconciliation, and redemption. -- **Out of scope:** the on-chain `BridgeFacilitatorAdapter` (Solidity). It lives in a - separate repo and is consumed here only via generated ABI bindings. -- **First target network:** 3F Sepolia dev (`chainId 11155111`), which has a live - deployment and a public-readable dev API. Mainnet config slots in later. +- **Out of scope:** the on-chain `BridgeFacilitatorAdapter` (Solidity, consumed via generated ABI + bindings) **and its 3F onboarding**. In the new model each adapter is deployed and registered with 3F + **as a facilitator by its own vault creator**, who then sets this solver's signer as the adapter's + **EIP-1271 signer**. The bot registers nothing with 3F and holds no API key. +- **First target network:** 3F Sepolia dev (`chainId 11155111`). Mainnet config slots in later. --- @@ -33,9 +35,10 @@ repo root) §4 for the functional blueprint of the 3F solver. | License | _TBD — not yet added_ | | Contract bindings | **abigen over vendored ABIs** in `api/abi/` (ABIs copied from `forge build` output, not hand-curated). `make refresh-abi` re-vendors from a Foundry `out/` dir; build stays hermetic off the committed ABIs. | | API client | **openapi-generator (Java)** over a vendored OpenAPI snapshot in `openapi/`. `make refresh-openapi` re-pulls the live spec. | -| Persistence | **Stateless + periodic on-chain resync.** No DB. Open positions come from `adapter.activeRequests()`; redemption readiness from `canWithdraw()`; auctions/offers from the 3F API. Optional live-log subscription is a latency optimization only, never on the critical path. | -| Key management | Env/file private key behind a pluggable **`Signer`** interface (KMS/remote-signer can be added later without touching call sites). | -| Multi-solver shape | 3F logic fully encapsulated in its own package; `main` initializes one solver today. A name→factory **registry** selects the impl from config. A **shared `txmanager`** owns on-chain sending so solvers never race on nonces. | +| Adapter scope | One solver serves a **set of adapters** (config whitelist now; a dynamic "list public 3F adapters" API later). Per auction it can cover the **full requested amount** with one or more single-adapter offers; the default strategy does this most-fundable first, stopping once covered. **1 adapter per offer, no aggregation within an offer** (a single offer is never split across adapters). | +| Persistence | **Stateless + periodic on-chain resync.** No DB. Open requests come from enumerating `adapter.requests(i)` (per adapter); redemption readiness from `canWithdraw()`; auctions/offers from the 3F API. Optional live-log subscription is a latency optimization only, never on the critical path. | +| Key management | Env/file private key behind a pluggable **`Signer`** interface (KMS/remote-signer can be added later without touching call sites). This key is the **EIP-1271 signer every served adapter trusts** (each adapter's owner sets it on-chain): it signs offers with `maker = adapter`, and the adapter's `isValidSignature` authorizes them. The same EOA is the tx-sender for `multicall(finalizeRequest…)` (via the shared `txmanager`). | +| Multi-solver shape | 3F logic fully encapsulated in its own package; a name→factory **registry** selects the impl from config. A **shared `txmanager`** owns on-chain sending so solvers never race on nonces. | --- @@ -45,13 +48,14 @@ Everything the bot needs is reachable from view functions + the 3F API: | Need | Source | Type | |---|---|---| -| Open position set | `adapter.activeRequests()` | on-chain view | -| Per-position detail (principal, ytExpected, openedAt) | `adapter.positions(request)` | on-chain view | -| Realized / recallable principal | `realizedPrincipal()`, `deallocatable()`, `skimmable()` | on-chain view | -| Redeem trigger (loan ready) | `IVaultController(request).canWithdraw()` across `activeRequests()` | on-chain view | -| Offer won / consumed | next `activeRequests()` resync | on-chain view | +| Open request count | `adapter.requestsLength()` (single read) | on-chain view | +| Open request set | `adapter.requests(i)` enumerated `0..requestsLength()-1` | on-chain view | +| Per-request valuation | folded into `adapter.totalAssets()` (values each request's PT/YT live) | on-chain view | +| Funding headroom | `adapter.getMaxAssets()` (min(limitOf − totalAssets, withdrawable), 0 if sweep pending) | on-chain view | +| Redeem trigger (loan ready) | `IVaultController(request).canWithdraw()` across the enumerated `requests(i)` | on-chain view | +| Offer won / consumed | next `requests(i)` resync | on-chain view | | Auction discovery + offer status | `GET /v1/auction`, `GET /v1/offer` | 3F API (off-chain) | -| Realized loss/gain per loan | `PositionRedeemed` log parsed from the bot's **own** `redeem` receipt | self-emitted | +| Realized loss/gain per loan | `FinalizeRequest` log parsed from the bot's **own** `multicall(finalizeRequest…)` receipt | self-emitted | Trade-off accepted: view-only loses *latency* (learn of consume/repay on the next poll tick) and *historical analytics*. Neither matters for 3F — funding pull time is @@ -73,8 +77,13 @@ vault-solver/ │ ├── txmanager/ # SHARED nonce-serialized tx sender ← shared infra │ ├── solver/ # generic Solver interface + registry + engine (solver-agnostic) │ ├── solvers/bridgefacilitator/ # ALL 3F-specific logic, encapsulated -│ │ ├── solver.go config.go apiclient.go auctionview.go sizer.go -│ │ ├── offer.go eip712.go chainreader.go redeemer.go +│ │ ├── solver.go config.go apiclient.go auctionview.go offercache.go +│ │ ├── offer.go eip712.go chainreader.go redeemer.go strategy.go +│ │ ├── strategies/ # pluggable decision layer: +│ │ │ ├── registry.go # package strategies — registry/factory +│ │ │ ├── types/ # OfferInput/OfferOutput + Strategy interface +│ │ │ ├── default/ # in-process default strategy (owns sizing/selection) +│ │ │ └── webhook/ # external-decider adapter │ ├── observability/ # logr+zap setup, prometheus, /healthz /readyz │ └── version/ ├── api/ @@ -84,7 +93,7 @@ vault-solver/ │ │ └── vaultv2/ # shared Symbiotic core, reused by every integration │ └── threef/ # openapi-generator (Java) output (committed) ├── openapi/3f-bf.openapi.json # vendored OpenAPI snapshot -├── config/{config.example.yaml,3f.sepolia.example.yaml} +├── config/{3f,rfq,redstone-oev}.example.yaml # one annotated example per solver ├── deploy/{Dockerfile,docker-compose.yml} ├── .github/workflows/ci.yml ├── .golangci.yml Makefile go.mod README.md .gitignore @@ -101,7 +110,7 @@ of `TxRequest{To, Data, Value, GasLimit?, Label}`; for each it tracks the nonce locally (seeded from the pending nonce, monotonic), sets EIP-1559 fees, signs via the `Signer`, sends, waits for the receipt, and handles `nonce too low` / stuck-tx bump + resync. Solvers **never** send directly — they build calldata (packed via the abigen -ABI, e.g. `adapter.Pack("redeem", requests)`) and hand it to the txmanager, receiving +ABI, e.g. `adapter.PackMulticall(finalizeRequest…)`) and hand it to the txmanager, receiving a `TxResult{Hash, Receipt, Err}`. Serializing through one worker eliminates parallel-nonce races across solvers. @@ -134,7 +143,7 @@ Adding a future solver is a register + config switch, no framework edit. --- -## 6. Configuration +## 6. Configuration & per-offer adapter selection Two-stage decode keeps solver config encapsulated. The generic layer reads only `solver.name` to pick the impl and keeps `solver.config` as a deferred `yaml.Node`; @@ -142,20 +151,106 @@ the chosen solver decodes it into its own typed struct. ```yaml chain: { rpcUrl, chainId, rpcFallbackUrls?, wsUrl? } # rpcFallbackUrls: HTTP(S), tried on primary failure -signer: { keyEnv: SOLVER_PRIVATE_KEY } # or keystorePath + passphraseEnv +signer: { keyEnv: SOLVER_PRIVATE_KEY } # the EIP-1271 signer every served adapter trusts txManager: { confirmations: 2, maxFeeGwei, tipGwei } -solver: - name: 3f-bridge-facilitator # ← registry key: selects the impl - config: # ← opaque to framework; typed by the 3F package - apiBaseUrl: https://bf.dev.gcp.3f.xyz - # Single vault+adapter pair: 3F registers exactly one offer-address per facilitator. - vault: "0x…" - adapter: "0x…" # BridgeFacilitatorAdapter (single-vault by construction) - exposure: { perRequestMaxUsdc: "…", totalSleeveMaxUsdc: "…", maxConcurrentLoans: 10 } - intervals: { discover: 1h, redeemPoll: 5m, reconcile: 15m } +solvers: + - name: 3f-bridge-facilitator # ← registry key: selects the impl + config: # ← opaque to framework; typed by the 3F package + apiBaseUrl: https://bf.dev.gcp.3f.xyz + strategy: + name: default # default local strategy, or webhook + config: {} + # The adapters this solver maintains offers for. Each must already be registered with 3F as a + # facilitator by its vault creator, with this solver's signer set as the adapter's EIP-1271 signer. + # A config whitelist for now; a dynamic "list public 3F adapters" API replaces it later. + adapters: + - "0x…adapterA" + - "0x…adapterB" + redeemBatchSize: 10 # optional (default 10) + httpTimeout: 30s # optional + intervals: { discover: 1h, redeemPoll: 5m, reconcile: 15m } ``` +`apiKeyEnv` and the single `adapter`/`vault`/`exposure` keys are **gone**: there is no API key, and each +adapter's **vault + collateral are resolved on-chain** (`adapter.vault()` / `vault.asset()`) and its +**per-request caps are read on-chain** (`minYieldPerRequest` — ppm, converted to bps by the reader; +`minAssetsPerRequest`; `maxAssetsPerRequest` — set via `setLimitsPerRequest`) — config carries only the +adapter addresses. Funding headroom is the adapter's own `getMaxAssets()` (it folds in the delegator's +per-adapter `limitOf`, the vault's `withdrawable`, and any pending sweep), so the bot reads no separate +sleeve cap. Concurrency is the contract's `MAX_REQUESTS` constant (50), mirrored as a bot const. + +### Per-auction adapter coverage and strategy split + +Each discover tick lists open auctions (public, unauthenticated), then for each auction covers its +**full requested amount** with one or more single-adapter offers, in a single pass: + +1. **Solver-owned snapshot** — the solver lists auctions, reads each configured adapter's + liquidity/exposure in Multicall, prunes its live-offer cache, and builds a compact strategy input. + The input contains only raw facts — adapter snapshots (liquidity and on-chain caps), normalized + auction snapshots, and the live offers the solver already holds. It does not include raw generated + API DTOs, and the solver computes no capacity, joins, or candidate scoring. +2. **No solver-side decisions** — the solver does not size offers, join adapters to auctions, filter + eligibility, or rank anything. It provides raw adapter caps and auction facts; the strategy owns + every decision (sizing, collateral/live-offer/min-yield eligibility, ordering, and cross-auction + budget accounting) built from those facts. +3. **Strategy decision** — the strategy interface is `DecideOffers(ctx, OfferInput) (OfferOutput, error)`, + where `OfferOutput` wraps the returned `[]OfferExecution` (`type OfferOutput struct { Offers []OfferExecution }`). + The selected strategy receives: + + ```go + type OfferInput struct { + Now time + Adapters []AdapterSnapshot + Auctions []AuctionSnapshot + LiveOffers []LiveOffer + } + + type AdapterSnapshot struct { + ID string + Adapter address + Vault address + Collateral address + Fundable uint256 // getMaxAssets() + OpenCount int // requestsLength() + MaxAssets uint256 // maxAssetsPerRequest, 0 = reject-all + MinAssets uint256 // minAssetsPerRequest, 0 = disabled + MinYieldBps uint256 // minYieldPerRequest converted from ppm to bps + MaxConcurrent int // MAX_REQUESTS + } + + type LiveOffer struct { + AdapterID string + AuctionID int64 + } + ``` + + It returns ordered execution offers: + + ```go + type OfferExecution struct { + AuctionID int64 + Request address + Maker address + Principal uint256 + ExpectedReturn uint256 + Reason string // optional, strategy-supplied context + } + ``` + + The default local strategy preserves the current behavior: process auctions in API order, filter + adapter eligibility (collateral match, no live offer for the pair, rate clears `minYieldPerRequest`), + compute each adapter's capacity from its raw caps, rank by available capacity (largest first), clamp + each offer to the still-uncovered remainder, and track local adapter commitments across the pass. + Capacity is `min(min(getMaxAssets, maxAssetsPerRequest), fundable − committed)` gated by the + concurrency and `minAssetsPerRequest` limits; `maxAssetsPerRequest` is an always-active ceiling (`0` + means no capacity). A `webhook` strategy posts the same JSON input to an external decider; big + integers are decimal strings and unknown response fields are rejected. +4. **Side effects** — the solver treats the strategy as trusted. It does not replay or revalidate the + returned execution offers against caps. It uses the `auctionId` to recover the raw auction EIP-712 domain, + signs the returned execution offer, submits `createOffer`, and records the live-offer cache only + after a successful submit. Strategy output cannot set nonce or signature. + --- ## 7. Make-driven codegen @@ -171,25 +266,46 @@ solver: | `make lint` / `test` / `build` / `docker` | golangci-lint; `go test -race -cover ./...`; build; image | Generated code is committed (hermetic build); refresh targets regenerate from upstream -on demand. ABIs required: `BridgeFacilitatorAdapter`, `IRequest`/`IVaultController`, +on demand. ABIs required: `ThreeFAdapter` (from core-mirror), `IRequest`/`IVaultController`, `IWhitelist`, `IVaultV2`. --- ## 8. Build phases -Prerequisite (done). **`BridgeFacilitatorAdapter` contract** — built in the `rfq` repo (`src/3f/`), 25 tests, ABI vendored here. This is what the bot binds against. +Prerequisite (done). **`ThreeFAdapter` contract** — core-mirror's `src/contracts/adapters/ThreeFAdapter.sol`, ABI vendored here from the core-mirror Foundry build. This is what the bot binds against (it replaced rfq's `BridgeFacilitatorAdapter`). 0. **(done)** Scaffold + tooling — module, layout, Makefile, `.golangci.yml`, CI, README, version pkg. (LICENSE not yet added.) 1. **(done)** Codegen pipeline — ABIs vendored from `../rfq/out`; OpenAPI snapshot; `bindings` (one pkg/contract) + `openapi-client`; committed. 2. **(done)** Core infra (solver-agnostic) — config (two-stage decode), chain primitives, signer, **txmanager (+5 tests)**, solver interface/registry/engine, observability, graceful shutdown. -3. **(done)** 3F solver (encapsulated) — API client (x-api-key auctions/offers), sizer (fundable-liquidity + curator exposure caps; Request authorization is the on-chain 3F whitelist), EIP-712 offer signing **+ golden-hash + apitypes parity test**, reconcile + redeemer (poll `canWithdraw` → pack `redeem` → txmanager), exposure / no-over-commit guards. Deltas tracked in §10. +3. **(done)** 3F solver (encapsulated) — signed-payload API client, offer sizing (now owned by the strategy layer: `getMaxAssets` headroom + per-request caps; Request authorization is the on-chain 3F whitelist), EIP-712 offer signing **+ golden-hash + apitypes parity test**, reconcile + redeemer (poll `canWithdraw` over `requests(0..requestsLength()-1)` → `multicall(finalizeRequest…)` → txmanager), exposure / no-over-commit guards. Deltas tracked in §10. 4. **(done)** Packaging + verification — README/config docs; Sepolia-dev e2e (offers won + redeemed live); multi-stage non-root distroless Dockerfile + compose (`deploy/`, ~20 MB static CGO-free image). +5. **(done) Adapter-as-facilitator + signed payloads + multi-adapter.** The new model (§1, §2, §6), + implemented across the `bridgefacilitator` package: + - **Dropped the API key + offer-address.** `listOffers` is now a per-adapter **signed** query (EIP-712 + `GetOffers` in an `Authorization: Bearer` header); `createOffer` sends no `x-api-key`. Removed the + key-gen, `apiKeyEnv`, and the `ensureOfferAddress`/`setOfferAddress` onboarding. Onboarding (deploy + adapter → register with 3F → set this signer as EIP-1271 signer) is the vault creator's job. + - **`adapter` → `adapters[]`.** Config whitelist; each adapter's vault/collateral resolved once at + startup; on-chain EIP-1271 signer check drops any adapter this solver isn't authorised for + (fail-closed; zero remaining → startup shutdown). **No redeem-only mode** — with ≥1 matching adapter + the bot runs offers + redeems for the matched set; with none it shuts down. + - **Per-auction multi-adapter coverage** (§6): cover each auction's full requested amount with one or + more single-adapter offers through the configured trusted strategy; uncovered remainder retries + next pass. Offer dedup, coverage, exposure, redeem, and reconcile all run per adapter. + - Tests: strategy registry/default selection, default strategy eligibility/sizing, webhook wire shape, per-(adapter,auction) dedup, `liveCoverage`, signed `listOffers` httptest, `authorizedSigner` + Multicall round-trip, EIP-712 `GetOffers` golden + apitypes cross-check. The `GetOffers` type string + and the signer's live-API acceptance are pinned by env-guarded live tests (§9). --- ## 9. Open items to confirm during implementation +- **Signed-payload API contract** — confirm with 3F the exact request shape for creating *and listing* + offers without an API key: how a list request is authenticated/scoped to an adapter (the signed payload), + and that 3F verifies offer creation via the adapter's EIP-1271 `isValidSignature`. +- **Dynamic "list public 3F adapters" API** — the endpoint that replaces the config whitelist (what + marks an adapter public/eligible, and how we filter to ones our signer is the EIP-1271 signer for). - Mainnet `RequestWhitelist` address and prod API base URL — supplied by 3F when prod lands. - Go module path (`github.com/symbioticfi/vault-solver` placeholder) — adjust to the real org. @@ -200,12 +316,18 @@ Prerequisite (done). **`BridgeFacilitatorAdapter` contract** — built in the `r Tracked TODOs and known gaps — each a scoped follow-up; none block release. **Deferred features / known gaps:** -- **Move exposure / risk params on-chain.** Today the caps (`perRequestMaxUsdc`, `totalSleeveMaxUsdc`, `maxConcurrentLoans`, `minReturnBps`) live in the bot config and are enforced only off-chain in `sizeOffer` — a buggy or rogue bot could exceed them. Hoisting them into the `BridgeFacilitatorAdapter` (e.g. owner-set caps re-checked in `onRequestConsumed`, mirroring the removed `requestMetadata` budget but at the adapter level) makes the limits trust-minimized and curator-governed; the bot's config caps then become a redundant client-side guard. Needs a contract change in the `rfq` repo + binding regen; the bot reads the on-chain caps instead of (or in addition to) config. -- **Offer pricing is naive.** The bot bids at the auction's current `maxRate`, then caps to exposure + fundable liquidity — it models no spread, risk-adjusted target rate, time-in-auction, or competing offers. A real quoting strategy (e.g. the RFQ solver's strategy logic) is the main follow-up; `buildSignedOffer` is the seam to extend, and `MinReturnBps` is the only knob today. -- **API key logged at debug (`V(1)`).** Convenience for out-of-band replay; it is a secret in logs — disable or scrub before production. +- **(done) Exposure / risk params are on-chain.** The per-request caps (`minYieldPerRequest` in ppm, `minAssetsPerRequest`, `maxAssetsPerRequest`) live on the `ThreeFAdapter` and are read per-adapter via Multicall each discover tick (`chainreader.go`); the bot no longer carries config exposure caps. Funding headroom is the adapter's own `getMaxAssets()` (folds in the delegator `limitOf`, vault `withdrawable`, and pending sweep), and the concurrency cap is the contract's `MAX_REQUESTS` constant — neither is a separate adapter read. Trust-minimized + curator-governed, as planned. +- **Multi-maker offers.** An auction's ask is covered by **multiple single-adapter offers** (most-fundable first, sized to the uncovered remainder), but a **single** offer is still funded by one adapter. Splitting one offer across several makers (true aggregation) is deferred — needs multi-maker offer support on-chain. +- **Re-pricing live offers on rising yield.** An auction's `maxRate` can climb over time, so an auction infeasible now (below an adapter's `minYieldPerRequest`) becomes feasible later — handled, since infeasible auctions are never negatively cached and each pass re-evaluates. But a live offer placed at an earlier, lower rate is **not** re-priced upward while it stays live (dedup by `(adapter, auction)`); capturing the higher rate would need cancel/replace (depends on `OfferControllerCancelV1`, below). +- **Dynamic adapter discovery.** The adapter set is a config whitelist; the dynamic "list public 3F adapters" API (§9) replaces it later, filtered to adapters our signer is the EIP-1271 signer for. +- **Custom offer pricing/scoring.** The default local strategy bids at the auction's current `maxRate` + and sizes by `getMaxAssets` headroom plus adapter per-request limits. Operators that need spread, + risk-adjusted target rate, time-in-auction, or competing-offer logic should replace it with a local + custom strategy or the built-in `webhook` strategy. The strategy returns principal and expected + return; the solver only signs and submits the returned offer. - **Offer cancellation.** `OfferControllerCancelV1` not wired — needs offer-id↔auction state. Note `offerTTL` (30m) < `discover` (1h) leaves a no-offer gap each cycle; consider `offerTTL` ≥ the discover interval (dedup prevents redundant re-offers). - **WS live-log subscription** (`chain.wsUrl`) — config field present but unused; the poll-based reconcile/redeem path is sufficient for v0. **Testing:** -- **Integration coverage.** `bridgefacilitator` unit coverage is ~16% — pure logic (EIP-712 golden+parity, sizer caps, config) is covered; the HTTP/on-chain paths (apiclient, chainreader, redeemer, Run loop) need an httptest-backed API mock + a simulated/forked chain backend. +- **Integration coverage.** `bridgefacilitator` unit coverage is ~16% — pure logic (EIP-712 golden+parity, default-strategy capacity/caps, config) is covered; the HTTP/on-chain paths (apiclient, chainreader, redeemer, Run loop) need an httptest-backed API mock + a simulated/forked chain backend. - **Solver-agnostic metrics seam.** `solver.Deps.Metrics` (the `Registerer()` extension point) is wired but no solver registers collectors yet; add bridge-facilitator metrics (offers sent/won, exposure, locked vs realized, redemptions) and they'll verify the seam. diff --git a/docs/OEV-PLAN.md b/docs/OEV-PLAN.md new file mode 100644 index 00000000..aceabc14 --- /dev/null +++ b/docs/OEV-PLAN.md @@ -0,0 +1,666 @@ +# vault-solver — RedStone OEV solver (plan) + +The **`redstone-oev`** solver: an off-chain bidder for RedStone Atom OEV auctions that captures +price-driven liquidations on **Morpho Blue** and exits the seized RWA collateral through **one** +Symbiotic LiquidLane adapter (one vault) in the same atomic transaction. It follows the framework +boundary and conventions in [`../CLAUDE.md`](../CLAUDE.md). §6 records the verified ground truth +(Executor source, wire schemas, Sepolia testbed, proven live liquidations) the design rests on. + +--- + +## 1. What the solver does + +RedStone Atom inverts MEV liquidations: instead of publishing a price update and letting searchers race, +RedStone runs an off-chain ~400 ms WebSocket auction among approved solvers for the right to be the +liquidator. The winning solver's signed payload is bundled **with the price update and the liquidation in +one atomic transaction**, submitted by RedStone's auctioneer. + +Per auction tick, end to end: + +1. **Auction frame** arrives over WSS (`oev/liquidations`). We consume the **prices only** — the price + push is oracle-scoped, so once an auction settles for an oracle every borrower underwater at that + price is liquidatable by our callback, not just the positions RedStone listed (§3.1). We target our + own independently-discovered underwater set; the frame's `positions[]` are ignored. +2. **Hot path** (`candidates` → `selectBundle` → sign, sync, in-memory, no I/O — budget ≈ 400 ms minus + WS RTT): build the candidate set from our own tracked at-risk positions, recompute health at the + settlement price with the shared Morpho math (incl. local interest accrual — never trust a pushed + `current_ltv`), drop positions already committed by an in-flight bid, size each seize/exit leg (taken + only if the adapter exit covers Morpho repayment), select the bundle by after-cost net profit, run the O(1) pre-bid + gates, sign the **EXECUTOR_V6** EIP-191 payload, and reply `{"op":"solve", …}`. +3. **Settlement** (on-chain, RedStone submits — not us): the Executor verifies our signature + nonce, + applies the price update to the oracle, then calls our callback's `liquidate(bid, solver, + operationData)` → per leg `Morpho.liquidate(…)` → Morpho pushes the seized collateral to the callback + and invokes `onMorphoLiquidate`, where the callback sells the WHOLE seizure through its one immutable + `LiquidLaneAdapter` in a single `swap` after recomputing current min-out, then approves Morpho's + repayment pull — then the Executor calls `payBid(bid)` and the callback pays it in native. + The Executor **catches** a callback revert (`LiquidationFailed(solver, nonce)`): the nonce is still + consumed and the gas liability still debited from the deposit — the liquidation just doesn't settle + (§6.2). A price-update revert, by contrast, reverts the whole tx. +4. **Bookkeeping** (background ops loop): an Executor-state poll refreshes nonce/deposit/callback-balance. + The circuit breaker is fed by the WS `liquidation-result` push (a `success:false` frame for our callback → + `recordFailure`); we are a state-reading bot and do **not** run chain log scans. When a + `liquidation-result.txHash` is available, we decode callback events from that receipt for attribution. + Realized profit is the callback's loan-token balance, not event accounting. + +Two roles, two pots (see §6.2 for the verified Executor mechanics): + +- **Signer EOA's deposit on the Executor** (≥ `MIN_DEPOSIT` = 0.00001 ETH on Sepolia) — a *rolling gas + prepayment*: after every settlement, win or revert, `(gasUsed + 35k) × tx.gasprice` is deducted and + paid to the auctioneer. This gas debit is **independent of the auction** (the winner is decided purely + by bid amount) and is **not reverted if the deposit underpays** — so it does NOT change the signed bid + amount. The pre-bid deposit gate requires enough unreserved deposit for `MIN_DEPOSIT + predictedGas × + maxTxGasPriceWei`; in-flight bids reserve their predicted gas debit until they resolve. It is still real + cost: the bot bids only if bundle loan profit, converted through the cached loan↔ETH rate, covers + estimated gas plus the dynamic bid and the optional `bid.minBundleProfitBidBps` margin. Topped up + out-of-band by the operator (`scripts/oev/oev-balance.sh`). +- **Callback contract's native balance** — pays the bid via `payBid` (forwarded to RedStone's + collector); **owner-refilled** out-of-band. Liquidation profit accumulates in the callback's ERC-20 + balance; the owner withdraws it (`withdrawERC20`). The bot never self-funds. + +**One bot, one vault, one loan token, one venue (Morpho).** The `SymbioticOevSolver` pins a single +immutable `LiquidLaneAdapter` at construction, so the solver serves exactly that adapter's vault. A market is +tracked only when its loan token equals that adapter's vault loan token (resolved on-chain). A single +auction is answered by ONE bid bundling every gas-fit, after-cost-positive leg (one loan token, no per-token +grouping). **One solver runs per vault** (spec §11: RWA curators set per-vault +discounts, so swaps can't aggregate across vaults): a second vault/loan token is a second +`SymbioticOevSolver` + a second process. + +--- + +## 2. Architecture + +A self-contained `internal/solvers/redstoneoev/` implementing `solver.Solver` — **no framework edits** +(CLAUDE.md modularity rule): + +- **`Solver` owns the whole pipeline for one venue.** WS lifecycle, bid economics, in-flight + reservation, EXECUTOR_V6 signing, and the breaker. There is exactly one + venue (Morpho liquidation through one LiquidLane adapter / one loan token / one vault), so the bot + reads the monitor snapshot directly: `s.fresh`/`s.candidates` (`candidates.go`) gate on cache + freshness and turn liquidatable positions into sized, scored `scoredLeg`s via the shared Morpho math + (`sizeLeg`). `selectBundle` bundles the legs into one bid; the callback runs each leg through its one + `LiquidLaneAdapter`. A second venue would be a new solver package, not an in-package abstraction. +- **Settlement truth comes over WS, not from logs.** The breaker's failure feed is RedStone's + `liquidation-result` push: a `success:false` frame whose `liquidator` is our callback → `recordFailure`, + tripping the breaker after N in the window. We gate on `liquidator == callback` (same won-detection as + `auction-result`) because the frame arrives on both the broadcast `oev/liquidations` and the + callback-scoped `oev/notify/` subscription, so a result may belong to another solver. We are a + state-reading bot, **not** a log-indexer: there is no `FilterLogs` scan. If a settlement receipt is + available from RedStone's `txHash`, the bot decodes callback `LegResult`/`PayBidResult` logs from that + receipt for diagnostics. Realized profit is read off the callback's loan-token balance / balance sheet, + not event accounting. +- **`Run(ctx)`** owns the resilient WS client (connect with `x-api-key`, subscribe `oev/liquidations` + + `oev/notify/`, reconnect with backoff + jitter, ~7 h proactive rotation, staleness + watchdog), the hot-path handler, the monitor's market/position refresh loops, and the Executor-state + ops loop. It joins every background loop on shutdown (`sync.WaitGroup`) so no goroutine outlives `Run`. + Caches are immutable snapshots swapped atomically (`atomic.Pointer`), read lock-free on the hot path. +- **The solver sends no transactions** — RedStone's auctioneer submits the settlement tx; Executor deposit + management is out-of-band. `deps.TxManager` is therefore unused, and the OEV config carries no + `txManager` section. +- **`deps.Signer` is the EXECUTOR_V6 signer.** The bid digest is `keccak256(abi.encode("EXECUTOR_V6", + chainId, callback, keccak256(operationData), bidWei, nonce, maxTxGasPrice))` wrapped in EIP-191 + (`personal_sign`), signed via `Signer.SignHash`. The signer EOA **is** the wallet holding the Executor + deposit (the Executor recovers the signer and debits *its* deposit/nonce). A KMS split is later + hardening, same as 3F/RFQ. +- **On-chain reads use latest-state `chain.Multicall` in background loops only** — nothing on the hot path + touches the network except the final `ws.Send`. Production Morpho market/position state comes from the + GraphQL snapshot; chain reads are limited to adapter/callback/Executor data (loan token, redeemable + collateral set, filler status, route quotes, deposit/nonce, gas predictor). The Sepolia test monitor is + the only path that reads Morpho `market()`/`position()` on-chain, over explicit test seeds. `chain.Multicall` + itself packs `aggregate3` + does its own + `eth_call` + unpacks via the v2 Multicall3 binding — every binding is abigen --v2 now (no v1 path remains). +- **Validate-everything, fail closed.** Auction frames are external input that drives funds: per-frame + count is bounded; a paused/dry/unserved vault yields no quote → no bid; malformed fields skip the leg, + never panic. Every pre-bid gate (snapshot block epoch, deposit gas headroom, callback balance, the + bundle-level gas profitability check, the adapter's `isFiller` gate) fails closed. The bid is bounded **off-chain** by the + configured `bid.bidEth` floor and optional `bid.totalBundleProfitBps` (spec §8) — the contract carries no on-chain bid cap. +- **Metrics** on the shared registry (`deps.Metrics.Registerer()`, nil-safe): auctions/bids/wins/ + failed-liquidations counters, a `skips_total{reason}` vector, a hot-path latency histogram, deposit + and callback-native gauges. The breaker halts bidding after N failed liquidations in a rolling window, + and immediately on a `blacklisted` frame. +- **Bindings.** The RedStone `Executor`, `IMorpho` (subset), `IAdaptiveCurveIrm`, `IOracle`, and our + `SymbioticOevSolver`, and `AggregatorV3` feeds are **abigen --v2** bindings under `api/bindings/oev/*` + (vendored ABIs in `api/abi/`: the external contracts hand-vendored, the Executor ABI mirroring the + verified Blockscout source — §6.2; `SymbioticOevSolver` is the OEV callback contract from rfq `src/oev`), + following the repo's `BINDINGS_V2` pattern (vendor → generate → commit). The reader builds every + Multicall3 sub-call and decodes every return/event blob through the bindings' typed `PackXxx`/`UnpackXxx` + (and `UnpackXxxEvent`), so an ABI change breaks the build at the call site — matching the rfq reader. The + LiquidLane read-side binding and the ERC-4626 vault binding live in their neutral shared groups + (`api/bindings/liquidlane/adapter`, `api/bindings/erc4626`), reused by RFQ and OEV. +- **Config**: all addresses / URLs / caps from the YAML `solvers[].config`; the API key via `apiKeyEnv` + and the signer key via the framework `signer.keyEnv` (`os.Getenv` at point of use — never in the + struct, never logged). + +### Component map (file → responsibility) + +The shared Morpho package is intentionally small: `internal/morpho` holds only reusable Morpho Blue math and +state accounting. The GraphQL wire binding is generated from the full vendored Morpho schema plus +`api/graphql/morpho/operations/*.graphql` into `api/morphographql`, with +`api/graphql/morpho/operations.json` recording the exact generated query strings. The hand-written Morpho API +adapter is OEV-local because it parses directly into the OEV monitor snapshot. + +| File | Responsibility | +|---|---| +| `solver.go` | `Register`, factory, `Run` (loops + join), `handleAuction` → `buildBid`, ops loop, the head-stable Executor/callback-balance/rate/gas-predictor cache (`cachedState`/`stateCache`) | +| `candidates.go` | auction frame → `[]evalItem` (price selection: auctioned frame price, or the test-only cached on-chain price) + the solver's I/O-free hot-path candidate sizing (`candidates` → `sizeLeg`); the candidate set is our own tracked positions (`workerCandidates`) — the frame's pushed positions are not consumed | +| `bundle.go` | single-token leg selection (`selectNetBundle`/`selectBundle`, `scoredLeg`/`chosenBundle`): live bidding chooses the bundle by bounded after-cost net search; dry-run/no-rate fallback ranks by gross loan profit; bid is `max(bidEth, grossProfitNative * totalBundleProfitBps / 10000)` | +| `sizing.go` | adapter pricing primitives (`swapOutFor`/`collForBudget`) and `sizeLeg`, the per-candidate single-swap leg sizing/decision over the shared Morpho math | +| `internal/morpho/math.go` | shared Morpho Blue math: health, LIF, share/asset conversions, Taylor accrual (exact big.Int rounding) | +| `monitor.go` | Morpho API snapshot, atomic hot-path state, single-adapter quote resolution | +| `morphoapi.go` | OEV-local adapter over generated Morpho GraphQL operations: `markets` returns adapter-scoped market state (§3.4), `marketPositions` returns at-risk position state capped at `maxTrackedPositions` (§3.2) | +| `api/morphographql` | generated Morpho GraphQL binding from vendored schema + explicit operation documents | +| `chainreader.go` | abigen --v2 binding instances (`api/bindings/oev/*`), keccak market-id re-derivation (`deriveMarketID`), adapter quote/filler/gas-predictor reads, and loan↔ETH feed reads | +| `fillerauth.go` | the single adapter's `isFiller` swap-caller preflight (`ReadFillerStatus`) | +| `reservations.go` | in-flight `payBid`/position reservation + auction-id de-dup (`seenAuctions`) | +| `wsclient.go` | resilient WS client: reconnect/backoff/jitter, ~7 h rotation, heartbeat, subscribe replay | +| `wsmessages.go` | wire types pinned to RedStone's zod + a captured auction frame | +| `eip191.go` | EXECUTOR_V6 digest + EIP-191 `SignBid` via `Signer.SignHash` (golden + parity tests) | +| `operationdata.go` | ABI-encode callback `operationData`: auction auth, capped max-seize legs, loan-denominated profit floors, and callback-auth signature | +| `callbackevents.go` | decode callback `LegResult` / `PayBidResult` receipt logs for settlement attribution | +| `noncestore.go` | strictly-ascending nonce high-water mark, reconciled with the on-chain getter | +| `breaker.go` | failed-liquidation rolling-window breaker + `blacklisted`-frame halt | +| `metrics.go` | nil-safe Prometheus collectors on the shared registry | +| `rate.go` / `gaspredictor.go` / `config.go` | loan↔ETH rate math, loan→native conversion, route-aware gas units, and `loanEthFeed` parsing | +| `config.go` | typed, validated `parseConfig` (shared parse/coerce helpers in `internal/parse`) | + +--- + +## 3. Candidate discovery & the shared health core + +The candidate set is **purely** our own independently-tracked at-risk positions (Morpho API + config +seeds). We know each market's oracle/params, so we compute liquidatability ourselves over our own set, +evaluated at the auction's prices; the frame supplies **prices only**. Every candidate is a +`Candidate{marketId, borrower, market, position}` evaluated through the **one** shared path — so +liquidatability, accrual, sizing, and the profit floor are computed identically. Market state (totals + +IRM rate for accrual) and the position both come from our own monitor snapshot; no pushed position is +trusted to size a leg. + +### 3.1 The frame supplies prices, not positions + +`operationData` is **ours** — we sign it and the auctioneer never decodes it (verified: it sorts bids by +`bid` only; §6.3 won an auction with arbitrary `operationData`). The price push is **oracle-scoped**, not +position-scoped, so once an auction settles for an oracle **every** borrower underwater at the pushed +price is liquidatable by our callback. We exploit exactly that: ignore RedStone's `positions[]` and +target our full independently-discovered underwater set, evaluated at the frame's pushed price. The one +dependency we keep: an auction must fire and push the price for that oracle. + +### 3.2 Morpho API snapshot + +In production (`morphoApiUrl` set), the monitor snapshots **all Morpho data from the Morpho GraphQL API**: + +- `markets(where: {loanAssetAddress_in, collateralAssetAddress_in, chainId_in})` discovers markets served + by the configured adapter's on-chain loan token and redeemable collateral set. +- The same market query returns immutable params plus accrued market state (`borrowAssets`, + `borrowShares`, `supplyAssets`, `supplyShares`, `timestamp`, `blockNumber`, optional `price`). +- `marketPositions(orderBy: HealthFactor, orderDirection: Asc, where: {marketUniqueKey_in, + healthFactor_lte: discoveryMaxHealthFactor})` returns the at-risk position state (`borrowShares`, + `collateral`) for those markets. `maxTrackedPositions` is the logical cap; the OEV Morpho client + keeps each live request within observed API limits (`marketUniqueKey_in` chunks of 100, pages of 1000) + and then sorts/truncates the combined result by health factor. + +The API is not trusted blindly. Each market is locally validated by re-deriving the Morpho market id from +`(loanToken, collateralToken, oracle, irm, lltv)` and by checking the adapter pair +(`loan == adapter.vault().asset()`, collateral in `tokensToRedeem`). Malformed numbers, zero addresses, missing +collateral, bad ids, and transport/GraphQL errors fail closed and keep the prior snapshot. The hot bidding +path still has **no network I/O**: it reads this immutable snapshot, applies local Morpho math at the +auction price, replays same-market liquidations, and builds calldata. + +The only chain reads left in production monitor refreshes are adapter-specific cache data: adapter loan +token/redeemable collateral set/filler status and route quotes (`getMaxRate`, `getMaxAssets`, `paused`). +Those are not Morpho state and are needed to know whether the configured LiquidLane route can settle the +seized collateral. + +`morphoApiUrl` is a production hard requirement. The Sepolia harness is the only exception: with +`OEV_TEST_MONITOR=true`, the bot reads a fixed seed set from `OEV_TEST_MARKETS`/`OEV_TEST_POSITIONS` and +reads Morpho `market`/`position` state on-chain from the callback's `MORPHO()` getter. Public +`api.morpho.org` does not index the custom Sepolia deployment. + +### 3.3 Snapshot concurrency model + +The monitor exposes one `atomic.Pointer[snapshot]`, written by **exactly one goroutine** (the monitor +run loop builds a fresh, never-mutated snapshot and swaps it) and read concurrently by the WS goroutine +via `candidates()`. Readers never lock — they `Load` the current pointer. The Executor-state cache +(`cachedState`/`stateCache` in `solver.go`) and the nonce high-water mark follow the same single-writer / +lock-free-read model. + +Every mutable snapshot records exactly one source block and that block's timestamp. API markets from a +different `state.blockNumber` are dropped instead of being mixed into the same snapshot; the test monitor +uses the latest RPC header block. The hot path fails closed with `stale_epoch` when a non-empty snapshot +has no block tag/timestamp or when its block timestamp is more than a small Ethereum/Sepolia block-time +window behind the auction timestamp. This allows ordinary one-block monitor/API lag without letting a +stuck API cache bid indefinitely. The ops loop is separate: it refreshes Executor state, callback native +balance, loan↔ETH feeds, gas price, and gas-predictor getters on `opsPollMs`. + +**Stale-state gate (rule for all background caches).** Every background-refreshed cache the hot path +reads stamps a wall-clock `updatedAt` **only on a successful store** — today the monitor snapshot +(Morpho markets/positions + adapter/vault quotes) and the ops-loop `cachedState` (Executor accounting, +callback balance, loan↔ETH rate, gas predictor). Before any bid, `staleStateGate` fails closed with +`stale_state` (an error log naming each stale component and its age) when any stamp is older than +`intervals.maxStateAgeMs` — a loop that keeps failing while serving its prior data stops bidding instead +of running on arbitrarily old state. Startup config validation enforces that every background poll +interval (`opsPollMs`, `monitorPollMs`) is strictly less than `maxStateAgeMs`. **Any future +background-refreshed state consumed by the bid path MUST follow the same pattern: stamp `updatedAt` on +successful store, join `staleStateGate`, and include its refresh interval in the startup +`< maxStateAgeMs` validation.** + +### 3.4 Market scope + +The tracked Morpho markets are **discovered from the adapter**, not configured. The (loan, collateral) +pair is fully on-chain-derivable from the pinned adapter: loan = `adapter.vault().asset()` (cached +immutable), collateral = the adapter's redeemable token set (`getTokensToRedeemLength()` + +`tokensToRedeem(i)`, cached). The API query uses that pair and validates returned ids locally. The auction +frame's pushed positions are never a discovery source. + +--- + +## 4. Economics & sizing + +### 4.1 The three prices + +Sizing uses the right price at each step; conflating them is the easiest way to bid into a loss: + +1. **Market price** — what we expect on the oracle *at settlement* → drives liquidatability and what we + owe Morpho per unit seized (with local accrual). Production uses the **auctioned** frame price (the + auctioneer applies the frame's pushed price atomically before our callback). A TEST-ONLY env flag, + `OEV_ONCHAIN_PRICE_FOR_TEST=true`, instead sizes against our cached `oracle.price()` — required on the + dev testbed, where settlement does *not* apply the frame price (§6.6). Sizing against the wrong one + reverts `InvalidSwapRate`. +2. **Adapter redemption rate** (`getMaxRate` = the adapter oracle price × (1 − curator `minDiscount`), + read on-chain + cached, decimals-correct) → the loan token we expect for the seized RWA, minus an + extra `swapHaircutBps` safety cushion. Off-chain sizing uses it to estimate expected output; the leg + encodes only `maxSeizeAssets`, and the no-preview callback recomputes current output in + `onMorphoLiquidate` before approving repayment. + +Gas is **not** a sizing input and not part of the bid amount: the auction winner is decided purely by bid, +and the `(gasUsed + 35k) × tx.gasprice` liability is debited from the deposit AFTER settlement, independent +of the auction, and is not reverted if the deposit underpays (§6.2). It is still real cost, so the bot uses +`bidEth` as the bid floor but selects live bundles by +`loanToNative(bundleGrossLoan, rate) - gasNative(bundle) - bidNative(bundle)` and only bids when the selected bundle clears +`ceil(bidNative * bid.minBundleProfitBidBps / 10000)`. `bidNative(bundle)` is +`max(bidEth, loanToNative(bundleGrossLoan, rate) * bid.totalBundleProfitBps / 10000)`. +The rate is required and comes from the cached dual-feed oracle +(`loanEthFeed`: ETH/USD + loan/USD); the hot path never +does feed I/O. `gasNative(bundle) = gasUnits(bundle, cachedPredictorState) × maxTxGasPriceWei`. +`maxTxGasPriceWei` is also the value signed into EXECUTOR_V6; using one ceiling avoids winning an auction +with a gas cap too low for RedStone's settlement tx. +The route-aware gas estimate is converted to loan units by the solver at `maxTxGasPriceWei` and signed into +each leg as `minProfit`; the callback does no ETH↔loan conversion. Morpho liquidation is kept as a fixed per-leg component because fork measurements +showed only about 3k gas of branch spread across the observed partial/full sizing cases, while the LiquidLane +swap leg is classified as acquire-only, allocate+sync, or deallocate+allocate+sync from cached adapter/vault +getter state. If state is missing or insufficient, the leg falls back to the conservative unknown route. A +live or dry-run config must provide a rate source because `operationData` carries loan-denominated +profit floors. + +**Profit = swap proceeds − repayment** (in the loan token). When the auctioned price crashes below the +adapter's NAV rate, a leg captures both the liquidation bonus *and* that gap. Off-chain `sizeLeg` only takes +a leg when expected adapter output exceeds repayment. The bundle must also clear gas + dynamic bid + +`minBundleProfitBidBps` in `selectNetBundle`. On-chain, the no-preview callback executes each signed +`maxSeizeAssets` leg fail-soft through Morpho, sells the actual seizure, and reverts that leg unless realized +output covers Morpho's actual repayment plus the signed leg `minProfit`. After all fail-soft legs, the +callback reverts unless realized total loan profit clears the signed `minBundleProfit`, and only then enables +`payBid`. + +**One sizing strategy: MAX.** By default, target 100 % of collateral and let the debt/liquidity clamps size +it down. This captures bad-debt opportunities: the unit economics are still `swap proceeds − repayment`, +but a full-collateral bad-debt liquidation can have more total profit because it does not leave the final +collateral slice behind. If settlement routing ever has issues with full-collateral cases, set +`sizing.allowFullLiquidation: false`; that hard-disables full seize and uses the fixed 90 % partial fallback. +Profit is **linear in seize**, so this extracts the most per won auction while keeping an explicit kill +switch. + +### 4.2 One liquidation, one swap + +The contract is single-adapter: it seizes the collateral once and sells the WHOLE seizure through its one +immutable `LiquidLaneAdapter` in a single `swap`. A leg therefore carries one capped `maxSeizeAssets`, not a +per-vault split or a stale min-out. +`sizeLeg` produces one leg per liquidatable candidate: + +- The seize starts from either all collateral (`allowFullLiquidation: true`) or the fixed 90 % partial + fallback (`false`), then is CLAMPED by two bounds: the borrower's full debt (`maxSeizeForFullDebt` — so a + small-debt / large-collateral position can't over-seize and revert the Morpho `borrowShares` underflow) + and the adapter's `getMaxAssets` redemption liquidity + (`collForBudget` — so the expected output never exceeds what the vault can allocate and revert + `InsufficientAllocate`). +- Expected output is computed at the adapter's discounted `getMaxRate` minus `swapHaircutBps` for + profitability and route/budget checks only. It is not encoded into `operationData`; the callback + recomputes the current amount out before deciding whether to execute. + +### 4.3 Bundling + +`selectNetBundle` selects live bundles by after-cost net profit: +`loanToNative(sumProfitLoan, rate) - gasNative(bundle) - bidNative(bundle)`. It uses a bounded beam search over +deterministic gross-ordered subsets, so a locally best leg cannot block a lower-gross subset with better +shared-liquidity/gas economics. Every scored leg is expected-positive before gas; the final bundle must clear +gas, bid, and `bid.minBundleProfitBidBps`. + +Bundle depth is not configured. Each trial bundle must fit the gas envelope: +`predictedGas <= 85% * min(latestHeader.gasLimit, observed RedStone settlement cap 2M)`. Predicted gas is fixed bundle gas +(`100k` callback base + `35k` Executor debit surcharge + `40k` per updated RedStone feed), plus route-aware +first-leg and marginal gas. The current no-preview fork calibration is: acquire `300k` first / `140k` +marginal, allocate `530k` first / `350k` marginal, deallocate `650k` first / `450k` marginal, unknown +`850k` first / `650k` marginal. Beam search is bounded by candidate +count `N`, gas-fit depth `L`, and fixed width `W = 64`. It first sorts candidates in `O(N log N)`, then each +depth evaluates at most `W*N` extensions and sorts at most `W*N` trial states, so the practical bound is +`O(N log N + L*W*N*log(W*N))` time with `O(W*N)` transient states per depth. With +`maxTrackedPositions=10000`, `W=64`, and the observed 2M RedStone settlement cap, `L` is about 2 worst-route +legs or 10 acquire-only legs before other filters. + +A per-collateral cumulative `getMaxAssets` cap skips a leg that would over-commit a collateral's shared +adapter liquidity (several same-collateral legs would otherwise revert `InsufficientAllocate` on settlement). +Multiple borrowers from the same Morpho market are allowed only through sequential local replay: +after each candidate leg, the selector applies Morpho's seize-driven `liquidate` accounting to the simulated +market and re-sizes the next same-market candidate against that post-state. Independent precomputed same-market +legs must never be copied directly into `operationData`. +Dry-run without a rate source keeps the old gross-profit `selectBundle` path so operators can observe flat +bids without configuring loan↔ETH conversion. `maxTxGasPriceWei` is the hard ceiling for the +`tx.gasprice` signed into EXECUTOR_V6 and live net selection. + +**The bid has a floor and optional profit share.** The solver bids +`max(bidEth, grossProfitNative * bid.totalBundleProfitBps / 10000)`: the floor keeps thin auctions simple, +while the bps share can scale bids with larger bundles. Since the winner is decided purely by bid amount and +gas is debited post-settlement regardless, the bot bids only when the selected bundle clears the after-cost +profitability check, gated by the callback holding the bid native (payBid) and the Executor deposit holding +`MIN_DEPOSIT + predictedGas × maxTxGasPriceWei`. Every +`auction-result` frame is logged with the winning `bid` and whether we won, so a win-rate controller can +later consume those results; the bid remains solver-bounded off-chain (spec §8). + +--- + +## 5. Settlement, reservation & safety + +- **In-flight reservation.** A sent bid commits `reservedBid{wei, gasNative, nonce, at, positions}` against + cached headroom: its payBid native, predicted Executor-deposit gas debit, and the `(market, borrower)` + positions it liquidates. `buildBid` debits both funding pots (so two bids in one window can't double-spend + the callback or over-commit gas deposit) **and drops in-flight + positions from its candidates** — until a prior bid's settlement reflects on-chain, the snapshot still + shows the position liquidatable, so re-bidding it would revert `HEALTHY_POSITION`. Result frames release + the reservation immediately when we lose `auction-result` or when our `liquidation-result` arrives. Nonce + reconciliation and `reservationTTL` are backstops for missed frames. +- **Adapter authorization (one gate, fail closed).** The configured adapter is usable only if its own + swap-caller predicate passes (`ReadFillerStatus`: `callback == marketMaker() || callback == owner() || + isFiller(marketMaker, callback)`) — so the bot never bids a leg whose swap would revert `InvalidCaller`. + The single immutable `LiquidLaneAdapter` is pinned at construction, so this curator `setFiller` gate is the + only adapter-routing preflight. +- **Other pre-bid gates**: background-cache age (`stale_state`, vs `intervals.maxStateAgeMs` — §3.3), snapshot block epoch (`stale_epoch`), duplicate-auction + de-dup + `timeoutMs` drop, after-cost profitability (`gas_unprofitable`), deposit gas headroom + (`deposit_low`) + callback-native funding. The bid is bounded **off-chain** by `bid.bidEth` plus optional `bid.totalBundleProfitBps` — there is no on-chain bid cap. A deposit below MIN_DEPOSIT raises + an `oev_deposit_below_floor` gauge + error log; insufficient predicted-gas headroom logs a structured + skip with deposit/reserved/required values. Topping it up (and the callback's payBid native) is the + operator's job (`scripts/oev/oev-balance.sh`). +- **No self-funding.** The bot moves no funds outside the signed settlement: the callback's payBid native + pool is owner-refilled out-of-band and the signer's Executor gas deposit is topped up out-of-band. The + bot holds a signing key but its only fund-moving action is the signed bid the Executor settles. + +### The `SymbioticOevSolver` contract + +The on-chain settlement contract is the OEV `SymbioticOevSolver` in rfq `src/oev/`: a single-adapter router +(Morpho-only on-chain) whose constructor pins ONE immutable `LiquidLaneAdapter` and one `AUTH_SIGNER`. +The Executor calls `liquidate(bid, solver, operationData)`. The callback verifies the solver-signed +auction auth (`auctionKey`, `bidAmount`, `minBundleProfit`, `deadline`, and capped legs), marks +`auctionKey` used, then processes legs fail-soft. The deadline is solver-local replay protection for the +callback auth (`now + bid.authTtlMs`, default 60s); the auction's sub-second `timeoutMs` remains an +off-chain send gate. + +Each leg carries `marketId`, `borrower`, `maxSeizeAssets`, and a loan-denominated `minProfit`. +`SymbioticOevSolver` is a no-preview callback: it reads immutable Morpho market params, clamps the signed +max seize by the borrower's live collateral, calls `Morpho.liquidate`, and lets `onMorphoLiquidate` +validate economics from actual `repaidAssets`. Morpho is invoked with `try/catch`, so a +stale/healthy/reverting leg emits `LegResult` and the bundle can continue. During `onMorphoLiquidate`, the +callback sells the seizure through the immutable `LiquidLaneAdapter` at the current adapter rate, approves +Morpho repayment, and requires the realized gain to cover repayment plus the leg's signed `minProfit`. A +skipped or reverted leg contributes **zero** to the bundle profit — the bundle gate +(`BundleResult.bidAuthorized`) compares only the successful legs' realized profit against the signed +`minBundleProfit`. + +The payBid native pool is owner-funded (`receive`) and owner-withdrawable (`withdrawNative`/`withdrawERC20`). +`payBid` is gated on the bundle outcome: it pays the authorized bid amount only when `liquidate` cleared the +`minBundleProfit` gate; otherwise (gate failed, or no matching authorized liquidation) it emits +`PayBidResult(..., false)` and pays nothing — which the Executor records as `BidUnderpaid`, an event +RedStone counts toward deposit slashing / blacklisting. A skipped leg in a multi-leg bundle can therefore +convert a profitable settlement into a `BidUnderpaid` strike (see §10). + +Settlement events emitted on-chain (`LegResult` and `PayBidResult`; the Executor's +`LiquidationFailed(solver indexed, nonce)`) document settlement and post-mortem reasons. The bot does not +scan historical logs, but when WS supplies a `liquidation-result.txHash` it fetches that receipt and logs +decoded callback events. The breaker is still fed by the WS `liquidation-result` push (§2). + +--- + +## 6. Verified ground truth + +Primary sources: the RedStone OEV docs; `redstone-finance/redstone-evm-examples/oev`; the **verified +Executor source** (Blockscout Sepolia, impl behind proxy `0xfdFB1862…EBd`); Morpho Blue +(`morpho-org/morpho-blue@main`); and live Sepolia state. All deployed addresses are in +[`addresses.sepolia.json`](../scripts/oev/addresses.sepolia.json). + +### 6.1 Wire protocol + +- Connect: WSS + `x-api-key` header. ≤30 connections/key; server pings after 120 s idle; connections + force-closed ~8 h (rotate proactively at ~7 h). +- Subscribe: `{"op":"subscribe","topic":"oev/liquidations"}`, `oev/feeds` + (flat feed auctions are observed but not used as liquidation triggers), and + `oev/notify/`; + re-send after every reconnect. +- **Auction frame** (confirmed from 35+ live Sepolia frames): + `{"op":"auction","id":"","timestamp":,"timeoutMs":,"payload":{positions,prices}}`. The + live timing field is **`timeoutMs`** (not the docs example's `durationMs`). `positions[]` carries market + id, borrower, token addresses + decimals, and pushed balances; `prices{oracle → 1e36-scaled string}`. + The WS type keeps only `prices`: `positions[]` is ignored because the monitor's Morpho snapshot is the + position source of truth (§3.1–§3.2). +- **Solve frame**: `{"op":"solve","id":"","data":{"bid","nonce","operationCallback", + "operationData","liquidationSig","maxTxGasPrice","borrowers"?}}` — `bid` is a decimal **ether** string; + bids are sorted descending, highest wins, late replies discarded. +- **Notify frames**: `auction-result {bid, liquidator}` (we won iff `liquidator == callback.toLower()`), + `liquidation-result {success, txHash, …}`, `blacklisted {liquidator, msg}`. +- Frame schemas are vendored verbatim from RedStone's zod at + [`../openapi/redstone-oev-ws.zod.ts`](../openapi/redstone-oev-ws.zod.ts); Go structs are pinned to it + by tests. The on-chain half follows vendor-and-generate (the Executor ABI from the verified source). + +### 6.2 Executor semantics (from verified source) + +``` +execute(callback, operationData, liquidationSig, bidAmount, nonce, maxTxGasPrice, priceAdapter, priceUpdate) + onlyAuctioneer; require(tx.gasprice <= maxTxGasPrice); settlement tx currently arrives with about 2M gas + solver = ecrecover(EIP-191(keccak256(abi.encode("EXECUTOR_V6", chainid, callback, + keccak256(operationData), bidAmount, nonce, maxTxGasPrice)))) + require(!locked[solver]); require(nonce > nonces[solver]); nonces[solver] = nonce + require(deposits[solver] >= MIN_DEPOSIT) // 0.00001 ETH on Sepolia + priceAdapter.call(priceUpdate) // price lands BEFORE liquidate; revert ⇒ whole tx reverts + callback.liquidate(bidAmount, solver, operationData) // revert ⇒ LiquidationFailed(solver, nonce) + on success: callback.payBid(bidAmount) with 100k gas // underpay ⇒ BidUnderpaid; paid → oevCollector + liability = (gasUsed + 35k) * tx.gasprice; deposits[solver] -= min(liability, deposit) → auctioneer +``` + +Implications: the nonce is **strictly greater** than the stored one and is consumed even on a failed +liquidation (but not by losing/unsubmitted bids) — so we send `nonces(signer)+1` as a high-water mark and +resync from the getter. Crucially, the on-chain nonce jumps to the *winning* bid's signed nonce, which +reflects our local monotonic counter (incremented on every auction we bid, won or lost) — so a nonce delta +is **not** a failure count, which is why the breaker is fed by the WS `liquidation-result` push for our +callback (§2), not by nonce arithmetic. The deposit is a rolling gas prepayment; **bid acceptance is purely off-chain by +`bid`** (the deposit gate is on-chain at settlement only). `deposit()` credits `msg.sender` — the signer +EOA funds its own per-signer deposit; the callback cannot. `requestWithdraw` sets `locked` (no bidding) +until a 24 h cooldown. The Executor is a UUPS proxy upgradable by RedStone. + +### 6.3 EXECUTOR_V6 signing — proven, auction won + +A well-formed solve signed by the project signer won a live auction (`auction-result.liquidator == our +address`, lowercased) **with `deposits(signer)=0`** — confirming RedStone bid selection is off-chain by +`bid`, while Executor deposit enforcement happens at settlement. The solver still preflights cached +deposit headroom before signing. Golden vector (`eip191_test.go`; testnet key in +`OEV_SIGNER_PRIVATE_KEY`): + +``` +chainId 11155111, callback = signer, bid 1e14 wei, nonce 1, maxTxGasPrice 50e9, operationData over 2 borrowers +keccak(operationData) = 0x0a85a1be3cf06539edd05476a60cca5482e8ef0c4fa0bb6c1cf3f79fd0945509 +digest = 0x78f6eb68948cfeb1e16a81b050c111bf099628ff9dc51debb55f0b4fff2c7e5a +``` + +### 6.4 Morpho Blue (ported with exact rounding in `internal/morpho/math.go`) + +- Health: `borrowed = toAssetsUp(borrowShares, totBorrowAssets, totBorrowShares)`; + `maxBorrow = collateral.mulDivDown(price, 1e36).wMulDown(lltv)`; liquidatable iff `maxBorrow < borrowed`. + Oracle price scale `1e36 × 10^(loanDec − collDec)`. +- LIF `= min(1.15e18, WAD.wDivDown(WAD − 0.3e18.wMulDown(WAD − lltv)))` (lltv 0.86 ⇒ ≈1.0438). +- Seize-driven: `repaidShares` from `seizedAssets.mulDivUp(price,1e36).wDivUp(LIF).toSharesUp(…)`; repaid + amount re-derived `toAssetsUp` (rounds against the liquidator). `VIRTUAL_SHARES=1e6`, `VIRTUAL_ASSETS=1`. +- Accrual: `interest = totalBorrowAssets.wMulDown(borrowRateView.wTaylorCompounded(elapsed))` (3-term + Taylor); borrow *shares* never change on accrual. +- Callback order inside `liquidate`: state updates → collateral `safeTransfer` to caller → + `onMorphoLiquidate(repaidAssets, data)` → `safeTransferFrom(caller, morpho, repaidAssets)` (so the + callback must end holding ≥ `repaidAssets` loan token + approval). +- **Griefing**: a third-party 1-wei repay/shrink can stale a seize-derived full-debt clamp. RedStone Atom + settlement is private, so the default accepts full-collateral sizing to capture bad-debt opportunities; + `sizing.allowFullLiquidation: false` is the fallback if an environment needs the old partial-collateral + posture. + +### 6.5 Sepolia testbed + +Two stacks (full manifest in [`addresses.sepolia.json`](../scripts/oev/addresses.sepolia.json)): RedStone's +shared testbed (their Executor + a custom Morpho Blue + a TLOAN(6dp)/TCOL(18dp) market, 3 borrowers +underwater at different prices), and — to avoid RedStone's registry-owner gates — our **own** Symbiotic +core + TLOAN vault + TCOL LiquidLane adapter + the single-adapter `SymbioticOevSolver` (owner = signer). +Verified on-chain: `getAmountOut(TCOL,1e18)=2000e6`, `minDiscount=10000` (**ppm**, not bps — 1e6 = 100 %, +so 10_000 = 1 %), `isFiller(marketMaker,callback)=true`. LiquidLane gotchas: `swap` takes `tokenIn` from +the adapter's own balance (callback `transfer`s then `swap`s in one tx), bounded by +`getMaxRate`/`getMaxAssets`; `getMaxAssets`/`withdrawable` are **non-view** (read via `eth_call`). + +### 6.6 Proven live on Sepolia + +The single-adapter stack settles real OEV liquidations end-to-end: + +- **Fork rehearsal** — on a Sepolia fork, the `SymbioticOevSolver` deployed + wired, the price dropped, and + a real liquidation settled through real Morpho + the real LiquidLane adapter (status 1, collateral + seized, profit retained = swap proceeds − repayment, matching callback settlement events). +- **Live** — under `OEV_ONCHAIN_PRICE_FOR_TEST=true` (and `OEV_DRY_RUN` unset → real bidding), the bot won a live RedStone auction and settled + a **two-leg** liquidation through the callback (both borrowers seized, **+61.66 TLOAN** retained + on-chain), paying `payBid` from callback native and debiting the deposit by the gas liability. Earlier + single-leg runs proved +17.55 and +30.83 TLOAN. A deliberate unfunded revert confirmed the failure path + (`LiquidationFailed`, nonce consumed, deposit debited the gas liability per §6.2). + > Note: earlier live runs were against prior callback builds. The current no-preview callback keeps + > auction auth, signed min-profit checks, fail-soft leg execution, and bundle-level profit gating, while + > removing the expensive pre-liquidation preview path. + +**Dev-settlement caveat.** On the dev endpoint the auctioneer passes `priceAdapter=address(0)` with a +frozen `priceUpdate`, so settlement does **not** write the auctioned price — every settlement reverts +`HEALTHY_POSITION` unless the feed is moved out-of-band first. Hence `OEV_ONCHAIN_PRICE_FOR_TEST=true` on +dev testbeds that move the feed directly (size against cached `oracle.price()`) and production unset (the +auctioneer applies the frame price atomically). The Sepolia profile also runs `OEV_TEST_MONITOR=true`, +because public `api.morpho.org` does not index RedStone's custom test Morpho. + +### 6.7 Test harness & money model + +RedStone's Node harness (testnet keys in its own `.env`) makes repeatable live liquidations possible. The +market oracle is a MorphoChainlinkOracleV2 over a mock 8-dec `collateralFeed`; `oracle.price() = +feedAnswer × 1e16`. The harness moves price via `collateralFeed.setAnswer(priceUSD×1e8)` — the *same* +feed→oracle path prod uses, only the trigger differs. `scripts/oev/oev-testrun.sh` drives the loop (reset → drop +price → run bot → status); positions don't self-heal (a partial liquidation strips the LIF bonus, leaving +them *more* underwater, so a `reset` re-arms them). `scripts/oev/oev-balance.sh` gives a read-only `sheet` (every +pool + readiness warnings) and owner-key writes (`recycle`, `topup-callback`/`topup-deposit`, +`rebalance`). Note: the public Alchemy Sepolia endpoint accepts writes into a private pool without +relaying them — broadcast settlement-adjacent txs via a public relay (e.g. +`ethereum-sepolia-rpc.publicnode.com`); the bot itself only reads + connects WS, so its RPC choice is +immaterial. + +### 6.8 Testing / CI gating + +The default `make test` (`go test -race -cover ./...`, no build tags) runs **hermetic tests only** — no +network, no chain. The opt-in live suite is excluded from CI by build tags: + +- `make test-oev-live` → `//go:build live`, `TestLive*` — Morpho API borrower-discovery and token-pair + market-autodiscovery checks against the live GraphQL schema. + +Every other `*_test.go` is hermetic (config matrix, sizing/golden, EIP-191 golden+parity, single-token +bundling, WS integration via in-process `httptest`, chain-reader decoders against hand-packed ABI bytes) +and runs in CI. The contract (the OEV `SymbioticOevSolver` in the `rfq` repo's `src/oev/`, with its Forge +suite) covers the single-adapter settlement path; deploy via `script/DeployOevOwnCore.s.sol` +then wire/operate via `scripts/oev/oev-balance.sh setup-callback` + `oev-testrun.sh`. The bot's role ends at +signing + sending the WS solve (proven hermetically by `wsintegration_test.go`); RedStone's auctioneer +submits the actual `Executor.execute`. + +--- + +## 7. Configuration & operations + +All addresses / URLs / caps come from the YAML `solvers[].config` (CLAUDE.md: config is king). Secrets are +referenced by env-var name and read at point of use. The full annotated profile is +[`../config/redstone-oev.example.yaml`](../config/redstone-oev.example.yaml). + +| Field | Meaning | +|---|---| +| `ws.url` / `ws.apiKeyEnv` | RedStone WSS endpoint; `x-api-key` read from the named env var | +| `executor` / `callback` / `adapter` | Executor proxy, `SymbioticOevSolver`, and the single LiquidLane adapter | +| `morphoApiUrl` / `discoveryMaxHealthFactor` | Production Morpho snapshot endpoint and API health-factor band | +| `maxTrackedPositions` | logical cap for at-risk positions retained from Morpho API pages | +| `loanEthFeed.{ethUsd,loanUsd,maxAgeMs}` | required dual-feed loan↔ETH rate source | +| `bid.bidEth` / `bid.totalBundleProfitBps` / `bid.minBundleProfitBidBps` | minimum bid, optional gross-profit bid share, and optional bundle margin after gas + bid | +| `bid.authTtlMs` | solver-signed callback auth replay window; default 60s | +| `bid.maxTxGasPriceWei` | signed gas-price cap and the gas price used for bundle/deposit gates | +| `sizing.allowFullLiquidation` / `swapHaircutBps` | full-collateral policy and swap cushion | +| `breaker.maxFailures` / `windowMs` | failed-liquidation rolling-window halt plus immediate blacklist halt | +| `intervals.{ops,monitor}PollMs` | ops refresh cadence and monitor snapshot cadence (each must be < `maxStateAgeMs`) | +| `intervals.maxStateAgeMs` | max age of any background cache before bidding fails closed on `stale_state` (§3.3) | + +Hard cutovers already applied: + +- Static `bid.loanPerEth`, `bid.minBundleProfitLoan`, and `sizing.minLegProfitLoan` are removed. Rate comes + only from `loanEthFeed`; the solver converts predicted gas, bid, and margin into signed loan-denominated + `minProfit` / `minBundleProfit` floors before bidding. +- configurable `bid.gasBase` / `bid.gasPerLeg` is removed. Gas is route-aware from code constants plus + cached adapter/vault state. +- configurable `bid.maxLegsPerBid` is removed. Bundle depth is derived from the cached latest header gas + limit, the observed RedStone settlement cap, and the route-aware gas prediction. +- `sizing.maxSeizeFractionBps` is removed. Use `sizing.allowFullLiquidation`. + +Dev/test env knobs are not config fields: + +| Env | Meaning | +|---|---| +| `OEV_ONCHAIN_PRICE_FOR_TEST=true|1` | size against cached `oracle.price()` instead of auction frame price | +| `OEV_TEST_MONITOR=true|1` | use the Sepolia harness monitor instead of Morpho API | +| `OEV_TEST_MARKETS` / `OEV_TEST_POSITIONS` | comma/space-separated Sepolia harness seeds for `OEV_TEST_MONITOR` | +| `OEV_DRY_RUN=true|1` | sign and log would-bids, never send solves | + +Production leaves `OEV_ONCHAIN_PRICE_FOR_TEST` / `OEV_TEST_MONITOR` unset and requires `morphoApiUrl`. + +Useful scripts live under [`../scripts/oev`](../scripts/oev). The full deployed-address manifest is +[`../scripts/oev/addresses.sepolia.json`](../scripts/oev/addresses.sepolia.json). + +- `scripts/oev/oev-balance.sh sheet` shows Executor deposit, callback native, callback loan balance, + and readiness warnings. +- `scripts/oev/oev-balance.sh topup-deposit` tops up the signer deposit on the RedStone Executor. +- `scripts/oev/oev-balance.sh topup-callback` tops up callback native for `payBid`. +- `scripts/oev/oev-balance.sh recycle` / `rebalance` move testbed funds back into the ready pools. +- `scripts/oev/oev-testrun.sh` drives reset → price move → bot run → status on the Sepolia testbed. + +The Executor deposit is a rolling gas prepayment. Every settlement, including callback reverts, debits +`(gasUsed + 35k) * tx.gasprice` from the signer deposit. The callback native balance pays only `payBid`. +Before signing, the solver requires unreserved deposit for `MIN_DEPOSIT + predictedGas * maxTxGasPriceWei` +and unreserved callback native for the selected bid. + +The solver logs predicted gas units/routes on bid, records actual/predicted gas ratio, and decodes callback +`LegResult`/`PayBidResult` logs when a `liquidation-result.txHash` receipt is available. Tune predictor +constants just above observed successful settlements, not as a blunt worst-case multiplier. The current model +was checked on a Sepolia fork against both callback variants. Full no-preview wrapper settlements consumed +about 469,911 gas for one acquire leg, 588,048 for two acquire legs, 703,664 for one allocate leg, 969,948 +for two allocate legs, and 817,877 for acquire→allocate (all including the `+35k` Executor surcharge and a +single `~40k` RedStone feed update). The preview variant was consistently more expensive on success +(roughly +190k to +440k gas for one/two-leg common cases) and only helped one narrow high-min-profit fail +case, so the no-preview variant is the preferred production callback. Unknown routes use a separate +conservative fallback because no cached route state means the solver cannot price the path fairly. + +--- + +## 10. TODO / refinements + +- **Keep calibrating predictor constants from real settlements.** The solver logs predicted route/gas and + records actual/predicted gas ratio from `liquidation-result.txHash` receipts; tune constants above worst + observed successful settlements. +- **BidUnderpaid exposure on multi-leg bundles.** Legs settle fail-soft with zero contribution, while the + signed `minBundleProfit` (gas + bid + margin) assumes the whole bundle lands — one skipped leg can fail + the bundle gate, so `payBid` pays nothing and the Executor emits `BidUnderpaid`, which RedStone counts + toward slashing/blacklisting. Mitigations to evaluate: derive `minBundleProfit` so the gate passes when + the strongest leg lands; feed `BundleResult.bidAuthorized == false` (from receipt decode) into the + breaker; prefer single-leg bundles near the profit floor. +- **Adapter budget calibration.** The callback no longer signs a per-leg `maxAssets`; it takes the live + adapter rate and relies on the per-leg profit floor. Solver-side, keep the cached per-collateral + `getMaxAssets` budget clamp as the `InsufficientAllocate` defense with an over-reserve buffer for rate + rises. diff --git a/docs/RFQ-PLAN.md b/docs/RFQ-PLAN.md index 234530a9..d8d319cf 100644 --- a/docs/RFQ-PLAN.md +++ b/docs/RFQ-PLAN.md @@ -74,7 +74,8 @@ A new self-contained `internal/solvers/rfq/` implementing `solver.Solver` — no `IVaultV2`/`IERC4626` (from a standalone `core-mirror` build) are vendored via `make refresh-abi` + `bindings` (two `FORGE_OUT`/`CORE_MIRROR_OUT` sources). The nested `fill`/order ABI is encoded/decoded via the generated bindings, never hand-rolled. -- **Signer** — the framework's single EOA is the RFQ **caller** (holds `CALLER_ROLE` on the Executor). +- **Signer** — the framework's single EOA is the RFQ **caller** (must be in the Executor's `callers` + allowlist, added by the owner via `setCallers`). ### Component port map (TS → Go) @@ -82,16 +83,37 @@ A new self-contained `internal/solvers/rfq/` implementing `solver.Solver` — no |---|---| | `index.ts` + `server.ts` (Hono) | `solver.go` (`Run`: HTTP server + poll loop) + `server.go` (Huma routes/auth) | | `api.ts` (Zod schemas) | `apitypes.go` (request/response structs + Huma validation tags) | -| `quote.ts` + `strategy.ts` | `quote.go` + `strategy.go` (pricing, discount, leg selection) | -| `execution.ts` | `execution.go` (poll loop, order state machine, fill, recovery) | +| `quote.ts` + `strategy.ts` | `quote.go` + `strategy.go` (quote-server wiring) + `strategies/` (the pluggable decision layer: `default` = pricing/discount/leg selection, `webhook` = external decider) | +| `execution.ts` | `execution.go` (poll loop, order state machine, fill; fill-plan production/recovery lives in the strategy) | | `executor.ts` + `reactor`/`contracts.ts` | `order.go` (encode/decode reactor order, `fill` calldata) | | `backend.ts` + `discounts.ts` | `backend.go` (thin adapter over the generated `api/rfqbackend` client: `/orders`, `/discounts`) | | `contracts.ts` + `inventories.ts` | `chainreader.go` (multicall adapter/vault reads) + shared `chain` | -| `domain.ts` | `store.go` types + `strategy.go` types (records, legs, inventories) | +| `domain.ts` | `store.go` types + `strategies/types` (strategy input/output, fill plan, legs, candidates) | | `config/env.ts` + deployment manifests | `config.go` (typed `solver.config`) | | `db`/repositories | `store.go` (in-memory strategies/orders/attempts) | | `metrics.ts` | `metrics.go` (collectors on the shared registry) + framework `internal/observability` (`/metrics` — see §2) | +### Pluggable strategy layer + +Pricing, discount, and leg-selection are not baked into the solver — they live behind a per-solver +**strategy** interface (`DecideQuote` at quote time, `BuildFillPlan` at fill time), selected by a +`strategy: { name, config }` block. The solver owns transport (HTTP, chain reads, signing, +submission) and hands the strategy a snapshot of raw facts (the request, the per-adapter inventory +candidates); the strategy owns the decision. Two ship in-tree: + +- **`default`** — the in-process faithful port (greedy discount + leg selection). It caches its + quote-time plan by `quoteId` and, on a cold cache, rebuilds from live on-chain state, re-binding the + plan to the awarded order (tokenIn/tokenOut/amountIn, `quotedAmountOut ≥ required`). +- **`webhook`** — a transport-only adapter that delegates to an external decider over JSON. It keeps + **no local cache**: `BuildFillPlan` re-calls the decider at fill time (carrying the order's + `amountIn`/`requiredAmountOut`), so the external implementer owns caching and fill-time validation. + +The generic strategy pattern and trust model (solver provides raw facts; the trusted strategy is the +brain; the solver executes the output verbatim) are documented once in +[`strategy-plan.md`](strategy-plan.md), shared with every solver. The concrete RFQ input/output types +(`QuoteInput`/`QuoteOutput`, `FillInput`/`FillPlan`, `QuoteCandidate`) live in +`internal/solvers/rfq/strategies/types`. + --- ## 3. Configuration (env-agnostic: local / hoodi / mainnet) @@ -99,19 +121,22 @@ A new self-contained `internal/solvers/rfq/` implementing `solver.Solver` — no One code path; per-environment differences are pure YAML. Sketch of `solver.config` for `rfq`: ```yaml -solver: - name: rfq-filler - config: - backendUrl: https://rfq-backend.example - backendSharedSecretEnv: RFQ_BACKEND_SHARED_SECRET # env var NAME (secret never in config) - listenAddr: ":42073" # quote HTTP server (poll-only; no /notify) - executor: "0x…" # Executor (bot EOA holds CALLER_ROLE) - reactor: "0x…" - pollIntervalMs: 3000 - orderLimit: 20 - solverMode: external # "external" (default) | "internal" — see below - adapters: # LiquidLane adapter addresses (whitelist + recovery) - - "0x…liquidLaneAdapter" # vault + collateral resolved on-chain at startup +solvers: + - name: rfq-filler + config: + strategy: # pluggable decision layer (omit ⇒ default) + name: default # "default" (in-process) | "webhook" (external decider) + config: {} + backendUrl: https://rfq-backend.example + backendSharedSecretEnv: RFQ_BACKEND_SHARED_SECRET # env var NAME (secret never in config) + listenAddr: ":42073" # quote HTTP server (poll-only; no /notify) + executor: "0x…" # Executor (bot EOA is an authorized caller — setCallers allowlist) + reactor: "0x…" + pollIntervalMs: 3000 + orderLimit: 20 + solverMode: external # "external" (default) | "internal" — see below + adapters: # LiquidLane adapter addresses (whitelist + recovery) + - "0x…liquidLaneAdapter" # vault + collateral resolved on-chain at startup ``` **`solverMode` — the single internal/external knob (default `external`).** The backend discounts API is @@ -194,7 +219,8 @@ cached `decimals`), and recovery issues one 3-views-per-adapter aggregate3 (`pau ## 5. Open items / prerequisites -- **`CALLER_ROLE` on the `Executor`** — the bot EOA must be granted it before fills land (onboarding +- **Authorized caller of the `Executor`** — the bot EOA must be added to the Executor's `callers` + allowlist (owner-only `setCallers`) before fills land (onboarding prereq, analogous to 3F's offer-signer). Document; do not grant from the bot. - **Per-environment inputs needed to run**: backend base URL, `Executor` / `Reactor` addresses, the LiquidLane adapter address list (`vaults`; adapter whitelist + recovery — each adapter's vault and @@ -207,8 +233,9 @@ cached `decimals`), and recovery issues one 3-views-per-adapter aggregate3 (`pau JSON-RPC error such as a revert), so every read/send path inherits it unchanged. Endpoints are operator-configured (no hardcoded public-RPC lists); duplicates are de-duped; all must be the same chain. A single `rpcUrl` keeps the plain dial (any scheme). -- **Pricing is a faithful port for now** — same discount + greedy-leg selection as the TS - `selectBestStrategy`; a richer quoting strategy is a later follow-up (mirrors the 3F pricing TODO). +- **Pricing is a faithful port for now** — the `default` strategy is a faithful port of the TS greedy + discount + leg selection; a richer quoting strategy is a later follow-up (mirrors the 3F pricing + TODO), or an operator can plug their own via the `webhook` strategy (see the strategy layer below). - **Quote latency** — `/quote` is synchronous in the backend's fan-out, so keep it cheap: pricing is one `getAmountOut` multicall, and `tokenIn` decimals are read once and cached. A warm quote is a single multicall; only the first quote for a not-yet-seen `tokenIn` adds a one-off `decimals` read. diff --git a/docs/UNISWAPX-PLAN.md b/docs/UNISWAPX-PLAN.md new file mode 100644 index 00000000..65c4e128 --- /dev/null +++ b/docs/UNISWAPX-PLAN.md @@ -0,0 +1,710 @@ +# vault-solver — UniswapX Quoter + Filler solver (plan) + +Adding a **UniswapX RFQ quoter + filler** to `vault-solver` as the **`uniswapx-filler`** solver, following the +framework boundary and conventions in [`../CLAUDE.md`](../CLAUDE.md). We become a UniswapX **market maker**: +we expose the quote webhook Uniswap's RFQ server calls, win exclusivity, then settle the order on Uniswap's +Reactor — sourcing liquidity from our Symbiotic vault `LiquidLaneAdapter`s (vaults-first; a secondary-DEX hop +is a later step). + +--- + +## 1. What the solver does + +UniswapX RFQ ("Exclusive Dutch Auction") is, structurally, the same shape as our own Symbiotic RFQ — signed +orders, a settlement Reactor, off-chain quoters/fillers — so this solver reuses the `rfq` solver's machinery +almost wholesale, plus a UniswapX protocol adapter. + +- **Quote webhook (the ≤500ms hot path).** Uniswap's RFQ server makes a synchronous HTTP `POST` to a webhook + URL we register with them. We price the requested swap off the `LiquidLaneAdapter` oracle (`getAmountOut`), + apply a haircut + gas floor, and respond `200` with `amountOut` (or `amountIn` for `EXACT_OUTPUT`) **and our + `filler` = the on-chain `UniswapXExecutor` address** — that is how we claim last-look exclusivity. We + **decline** when we can't/won't price (see §4 for the decline semantics). +- **Order ingestion.** If our quote wins, Uniswap's API (the cosigner = Uniswap Labs) finalizes the order and + we pick it up by **polling `GET /orders`** (≤6 RPS, Uniswap's stated filler rate limit), dedup by + `orderHash`. Uniswap's `order-notification` push webhooks are **deprecated for new integrations** + (Filler FAQ), so poll is the only delivery channel; the `OrderSource` seam keeps push addable if + Uniswap ever re-enables it (§4.2). +- **Settlement.** We call **`executeWithCallback`** on Uniswap's V2 Dutch Order Reactor; our + `UniswapXExecutor` implements `IReactorCallback.reactorCallback`, sources the output token from the + `LiquidLaneAdapter` (vaults-first), and approves it back to the Reactor. Inputs are pulled from the swapper + via Permit2. This is the analog of our own `Executor.execute`, but for Uniswap's reactor interface. +- **Safety.** Fail-closed pre-fill validation gates + a fade-aware circuit breaker (Uniswap penalizes + win-but-don't-fill — see §4, §6). +- **State** — in-memory only: `quotes` (by `quoteId`), `orders` (state machine), `attempts`; TTL-swept. + +**Scope decisions (locked):** + +| Decision | Choice | Rationale | +|---|---|---| +| Our role | **Quoter + Filler (market maker)** | We provide liquidity for our own vault assets on UniswapX. | +| Liquidity source | **Symbiotic vaults first**; secondary-DEX hop later | Reuse `LiquidLaneAdapter` redemption pricing; widen pairs later. | +| Order version | **V2 first (mainnet); codec abstracted for V3** | "Goal is mainnet" (V2). Tempo + most L2s are V3 — slots in behind the same interface later. | +| Pricing v1 | **Redemption rate − fixed haircut**, gas-aware floor | Ship fast, tune later (matches how `3f`/`rfq` shipped). | +| On-chain executor | **UniswapX-specific** `UniswapXExecutor.sol` | Smallest, auditable surface; no multi-venue abstraction yet. | +| Code organization | **Sibling solver reusing rfq's `default` + `webhook` strategies** — the strategy layer (contract, registry, strategies) is promoted to a shared package on second use, alongside `internal/liquidlanemath` + `internal/webhook` | The strategies are protocol-neutral (verified, §2.1): selection/validation/caching/recovery all transfer; only candidate construction and pricing policy are solver-side. | + +**Directionality (structural constraint):** `LiquidLaneAdapter`s are one-way — they consume a +token-to-redeem and pay out the vault asset. So the only fillable orders are **RWA-in → vault-asset-out**: +`tokenIn` must be a token-to-redeem on a whitelisted adapter *and* `tokenOut` that adapter's vault asset +(native-ETH `tokenOut` maps to a WETH vault asset via executor unwrap, §7). Everything else — including +the reverse-direction opposing probe (§4.1) — auto-declines. + +**Out of scope (v1):** V3/Tempo order type (codec stubbed behind the interface), multi-output orders +(`numOutputs > 1` declined), secondary-DEX sourcing, self-funding, exclusivity-override (`exclusivityOverrideBps`) +economics, and quoting any pair our vaults can't settle. + +--- + +## 2. How it maps onto the framework + +A new self-contained `internal/solvers/uniswapx/` implementing `solver.Solver` — **no framework edits** +(CLAUDE.md modularity rule). Code organization follows the repo's **solver-local strategy architecture** +(see `docs/strategy-plan.md`). §2.5 is the consolidated reuse-vs-delta implementation checklist. + +### 2.1 Shared strategy logic — reusing the rfq strategy layer + +> Supersedes this plan's earlier `internal/symbiotic/` shared-tier proposal, in favor of the repo's +> **solver-local strategy architecture** (`docs/strategy-plan.md`): the framework never parses/routes +> strategy configs; each solver defines its own **strategy contract and registry**; and the genuinely +> cross-solver pieces live in **`internal/liquidlanemath/`** (the LiquidLane fixed-point rate math: +> `AmountOutForRate`, `MaxAmountInForRate`, `MinAmountInForAmountOut`, `RateForAmountOut`, `RATE_SCALE` +> 1e18) and **`internal/webhook/`** (a neutral HTTP-decider transport client — timeouts, body caps, +> env-backed headers, strict decode). + +**Verified against the rfq strategy implementation (2026-07-06): the `default` and `webhook` strategies +are protocol-neutral and reusable by `uniswapx` as-is.** Everything the `default` strategy does — +candidate matching by `tokenOut`, oracle pricing through the `Pricing` seam, greedy rate-sorted leg +selection on `liquidlanemath`, the validation/replay rules (legs reference input candidates, no +duplicates, ≤ `maxAssets`, achievable under `maxRate`, sums reconcile), the TTL fill-plan cache keyed by +`quoteId`, and `BuildFillPlan` recovery with the `RequiredAmountOut` gate — operates purely on neutral +types (addresses, big.Ints, candidates). The same holds for the `webhook` strategy and its JSON wire +contract. Neither knows anything about the RFQ backend; the only rfq-ness is the import path. The math is +a small fraction — **the strategies' real content is the adapter/leg selection + plan +validation/caching/recovery, and all of it transfers.** + +So `uniswapx` does **not** mirror per-solver copies — it triggers CLAUDE.md's shared-code rule +("hand-written domain adapters stay inside the owning solver *unless a second solver actually reuses +them*"): **on second use, the strategy layer is promoted out of `rfq/` into a shared package** and both +solvers consume it. Each solver still routes by its own `strategy: {name, config}` — the shared part is +the contract, registry, and the two strategy implementations. + +``` +internal/ + {config,chain,signer,txmanager,solver,observability}/ # framework — unchanged, protocol-agnostic + liquidlanemath/ # SHARED: rate math, reused verbatim + webhook/ # SHARED: remote-strategy transport, reused verbatim + llstrategy/ # PROMOTED from internal/solvers/rfq/ when uniswapx lands (naming TBD) + types/ # Strategy{DecideQuote, BuildFillPlan}, Pricing seam, QuoteInput/FillPlan + wire JSON + registry/ # name→factory registry + strategies/{default,webhook}/ # reused by rfq AND uniswapx, unchanged logic + solvers/ + rfq/ # consumes the promoted layer; backend candidate construction + discounts stay here + uniswapx/ # consumes the promoted layer; chain candidate construction + UniswapX plumbing here +``` + +- **Reused as-is:** `internal/liquidlanemath/`, `internal/webhook/`, and the promoted strategy layer — + contract, registry, `default` + `webhook` strategies with their selection/validation/caching/recovery + logic unchanged. The discount branch is simply never taken (uniswapx candidates carry no `DiscountID`). +- **Two things that need no strategy changes at all** (they compose from outside): + - **Pricing policy (haircut + gas floor)** is uniswapx **solver-side**, applied *after* + `DecideQuote`: quote `planOutput − haircutBps` to Uniswap while the plan sources the full amount + (the spread is the margin), and gate on the gas-aware min-profit floor before responding. The shared + `default` strategy stays policy-free — rfq behavior untouched. + - **The fill-time decay gate** maps directly onto `BuildFillPlan(RequiredAmountOut = resolved decayed + output at fill block)` — the strategy's built-in `QuotedAmountOut ≥ RequiredAmountOut` check *is* + the "price didn't decay past our quote" gate from §6. +- **The one contract delta — `EXACT_OUTPUT`:** `QuoteInput` is exact-input-only (`AmountIn` required, no + trade type). Options: (a) **v1 declines `EXACT_OUTPUT`** — zero contract change; or (b) add an + optional trade-type/amount-out field to the shared contract + an output-driven loop in `default` + (additive; rfq unaffected, it only ever sends exact-input). Decide in P1; (a) is the default posture. +- **Candidate construction is solver-owned** (the strategy architecture keeps chain reads with the + solver/strategy, not in a shared tier), and here lies the one **inversion vs `rfq`**: rfq receives quote-time candidates + (`maxAssets`/`maxRate`/`assetDecimals`) *in the backend's `/quote` request* and reads on-chain only for + recovery; UniswapX's quote request carries **no inventory**, so `uniswapx` builds candidates from chain + on every quote — its own `chainreader.go` modeled on rfq's (paused/`getMaxAssets`/`getMaxRate`, the + `marketMaker`/`owner`/`isFiller` authorization filter, startup vault/asset resolution, shared decimals + cache) over the configured `adapters`. Hot-path consequence: that's a second multicall next to + `getAmountOut` — either merge both into one `aggregate3` or refresh candidates in a background loop and + quote off the cached snapshot (≤500ms budget); decide in P1/P4. (If the rfq/uniswapx readers start + drifting, extracting a shared LiquidLane reader is a later, separate refactor — not assumed here.) +- **Cost/risk:** the promotion is a pure package move (import-path change, logic untouched) guarded by + the existing strategy tests; the behavior-preserving extraction of rfq's selection logic already + happened in the rfq strategy refactor. `uniswapx`'s P1 shrinks to: the package move, the candidate + reader, and (if chosen) the additive exact-output extension. + +### 2.2 Reuse of the generic layer (unchanged) + +- **`Run(ctx)`** starts the UniswapX **quote webhook server** *and* the `GET /orders` poll loop, blocking + until ctx cancels. The framework observability server (`:9090`) stays separate. +- **The quote server is code-first OpenAPI via Huma** — request/response structs carry validation + tags driving both inbound validation and the served spec (same approach as `rfq`). +- **`/metrics`** is the framework's shared registry; the solver registers its collectors via + `deps.Metrics.Registerer()` in the factory (HTTP middleware records request/latency by route). +- **Fills go through the shared `txmanager`** (CLAUDE: solvers never send directly). We build the + `UniswapXExecutor.execute` / reactor `executeWithCallback` calldata; txmanager owns nonce/send/receipt. +- **On-chain reads use `chain.Multicall`** (via the solver's candidate reader + the strategy's `Pricing` + seam, §2.1). +- **Addresses + URLs come from `solver.config`**; secrets (`UNISWAP_API_KEY`, the keeper key) via `*Env` + indirection (`os.Getenv` at point of use). +- **Signer** — the framework's single EOA is the UniswapX **keeper** (holds the role on `UniswapXExecutor`, + submits `execute`). We do **not** cosign or sign orders in production (that's Uniswap Labs + the swapper); + our EIP-712 code is verification + test-only signing. + +### 2.3 Component map (file → responsibility) + +| Go (`internal/solvers/uniswapx/`) | Responsibility | Reuse vs net-new | +|---|---|---| +| `solver.go` | `Run`: quote server + poll loop; `init()` self-register; factory | mirror `rfq` | +| `config.go` | typed `solver.config` (reactor/permit2/cosigner/executor addrs, ports, pricing knobs, adapters) | mirror `rfq` | +| `server.go` / `apitypes.go` / `middleware.go` | Huma quote webhook (`POST /quote`, ≤500ms), `/health`, OpenAPI; auth (each request priced independently — probe is indistinguishable, §4.1) | mirror `rfq` + net-new | +| `quote.go` / `chainreader.go` | quote orchestration: scope checks, build `QuoteCandidate`s from chain (§2.1), call the shared `Strategy.DecideQuote`, apply the solver-side haircut + gas floor, map to response + `filler` | mirror `rfq` + reader port | +| *(shared)* strategy layer | contract + registry + `default`/`webhook` strategies, promoted out of `rfq/` (§2.1) | **reused as-is** | +| `order.go` | **UniswapX V2 Dutch order codec** (serialize/parse), Permit2 witness EIP-712, cosignature verify; reactor calldata via abigen | **net-new** | +| `ordersource.go` | `OrderSource` — poll `GET /orders` (≤6 RPS), dedup by `orderHash`; interface seam for a future push channel | net-new (poll mirrors `rfq`) | +| `execution.go` | validation gates, build callbackData, `executeWithCallback` via txmanager, reconcile, breaker | mirror `rfq` + net-new | +| `store.go` / `metrics.go` | in-memory quotes/orders/attempts (TTL-swept); collectors on shared registry | mirror `rfq` | +| `backend.go` | thin adapter over the generated `uniswapx-service` poll client + the hand-vendored quote-webhook structs | mirror `rfq` | + +**On-chain (sibling `rfq` repo, `src/uniswapx/`):** `UniswapXExecutor.sol` — see §7. ABI vendored to +`api/bindings/uniswapx/` via `make refresh-abi`. + +### 2.4 Configuration (`solver.config`) + +One code path; per-environment differences are pure YAML (CLAUDE.md "config is king"). Secrets via `*Env` +indirection (read with `os.Getenv` at point of use, never stored in the parsed config). `chain` / `signer` / +`txManager` / `observability` come from the framework block, unchanged from the `rfq` profile. A full profile +(`config/uniswapx.mainnet.example.yaml`) lands in build phase P4. + +```yaml +solvers: + - name: uniswapx-filler + config: + orderType: v2 # v2 now; v3 later (codec is versioned) + reactor: "0x00000011F84B9aa48e5f8aA8B9897600006289Be" # V2 Dutch Order Reactor (per chain) + permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3" + cosigner: "0x…UniswapLabs" # verified on every order (per-order cosigner field §3.3) + executor: "0x…UniswapXExecutor" # our callback contract == the `filler` we quote + quote: + listenAddr: ":42080" # ≤500ms webhook (POST /quote, /health, OpenAPI) + apiKeyEnv: UNISWAP_API_KEY # env var NAME (secret never in config) + authHeader: "x-api-key" # static header Uniswap registers (confirm §10.1) + declineMode: zeroAmount # 200 + amountOut "0" — a 404 lands in Uniswap's axios ERROR path (§4.1) + orderSource: + poll: { url: "https://api.uniswap.org/v2", intervalMs: 1000 } # GET /orders, ≤6 RPS; dedup by orderHash + strategy: # shared strategy layer (docs/strategy-plan.md), routed by name + name: default # default (greedy direct-leg) | webhook (remote decider) + config: {} # opaque to the solver; parsed/validated by the strategy + pricing: # uniswapx solver-side policy, applied after DecideQuote (§2.1) + haircutBps: 30 + priceBasis: auctioned # auctioned (prod) | onchain (size vs cached oracle) + minProfitWei: "1000000" + loanPerEth: "0" # base units per 1 ETH; enables gas-netting (0 => flat) + estGasPerFill: 300000 + maxTxGasPriceWei: "60000000000" # 60 gwei — bounds per-tx gas price + breaker: { maxFailures: 3, windowMs: 3600000 } # + honor Uniswap blockUntilTimestamp + adapters: # vaults-first inventory; vault+asset resolved on-chain at startup + - "0x…liquidLaneAdapter" +``` + +### 2.5 Implementation delta vs `rfq` — what reuses, what changes, what's manual + +The working checklist for the build: everything below is either lifted from the shipped `rfq` solver, +deliberately different from it, or an operational step `rfq` never needed. (Verified against the `rfq` +code 2026-07-06 — see §2.1 for the extraction evidence.) + +**Reused as-is:** + +- `internal/liquidlanemath/` — the LiquidLane fixed-point rate math (`AmountOutForRate`, + `MaxAmountInForRate`, `MinAmountInForAmountOut`, `RateForAmountOut`, `RATE_SCALE` 1e18), verbatim. +- `internal/webhook/` — the neutral remote-decider transport client, verbatim (backs the optional + `webhook` strategy). +- `rfq`'s `default` and `webhook` strategies — selection, validation/replay, fill-plan caching, and + recovery — **reused as-is via the promoted shared strategy package** (§2.1); the discount branch is + never taken (UniswapX candidates carry no `DiscountID`). +- The chain-reader surface, ported as the uniswapx candidate reader (§2.1): batched `getAmountOut`, + paused/`getMaxAssets`/`getMaxRate` reads, adapter→vault→asset startup resolution, the + `marketMaker`/`owner`/`isFiller` authorization filter, shared decimals cache. +- Solver scaffolding patterns 1:1: `init()` registration + factory, the shared strategy + contract/registry (`Strategy{DecideQuote, BuildFillPlan}` + `Pricing` seam + `strategy: + {name, config}` config routing), Huma code-first quote server + middleware stack, in-memory TTL-swept + store keyed by `quoteId`, poll loop + order state machine shape, calldata-only submission through the + shared `txmanager`, collectors on `deps.Metrics.Registerer()`. + +**Done differently from `rfq` (the real implementation work):** + +| # | Area | `rfq` does | `uniswapx` must do | +|---|---|---|---| +| 1 | Quote-time inventory | Backend sends `adapters[]` (maxAssets/maxRate/decimals) in the `/quote` body; on-chain inventory read is recovery-only | **Self-source on-chain** over configured `adapters`: the recovery read becomes the quote-time read. Hot path: merge the inventory + `getAmountOut` multicalls into one `aggregate3`, or price off a background-refreshed snapshot (≤500ms) — §2.1 | +| 2 | Quote wire contract | Backend schema, `x-rfq-shared-secret`, 204 decline, 422 on schema violation | UniswapX Joi schema (hand-vendored, golden-tested), static API-key header + source-IP allowlist, **`200`+`amountOut:"0"` decline**, echo the obfuscated `requestId`, answer the indistinguishable opposing probe independently — §4.1 | +| 3 | Quoted price policy | Quotes the raw oracle `getAmountOut` (no margin) | Apply **`haircutBps` + gas-aware min-profit floor** solver-side *after* `DecideQuote` (the shared strategy stays policy-free; the spread is the margin); below floor ⇒ decline — §2.1, §5 | +| 4 | `EXACT_OUTPUT` | Hard-rejected at validation | Shared `QuoteInput` is exact-input-only: **v1 declines `EXACT_OUTPUT`** by default; optional additive contract extension + output-driven loop if flow warrants — §2.1, §5 | +| 5 | Order ingestion | Polls own backend `GET /orders`, decodes the Symbiotic Reactor order from the backend payload | Polls Uniswap `GET /orders?filler=` (≤6 RPS); **net-new V2 Dutch order codec + Permit2 witness EIP-712 + cosignature recovery** (`order.go`, the riskiest unit — P2) — §3.3, §4.2 | +| 6 | Pre-fill validation | Order-deadline + strategy↔order binding checks | Those **plus**: cosigner recovers to configured `cosigner`, `exclusiveFiller == our executor`, decay window still fillable, resolved output at current block ≥ quoted floor — §6 | +| 7 | Settlement call | `Executor.fill(order, protocolSig, swaps[], discountSwaps[], executorData)` on our Reactor | `UniswapXExecutor.execute(SignedOrder, callbackData)` → Uniswap reactor `executeWithCallback` → `reactorCallback` runs the adapter swaps; callbackData built from the cached fill plan (`FillPlan.Legs` returned by `BuildFillPlan`, §2.1) (map 1:1 onto `Swap{recipient,tokenIn,amountIn,amountOut}`); **native-ETH outputs unwrap WETH and forward ETH** — §7 | +| 8 | Failure economics | Failed fill ⇒ order re-armed next poll; no external penalty | **Fade penalty regime**: fail-closed gates before gas, local breaker, honor `blockUntilTimestamp`, quote only what inventory certainly fills — §6 | +| 9 | Not ported at all | Discount legs, backend `/discounts`, `solverMode` internal/external split | None of it — direct legs only; scoping is just the configured `adapters` list | + +**Manual / operational (no `rfq` analogue — `rfq` only needed a shared secret with our own backend):** + +- Uniswap onboarding: intake form, `UNISWAP_API_KEY`, register the quote URL + filler address + + chainIds (S3-provisioned by Uniswap), allowlist their RFQ source IPs — §10.1–10.2. +- Beta qualification: 5 exclusive fills with real funds, tx hashes emailed for manual promotion — §10.4. +- On-chain: deploy `UniswapXExecutor`, grant the keeper EOA, get the executor authorized as + `isFiller`/`marketMaker` on each sourced adapter, confirm adapter `ALLOCATE_ROLE` — §10.3. +- Vendoring: UniswapX reactor + Permit2 ABIs → `api/bindings/uniswapx/`, `uniswapx-service` + `swagger.json` → generated poll client, hand-vendored quote-webhook structs — §4.3, P0. + +--- + +## 3. UniswapX protocol reference (verified ground truth) + +Collected and source-verified during planning; **re-verified 2026-07-06** against the UniswapX repo, +the uniswapx-sdk `constants.ts`, and developers.uniswap.org. Treat as the contract-of-record; re-verify +addresses against the live deployments page before going live. + +### 3.1 Auction model & order versions per chain + +UniswapX RFQ uses the **Exclusive Dutch Auction**: the winning quoter's `filler` address is set as +`exclusiveFiller` and may fill during a short exclusivity window before the order decays open to permissionless +fillers. A non-exclusive filler can override exclusivity only by paying the swapper extra +(`exclusivityOverrideBps`); the swapper is never worse off. When `exclusivityOverrideBps == 0` (strict +exclusivity, `ExclusivityLib` reverts `NoExclusiveOverride`) no override is possible at all; Uniswap's +hard-quote cosigner sets a nonzero default. + +Mainnet RFQ is now branded **"UniswapX RFQ V2"** — an *off-chain* redesign (indicative quotes pre-signature +vs **hard quotes** post-signature, with hard-quoters "held fully accountable"). On-chain settlement is +unchanged: it still runs the `V2DutchOrderReactor`. Consequence for us: fade discipline (§6) is +program-critical, not just polite. + +| Chain | Order type | Decay | Quoter-relevant? | +|---|---|---|---| +| **Ethereum mainnet (chainId 1)** | **V2** Dutch | time-based (`decayStartTime`/`decayEndTime`); exclusivity ~24 s (2 blocks) | **Yes — our first target** | +| Tempo, Base, Arbitrum, Avalanche, BNB, Unichain, Robinhood Chain | **V3** Dutch | block-based, nonlinear (`decayStartBlock`, `relativeBlocks[]`/`relativeAmounts[]`); exclusivity ~2–4 s | Yes (later) | + +- Exclusivity window on mainnet is **"currently about 24 seconds (2 blocks)"** — long enough that ≤1 s + polling (§4.2) comfortably fits inside it. +- The chain matrix keeps expanding (Robinhood Chain + Arc were registered June 2026) — **confirm the live + matrix with Uniswap** (§10). A V3 reactor is also deployed on mainnet but docs state mainnet RFQ does + not route to it today. + +### 3.2 Deployed addresses (mainnet, chainId 1 — verify before use) + +| Contract | Address | +|---|---| +| V2 Dutch Order Reactor | `0x00000011F84B9aa48e5f8aA8B9897600006289Be` | +| V3 Dutch Order Reactor | `0x0000000015757c461808EA25Eb309638B62681cf` | +| ExclusiveDutchOrderReactor (V1) | `0x6000da47483062A0D734Ba3dc7576Ce6A0B645C4` | +| OrderQuoter | `0x54539967a06Fc0E3C3ED0ee320Eb67362D13C5fF` *(docs report several variants — verify per chain)* | +| Permit2 (all chains except zkSync Era — out of scope) | `0x000000000022D473030F116dDEE9F6B43aC78BA3` | +| Arbitrum V3 Reactor | `0xB274d5F4b833b61B340b654d600A864fB604a87c` | +| Base DutchV3 Reactor | `0x000000008a8330B5d1F43A62Bf4C673A49f27ba0` | + +Reactor constructor (V2 and V3): `constructor(IPermit2 _permit2, address _protocolFeeOwner)`. + +**Testnet reactors (from the SDK `REACTOR_ADDRESS_MAPPING`, NOT the docs deployments page — the docs omit +testnets).** These exist on-chain and are usable for settlement/fill testing (§8); there is **no RFQ *server*** +on these chains, so the quote/order-delivery half can't be driven by Uniswap there. + +| Chain | Order type | Reactor address | +|---|---|---| +| **Sepolia (11155111)** | **Dutch V2** | `0x0e22B6638161A89533940Db590E67A52474bEBcd` | +| Sepolia (11155111) | Dutch V1 | `0xD6c073F2A3b676B8f9002b276B618e0d8bA84Fad` | +| Unichain Sepolia (1301) | Hybrid (v4) | `0x000000000C75276D956cc35218ca8f132D877957` | +| **Tempo (4217)** | Dutch V3 | `0x00000000fc1E66C9f582566EAd00108e55F1c0C6` (RPC `https://rpc.tempo.xyz`) | + +Source of truth for deployed addresses is the SDK `sdks/uniswapx-sdk/src/constants.ts` `REACTOR_ADDRESS_MAPPING` +(Permit2 canonical on all incl. Sepolia). The `uniswapx-tool` CLI's quote/order flow is **mainnet-only** +(`ChainId` enum = {1, 42161, 8453}; `Env.Beta`/`Env.Prod` both hit the prod gateway) — testnets are reachable +only for direct on-chain settlement. + +### 3.3 V2 Dutch order struct & cosignature + +``` +SignedOrder { bytes order; bytes sig } // order = ABI-encoded V2DutchOrder; sig = swapper Permit2 signature + +V2DutchOrder { + OrderInfo{ reactor, swapper, nonce, deadline, additionalValidationContract, additionalValidationData } + address cosigner // per-order field (Uniswap Labs in prod); no reactor ctor/setter + DutchInput baseInput // token, startAmount, endAmount + DutchOutput[] baseOutputs // token, startAmount, endAmount, recipient + CosignerData{ decayStartTime, decayEndTime, exclusiveFiller, exclusivityOverrideBps, inputAmount, outputAmounts[] } + bytes cosignature +} +``` + +**Cosignature verification (verbatim from `V2DutchOrderReactor._validateOrder`):** +```solidity +address signer = ecrecover(keccak256(abi.encodePacked(orderHash, abi.encode(order.cosignerData))), v, r, s); +if (order.cosigner != signer || signer == address(0)) revert InvalidCosignature(); +``` +Digest = `keccak256(orderHash ‖ abi.encode(cosignerData))`, signed **raw** (no EIP-191 prefix). Because +`cosigner` is a per-order field with no on-chain setter, **we can self-cosign in tests** with any key we hold — +this is what makes self-driven local E2E possible (§8). + +### 3.4 Settlement interfaces (Uniswap's reactor) + +```solidity +function executeWithCallback(SignedOrder calldata order, bytes calldata callbackData) external payable; +interface IReactorCallback { function reactorCallback(ResolvedOrder[] memory, bytes memory) external; } +``` +Flow: reactor validates order + swapper Permit2 sig → transfers input to our executor → +`reactorCallback(resolvedOrders, callbackData)` → we source the output + approve it to the reactor → reactor +delivers output to the swapper and verifies amounts. + +--- + +## 4. API schemas & event/delivery model + +**There is no websocket/stream.** Inbound traffic is exactly one surface — the **quote webhook we register +with Uniswap** (their config is S3-backed; we do not self-serve registration). Won orders are fetched by +**polling** (`GET /orders`); Uniswap's order-delivery webhooks are **deprecated for new integrations** +(§4.2). Schemas live in two repos: `uniswapx-parameterization-api` (quote webhook, **Joi**, no OpenAPI) +and `uniswapx-service` (order pool, Joi + an OpenAPI `swagger.json`). + +### 4.1 Quote webhook — Uniswap → us (synchronous `POST`, ≤500ms) + +- **Method/timeout:** `axios.post`, `application/json`, **500ms on every chain** (`WEBHOOK_TIMEOUT_MS_DEFAULT + = 500`; the FAQ's "250ms on non-mainnet" figure is stale). `ECONNABORTED` past that → we silently lose. + Source: `lib/quoters/WebhookQuoter.ts`, `lib/constants.ts`. +- **Two POSTs per request, in parallel:** the real quote **plus an "opposing" probe** (inverted `type`, + swapped `tokenIn`/`tokenOut`) for price discovery. Since June 2026 (parameterization-api #456, + "obfuscate two-sided RFQ quote") each of the two carries a **distinct fresh `requestId`** and they are + sent in randomized order — the pair is **indistinguishable and uncorrelatable by design**. So: no + probe-pairing logic anywhere; price every request independently and honestly. For us the probe's + reverse direction (vault-asset → RWA) is structurally unfillable and auto-declines (§1 directionality). +- **Auth:** **no signed scheme** — static headers we register (e.g. an API key) are sent verbatim. The FAQ + also publishes fixed RFQ source IPs to whitelist (Beta `3.135.148.114`, Prod `3.138.88.28`). Confirm at + onboarding. +- **Decline:** **`200` with `amountOut: "0"`** — the one form that demonstrably flows through the graceful + `isNonQuote()` path. Do **not** use `404`: axios' default `validateStatus` rejects non-2xx, so a 404 + lands in Uniswap's *error* path (`HTTP_ERROR`, same bucket as a timeout). The public docs say `204` — + confirm its handling at onboarding (§10.1), but `zeroAmount` is the shipped default. +- **Response must echo the (obfuscated) `requestId` received** or it's dropped (`RFQ_FAIL_REQUEST_MATCH`). + +**Request body** (`PostQuoteRequestBodyJoi`, `QuoteRequest.toCleanJSON()`): +```jsonc +{ + "tokenInChainId": number, // required + "tokenOutChainId": number, // required, MUST equal tokenInChainId (same-chain only) + "requestId": string, // required + "tokenIn": string, // required ERC20 (native ETH = 0x000...000) + "tokenOut": string, // required ERC20 + "amount": string, // required base-unit integer string + "swapper": string, // 0x000...000 at quote time — swapper is hidden; price on pair+amount only + "type": "EXACT_INPUT" | "EXACT_OUTPUT", + "numOutputs": number, // required >= 1 + "protocol": string, // default "V1" + "quoteId": string // optional uuid +} +``` + +**Response body** (`RfqResponseJoi`): +```jsonc +{ + "chainId": number, "requestId": string, // echo requestId + "tokenIn": string, "amountIn": string, + "tokenOut": string, "amountOut": string, // "0" => decline + "filler": string, // our UniswapXExecutor address + "quoteId": string +} +``` + +### 4.2 Won/cosigned order delivery — us ← Uniswap (POLL-ONLY) + +**Order webhooks are deprecated.** Per Uniswap's Filler FAQ, `order-notification` webhooks "were deprecated +on UniswapX due to degraded performance" and **new webhook integrations are no longer onboarded** — +"fillers should start with polling for orders and rate limit at 6 RPS". So there is no push half: +`OrderSource` is poll-only, behind an interface seam that admits a push channel if Uniswap re-enables one. + +**POLL — `GET https://api.uniswap.org/v2/orders`** (mainnet; Beta base `https://beta.api.uniswap.org/v2`), +**≤6 RPS**: +- Query: `orderStatus=open&filler=&chainId=` (+ `limit, cursor, sortKey=createdAt, sort, desc, + orderHash(es), swapper, pair`). `orderStatus ∈ {open, expired, error, cancelled, filled, insufficient-funds}`. +- Response: `{ orders: OrderEntry[], cursor? }`; a Dutch V2 entry carries `encodedOrder, signature, + cosignature, cosignerData{decayStartTime, decayEndTime, exclusiveFiller, inputOverride, outputOverrides[]}, + input, outputs[], orderHash, chainId, swapper, txHash, quoteId, requestId, nonce, ...`. The + `encodedOrder` + swapper `signature` *is* our `SignedOrder{order, sig}` — directly fillable. + +**Ingestion design:** poll tight — `intervalMs` ≈ 500–1000, well inside the 6 RPS budget. Against the ~24 s +mainnet exclusivity window a 1 s cadence costs at most ~1 s of the window. Dedup by `orderHash`. + +**Hard-quote** (`POST /hard-quote`, parameterization-api) — the synchronous cosigning flow where the KMS +cosigner sets `exclusiveFiller` to the `filler` we returned (with a nonzero default +`exclusivityOverrideBps`). **We do not implement it**; it explains how our quote becomes a won order. + +### 4.3 Vendoring plan (matches CLAUDE.md codegen discipline) + +- **Poll client:** vendor `uniswapx-service/swagger.json` (OpenAPI 3.0.0; raw GitHub URL in §11) → generate the + Go client via the Java openapi-generator (same pipeline as `openapi/rfq-backend.openapi.json`). Covers only + `/orders`, `/limit-orders`, `/nonce`. +- **Hand-vendored structs (no OpenAPI):** the quote webhook (request/response) — transcribed from the Joi + files into Go structs with Huma validation tags, **golden-tested** against committed fixtures. (The + hard-quote and deprecated push-notification shapes are reference-only; not vendored.) +- **Onboarding step:** with `UNISWAP_API_KEY`, pull the *runtime* `/v2/uniswapx/docs` spec (gated; may be + richer than the GitHub copy) and re-vendor if it differs (§10). + +--- + +## 5. Pricing (v1: redemption rate − fixed haircut) + +On the ≤500ms path, mirroring `rfq`'s "one multicall, decimals cached" discipline: + +1. Map the request → internal; **decline (`200`+`amountOut:"0"`) fast** on: wrong/`!=` chainId, unfillable + direction (§1: `tokenIn` must be a token-to-redeem on a whitelisted adapter *and* `tokenOut` that + adapter's vault asset; native-ETH `tokenOut` maps to a WETH vault asset, unwrapped at settlement §7 — + this rule also auto-declines the opposing probe), `numOutputs > 1`, or no viable inventory. +2. Build `QuoteCandidate`s (solver-owned, §2.1): cached `tokenIn` decimals + the candidate/authorization + reads + **one Multicall3 `getAmountOut`** across candidate adapters for the matching asset (served to + the strategy through its `Pricing` seam). +3. `Strategy.DecideQuote` (the shared `default` strategy): greedy direct-leg selection on + `internal/liquidlanemath`. Then the **solver-side policy** (§2.1): `quote = planOutput − haircutBps`, + then a **gas-aware min-profit floor** (est. fill gas × gas price, netted at the configured loan/ETH + rate). Below floor ⇒ **decline**. +4. Persist the strategy by `quoteId` (TTL-swept), return `200` with `amountOut` (or `amountIn` for + `EXACT_OUTPUT`) + `filler` = `UniswapXExecutor`. + +`EXACT_OUTPUT`: the shared `QuoteInput` contract is exact-input-only, so **v1 declines `EXACT_OUTPUT` by +default** (§2.1). If flow data says it matters, the additive path is an optional trade-type/amount-out +field on the shared contract plus an output-driven loop in `default` walking the same rate-sorted legs on +`liquidlanemath.MinAmountInForAmountOut` — `rfq` is unaffected either way (it only ever sends +exact-input). Pricing is intentionally naive for v1; a competitive/win-rate controller (modeling +`exclusivityOverrideBps`, time-in-auction, competing fillers) is a later follow-up — the pricing policy +function is the seam to extend. + +--- + +## 6. Safety & fade-aware circuit breaker + +Uniswap penalizes **win-but-don't-fill** ("fade"): a temporary disable starting at **15 minutes**, increasing +**exponentially** for consecutive fades, surfaced as a `blockUntilTimestamp`. Sustained ≤500ms breaches can +also suspend. RFQ V2 explicitly holds hard-quoters "fully accountable" for winning quotes (§3.1), so safety +is economic, not just gas: + +- **Fail-closed pre-fill gates** (in `execution.go`, before spending gas): cosignature recovers to the + configured `cosigner`; `cosignerData.exclusiveFiller == our executor` (we actually won); order + `deadline`/`decayStartTime` still fillable; resolved output at current block ≥ our cached quoted output + (price didn't decay past our floor — the `priceBasis: auctioned|onchain` knob applies); strategy↔order + token/amount binding. Any failure ⇒ skip, no tx. +- **Quote only what we can certainly fill** — inventory present + gas floor cleared — so we rarely fade. +- **Local breaker** (the OEV `breaker{maxFailures, windowMs}` pattern) halts *quoting* after N reverts in a + window. +- **Honor `blockUntilTimestamp`** from Uniswap — stop quoting until it passes; surface as a metric + log. + +--- + +## 7. On-chain settlement contract — `UniswapXExecutor.sol` + +New contract in the sibling `rfq` repo (`src/uniswapx/`), UniswapX-specific (no multi-venue abstraction), +mirroring our existing `Executor.sol` role-gating: + +- `reactorCallback(ResolvedOrder[] calldata, bytes calldata callbackData)` — guarded `msg.sender == reactor`; + routes each resolved order's input through the `LiquidLaneAdapter` named in `callbackData`, approves outputs + back to the reactor. +- **Native-ETH outputs:** the reactor pays native outputs from **its own balance**, not via `transferFrom` — + for a native-output order the callback unwraps the WETH received from the adapter and forwards ETH to the + reactor within the callback; ERC-20 outputs are approved and pulled. In scope for v1 (ETH-output flow is + likely the largest real market for a WETH-vault asset like wstETH). +- `execute(SignedOrder, bytes callbackData)` — entry gated to our **keeper EOA** (owner/role), so only we + trigger our own exclusive fills; calls `IReactor(uniReactor).executeWithCallback(order, callbackData)`. +- `callbackData` = ABI-encoded `(adapter, swapParams)[]`, built off-chain in `execution.go` from the cached + strategy. The **secondary-DEX route** is a later variant of this same blob (vaults-first now). +- Owner-set: reactor address, adapter allowlist, sweep/rescue. +- ABI vendored → `api/bindings/uniswapx/`; `executeWithCallback` calldata packed via abigen `--v2` (never + `abi.Pack("...")`). +- **Forge fork integration test** mirrors UniswapX's own `test/integration/*.t.sol` (self-cosign loop, §8). + +--- + +## 8. Validation & testing strategy + +**There is no testnet RFQ *server*, but there IS a testnet *reactor*.** Uniswap's quote/order API is mainnet +only (Beta is *mainnet*, production contracts, real funds — the `uniswapx-tool` CLI confirms `ChainId` = +{1, 42161, 8453}). **However**, the SDK ships **deployed Sepolia reactors** (§3.2) — a live **Dutch V2 reactor +on Sepolia** (`0x0e22B6…BEBcd`) + canonical Permit2 — so the settlement/fill half can be exercised on a real +public testnet, not just a fork. The quote-request half stays self-driven/synthetic until Beta. + +**Layer 1 — quote-path, synthetic & local (no Uniswap, no funds).** We *are* the RFQ server: an `httptest` +harness + a small local mock POSTs requests built to the exact `uniswapx-parameterization-api` schema (incl. +the opposing probe with its distinct obfuscated `requestId`, §4.1) at our ≤500ms webhook — asserting pricing, +decline (`amountOut:"0"`), the `200` shape, `requestId` echo. Wire format is pinned, so synthetic ≈ real for +the contract. + +**Layer 2 — settlement, real Sepolia or mainnet fork (no funds).** Preferred: the **live Sepolia Dutch V2 +reactor** (`0x0e22B6…BEBcd`, §3.2) + canonical Permit2 — a persistent, shareable public testbed; deploy our +`LiquidLaneAdapter` + vault + `UniswapXExecutor` on Sepolia (we already run Sepolia/Hoodi deployments). +Alternative: an Anvil **mainnet fork** where canonical Permit2 + the real `V2DutchOrderReactor` already exist +(or deploy our own via UniswapX `DeployDutchV2.s.sol`, ctor `(IPermit2, protocolFeeOwner)`). Either way, drive +the whole +loop ourselves: build a V2 order as swapper → sign Permit2 witness → **self-cosign with our test key** +(per-order cosigner) → `UniswapXExecutor.execute(signedOrder, callbackData)` → assert swapper received +`tokenOut` and inventory moved. Exercises codec **parity** (our serialize/parse vs committed SDK fixtures), the +**cosignature golden** (vs the contract `ecrecover` digest §3.3), the settlement contract, and gas. Forge +integration test in the `rfq` repo. + +**Layer 3 — full local loop.** Mock RFQ server → our webhook → self-cosign → fork fill, end-to-end in one +harness. Closest to "real quote requests + validation" before Beta, entirely ours. + +**Layer 3.5 (optional) — permissionless mainnet soak.** Post-exclusivity open orders (and orders with a +nonzero `exclusivityOverrideBps`) are permissionlessly fillable, so once `UniswapXExecutor` is deployed we +can opportunistically fill small open orders whose `tokenIn` we redeem — real mainnet settlement, real gas +data, **zero onboarding dependency**. Does not count toward Beta qualification (which needs *exclusive* +fills), purely de-risking; skip if flow for our tokens is negligible. + +**Layer 4 — Uniswap Beta (mainnet, real funds; qualification gate only).** Register webhook URL + filler addr +(`UNISWAP_API_KEY`), drive orders with the private **`uniswapx-tool`** CLI (`UNISWAP_PRIVATE_KEY` for +`submit`), fill **5** within exclusivity (before `decayStartTime`), submit the tx hashes for **manual** +promotion. Do Layers 1–3 exhaustively first; use **minimum-size orders** in Beta to cap real-fund exposure to +≈ gas + tiny notional. + +**SDK helpers for self-driven orders** (`@uniswap/uniswapx-sdk`): `V2DutchOrderBuilder` → +`buildPartial()` (swapper sign) → `cosignatureHash()` (sign with our key) → `cosignerData()` / `cosignature()` +/ `build()`; or `CosignedV2DutchOrder.fromUnsignedOrder(...)`. `OrderQuoter` simulates `resolve()` on a fork. + +--- + +## 9. Build phases (code) + +Each phase is a reviewable increment; all are committed scope. + +- **P0 — Scaffold + codegen.** Vendor UniswapX V2 reactor + Permit2 ABIs → `api/bindings/uniswapx/`; vendor + `uniswapx-service/swagger.json` → generated poll client; scaffold the `uniswapx` package + `init()` register + + blank-import from `main`. CGO-free build holds. +- **P1 — Strategy layer promotion.** Promote the strategy layer (contract + registry + + `default`/`webhook` strategies) out of `internal/solvers/rfq/` into the shared package (§2.1) — a + pure package move, logic untouched, existing strategy tests carried along; `rfq` adopts the new + import path. Build the uniswapx candidate `chainreader` (settle the + merge-multicalls-vs-cached-snapshot hot-path decision, §2.1) and lock the `EXACT_OUTPUT` posture + (decline vs additive contract extension). +- **P2 — V2 order codec + cosignature** (the riskiest unit, front-loaded). `Serialize`/`Parse`, Permit2 witness + EIP-712, `CosignatureDigest`/`RecoverCosigner`. Golden tests + SDK-parity tests against committed fixtures. +- **P3 — `UniswapXExecutor.sol`** in the `rfq` repo + forge fork integration test (self-cosign loop). ABI + vendored here. +- **P4 — Quote webhook.** Huma server (`POST /quote` ≤500ms, `/health`, OpenAPI), independent per-request + pricing (probe indistinguishable, §4.1), `amountOut:"0"` decline, static-header auth; + redemption-minus-haircut pricing + gas floor; in-memory store. Unit-tested (pricing golden, httptest incl. + opposing probe, decline paths). +- **P5 — Ingestion + execution.** `OrderSource` (poll `GET /orders` ≤6 RPS behind the source seam, dedup + by `orderHash`); validation gates; build callbackData; `executeWithCallback` via txmanager; reconcile; + fade-aware breaker + `blockUntilTimestamp`. Unit-tested (state machine with fakes, poll dedup, gates). +- **P6 — Packaging + E2E.** Local mainnet-fork harness (Layers 1–3); config profiles; metrics; then Beta + 5-fill qualification (operational, §10). + +--- + +## 10. Operational / non-code TODO list (live) + +Tracked operational and onboarding steps — **update as items start/finish/drop** (CLAUDE.md plan-sync). + +### 10.1 Confirm with Uniswap (Henrique / Andrey) — blockers to going live +- [x] **Testnet:** RESOLVED — no testnet RFQ *server* (Uniswap quote/order API is mainnet/Beta only, per the + `uniswapx-tool` CLI), **but** the SDK ships a live **Sepolia Dutch V2 reactor** (`0x0e22B6…BEBcd`, §3.2) + usable for settlement testing (§8 Layer 2). TODO: confirm with Uniswap whether the Sepolia reactor is + maintained/safe to rely on, and whether any testnet RFQ server is planned. +- [ ] **Quote-webhook auth scheme** — exact header/secret (no signed scheme in source; FAQ publishes fixed + RFQ source IPs to whitelist: Beta `3.135.148.114`, Prod `3.138.88.28`). +- [x] **Decline status code:** RESOLVED — default `200`+`amountOut:"0"` (a 404 lands in axios' error path, + §4.1). Residual TODO: confirm whether the docs' `204` is also accepted in prod. +- [x] **Order delivery:** RESOLVED — order webhooks are **deprecated for new integrations** (Filler FAQ); + delivery is **poll-only, `GET /orders` at ≤6 RPS**. Residual TODO: confirm poll auth + exact + rate-limit enforcement at onboarding. +- [ ] **Quote-webhook registration** — how we register our quote URL, filler addr, `chainIds`, + exclusive-filler status (the `WebhookConfiguration` is S3/Uniswap-provisioned). +- [ ] **Live chain matrix** for RFQ quoting (V3 set as of 2026-06: Arbitrum, Avalanche, Base, BNB, Tempo, + Unichain, Robinhood Chain; Arc registered June 2026). +- [ ] **Real order flow for our assets** — is there meaningful RFQ flow for *our* vault collaterals on + mainnet? (Determines whether quoting is worth it before the secondary-DEX hop.) +- [ ] **Rate limits** (req/sec) — not published. +- [ ] **`uniswapx-tool` CLI access** (private GitHub) + any private **reference quoter server**. +- [ ] **Captured real Beta quote-request payloads / a recording** to replay in CI (makes Layer-1 testing + bit-for-bit faithful instead of schema-faithful). +- [ ] **Runtime OpenAPI** — pull `/v2/uniswapx/docs` spec with `UNISWAP_API_KEY`; re-vendor if richer than the + GitHub `swagger.json`. Confirm whether a spec exists for the parameterization-api (quote) surface. + +### 10.2 Onboarding +- [ ] Submit the quoter intake form: **https://developers.uniswap.org/quoter**. +- [ ] Generate `UNISWAP_API_KEY` at developers.uniswap.org; provision the keeper key (`UNISWAP_PRIVATE_KEY` + for CLI `submit` only — distinct from our on-chain keeper EOA). +- [ ] Get added to the private `Uniswap/uniswapx-tool` GitHub. +- [ ] Hand Uniswap our **quote-server URL** + **filler (UniswapXExecutor) address**. + +### 10.3 On-chain prerequisites +- [ ] Deploy `UniswapXExecutor.sol` (mainnet) + grant our keeper EOA the executor role. +- [ ] Set the executor's reactor address + adapter allowlist; fund the keeper EOA with ETH for gas. +- [ ] Confirm the `LiquidLaneAdapter`(s) we'll source from authorize our executor as filler + (`isFiller`/`marketMaker` — the adapter validates the swap *actor*, not the caller). +- [ ] Confirm each sourced adapter holds `ALLOCATE_ROLE` on its vault's `UniversalDelegator` (vault-funded + swaps revert without it) and watch for pending withdrawal-queue sweeps (they zero the vault-funded + part of `getMaxAssets`). + +### 10.4 Beta qualification (real funds, mainnet) +- [ ] Stand up the quote-webhook endpoint reachably (TLS, registered with Uniswap; source-IP allowlist per + §10.1) and point the poller at the Beta base URL. +- [ ] Drive **minimum-size** orders via `uniswapx-tool`; fill **5** within exclusivity (before + `decayStartTime`). +- [ ] Collect the **5 tx hashes**; email them to our Uniswap contact for **manual** promotion review. +- [ ] On promotion: flip from Beta base URL to production; widen order sizes per risk. + +### 10.5 Deferred (post-v1) +- [ ] V3 order codec + reactor target (Tempo + L2s) behind the same `OrderCodec` interface. +- [ ] Secondary-DEX sourcing in `reactorCallback` for pairs our vaults can't settle. +- [ ] Competitive/win-rate pricing controller (`exclusivityOverrideBps`, time-in-auction, competing fillers). +- [ ] Multi-output (`numOutputs > 1`) orders. +- [ ] Self-funding loops (keep keeper-gas / pay-bid pots fed from profit) if needed. + +--- + +## 11. Resources & references (collected) + +### Docs (developers.uniswap.org) +- Architecture — https://developers.uniswap.org/docs/liquidity/uniswapx/concepts/architecture +- Auction types (RFQ + Exclusive Dutch) — https://developers.uniswap.org/docs/liquidity/uniswapx/concepts/auction-types +- UniswapX RFQ flow — https://developers.uniswap.org/docs/liquidity/uniswapx/concepts/uniswaprfq +- Become a Quoter — https://developers.uniswap.org/contracts/uniswapx/fillers/mainnet/becomequoter +- Filling on Mainnet / Filler overview — https://developers.uniswap.org/contracts/uniswapx/fillers/filleroverview +- Deployments — https://developers.uniswap.org/contracts/uniswapx/deployments +- Quoter intake form — https://developers.uniswap.org/quoter +- RFQ-on-Base/Arbitrum changelog — https://developers.uniswap.org/docs/changelog/active-notifications/uniswapx-rfq-auctions-on-base-and-arbitrum + +### Repos +- Reactor/settlement contracts — https://github.com/Uniswap/UniswapX (deploy scripts `script/DeployDutchV2.s.sol`, + `DeployDutchV3.s.sol`, `DeployOrderQuoter.s.sol`; fork tests `test/integration/*.t.sol`) +- TypeScript SDK — https://github.com/Uniswap/sdks/tree/main/sdks/uniswapx-sdk (builders, order parse/serialize, + `permitData`, `resolve`, `cosignatureHash`). **Deployed-address source of truth:** + `sdks/uniswapx-sdk/src/constants.ts` `REACTOR_ADDRESS_MAPPING` (incl. testnet reactors the docs omit). +- UniswapX CLI (Beta driver, **public**) — https://github.com/Uniswap/uniswapx-tool (`src/config.ts`: + mainnet-only `ChainId`, `Env` Beta|Prod both → prod gateway; `src/approve.ts`: per-chain RPC list). +- **Quote webhook schema (Joi)** — https://github.com/Uniswap/uniswapx-parameterization-api + (`lib/handlers/quote/schema.ts`, `lib/entities/QuoteRequest.ts`, `lib/entities/QuoteResponse.ts`, + `lib/quoters/WebhookQuoter.ts`, `lib/constants.ts`, `lib/handlers/hard-quote/schema.ts`, + `lib/providers/webhook/index.ts`) +- **Order pool** — https://github.com/Uniswap/uniswapx-service (`lib/handlers/get-orders/schema/*`, + `lib/entities/Order.ts`; the `order-notification` handler still exists in-repo but the webhook program is + deprecated for new integrations — see the Filler FAQ) +- **Filler FAQ (webhook deprecation, 6 RPS poll limit, source IPs)** — + https://developers.uniswap.org/contracts/uniswapx/fillers/webhooks +- OpenAPI spec (poll surface) — https://raw.githubusercontent.com/Uniswap/uniswapx-service/main/swagger.json + (OpenAPI 3.0.0, base `https://api.uniswap.org/v2`, paths `/orders`, `/limit-orders`, `/nonce`) +- UniswapX CLI (Beta driver, **private**) — `Uniswap/uniswapx-tool` + +### Endpoints +- Production order API base — `https://api.uniswap.org/v2` (poll `GET /orders`); **gated (needs API key)**. +- Beta API base — `https://beta.api.uniswap.org/v2`; Beta docs (Swagger UI) — `.../v2/uniswapx/docs`; **gated**. + +### Internal (this monorepo) +- Sibling solver template — `vault-solver/internal/solvers/rfq/` + [`RFQ-PLAN.md`](RFQ-PLAN.md) +- Strategy architecture (solver-local `strategies/` registry (package `strategies`, `registry.go`) + `strategies/types` + `strategies/{default,webhook}`, shared + `internal/liquidlanemath` + `internal/webhook`) — [`strategy-plan.md`](strategy-plan.md) +- Framework conventions — [`../CLAUDE.md`](../CLAUDE.md) +- On-chain adapters/executor live in the sibling `rfq` repo (consumed via `api/bindings/`) + +### Beta program facts +- Gate: **5 valid exclusive fills** (before `decayStartTime`) → submit tx hashes → manual promotion. +- Env: `UNISWAP_API_KEY` (all Beta requests), `UNISWAP_PRIVATE_KEY` (CLI `submit`). +- Quote SLA: **≤500ms on all chains**; decline `200`+`amountOut:"0"` (docs mention `204`; never `404`, §4.1). +- Won orders: **poll-only, `GET /orders` at ≤6 RPS** (order webhooks deprecated for new integrations). +- Fade penalty: **15 min**, exponential for consecutive fades; `blockUntilTimestamp` surfaced. +- Tokens: **no allow-list** (there *is* a blocklist — unsupportedtokens.uniswap.org) — open + `tokenIn`/`tokenOut`; we **decline** (`amountOut:"0"`) anything we can't price. Our fillable universe = + pairs where `tokenIn` is a token-to-redeem on a whitelisted `LiquidLaneAdapter` **and** `tokenOut` is that + adapter's vault asset (native-ETH `tokenOut` maps to WETH, §7) — narrow by construction until the + secondary-DEX hop. diff --git a/docs/strategy-plan.md b/docs/strategy-plan.md new file mode 100644 index 00000000..98475e98 --- /dev/null +++ b/docs/strategy-plan.md @@ -0,0 +1,157 @@ +# vault-solver — Solver-Local Strategy Architecture + +The **strategy** is the decision-making core of a solver. This document records the *generic* strategy +boundary shared by every solver. Concrete per-solver contracts (the actual input/output types) live in +that solver's own plan under `docs/` and in its `strategies/types` package — never here. + +## Core principle + +A solver splits into two parts: + +- **The solver skeleton** — everything that faces the outside world: discovering work, reading + chain/API state, assembling a snapshot of **raw facts**, then signing and submitting whatever the + strategy decides. It holds the key and moves funds, but it makes no economic decision. +- **The strategy** — the brain. Given the solver's input snapshot, it decides what to do and returns a + concrete, ready-to-execute plan. + +The flow is one-directional and trusted: + +``` +solver builds input (raw facts) → strategy decides → solver executes the output verbatim +``` + +**The strategy is a trusted module, and it is the core of the solver.** The solver does not re-verify, +re-price, clamp, re-rank, or otherwise modify the strategy's output — it executes it as given. Any +validation, sizing bounds, eligibility filtering, replay, caching, or recovery that a decision needs +lives *inside* the strategy implementation, not in the solver skeleton. This keeps the boundary crisp: +swapping in a different strategy (including an external one) never requires the solver to grow — or +second-guess — decision logic. + +Concretely: + +- The solver provides **raw facts, not decisions** — available liquidity and caps, discovered work + items, current state, things it already holds. It never pre-computes a sizing, a ranking, or a + candidate selection for the strategy. +- The strategy returns a **complete plan the solver runs as-is**. The solver's only remaining + responsibility is execution integrity — values that are properties of the transaction rather than + the decision (nonce, signature, EIP-712 domain). Those the solver sets itself, and the strategy can + never supply them. + +## The contract + +Each solver defines its own decision interface — one method per decision point — in its own +`strategies/types` package. **There is no single cross-solver `Strategy` type: each solver's interface +is unique to its workflow** (a quote/fill solver's differs from an auction solver's, which differs from +a bidding solver's). What every solver shares is the *pattern*, not the signature — a solver-built +input of raw facts in, a strategy-decided output out. + +For direction, the 3F solver's interface looks like this: + +```go +// package types — internal/solvers/bridgefacilitator/strategies/types +type Strategy interface { + DecideOffers(ctx, OfferInput) (OfferOutput, error) +} +``` + +where `OfferInput` carries only raw facts (adapter liquidity/caps, open auctions, offers already held) +and `OfferOutput` is the list of offers for the solver to sign and submit. A quote/fill solver instead +exposes a quote decision and a fill decision; a bidding solver a single bid decision. The concrete +types are documented in each solver's plan (`docs/3F-PLAN.md`, `docs/RFQ-PLAN.md`, …) and defined in +its `strategies/types` package — this document intentionally does not restate them. + +## Selection and configuration + +Strategy selection is solver-local: the generic framework does not parse, validate, or route strategy +configs. It owns only solver lifecycle. + +```yaml +solvers: + - name: + config: + strategy: + name: default # solver-local strategy name (omit ⇒ default) + config: {} # opaque to the framework and the solver skeleton +``` + +`solvers[].config` is opaque to the framework; each solver decides whether it supports a `strategy` +field and which names exist. Inside a solver: + +```go +type StrategySpec struct { + Name string + Config yaml.Node +} + +type StrategyFactory func(raw yaml.Node, deps StrategyDeps) (types.Strategy, error) +``` + +Each solver keeps a local registry/factory. A strategy self-registers from its own package `init()` +under a solver-local unique name; the solver-level factory only routes by `name`, and the selected +strategy owns parsing and validating its own `config` node. + +## Built-in strategies + +Two strategy kinds are conventional across solvers: + +- **`default`** — the in-process strategy that ships with the solver. It is the reference decision + logic and needs no external service. It may validate its own output as thoroughly as it likes; that + is internal to the strategy. +- **`webhook`** — delegates the decision to an external HTTP service. It POSTs the solver's input + snapshot as JSON and hands back the plan the service returns. The external decider is the brain; the + in-process handler is transport-only and adds no decision logic and no second-guessing of its own. + This is the seam for running custom decision logic out-of-process without forking the solver. + +Both plug into the same trusted boundary: the solver executes their output the same way, so a solver +is never coupled to which strategy is loaded. + +## Adding your own strategy + +Strategies are pluggable per solver, and there are two ways to add one. + +**Out-of-tree, no Go changes — run a `webhook`.** Point the solver's `strategy` at your own HTTP +service (see below). It receives the solver's raw-facts input as JSON and returns the plan to execute; +the solver runs it verbatim. This is the fastest path and keeps your decision logic in your own +codebase and language. + +**In-tree — register a new strategy on the solver.** To ship a strategy alongside a solver, implement +that solver's interface (each is unique — you implement the one the target solver defines): + +1. Create a package under the solver's `strategies//` and implement the solver's strategy + interface (e.g. `DecideOffers` for 3F), consuming its `strategies/types` input and returning its + output type. +2. Add a `NewFromConfig(raw yaml.Node, deps) (types.Strategy, error)` constructor that parses your own + `strategy.config` node — the framework hands it to you opaque, so you own its schema and validation. +3. Self-register from your package `init()` via the solver's local + `strategies.Register("", NewFromConfig)`, under a name unique within that solver. +4. Ensure the package is imported so its `init()` runs (blank-import it where the solver wires its + strategies). +5. Select it in config: `strategy: { name: , config: { … } }`. + +Either way the solver skeleton is untouched: it provides the same input and executes whatever plan your +strategy returns — so the correctness of the decision is entirely yours to own. + +## Shared transport: `internal/webhook` + +The only shared strategy-adjacent package is `internal/webhook`, a generic HTTP JSON client: + +- HTTP JSON `POST`, configurable timeout, request/response body byte caps (default 1 MiB each) +- literal or env-backed headers (secrets by env-var name, never inlined) +- strict response decode; non-2xx and empty-body responses are errors + +It has no solver names, no strategy registry, and no per-solver DTOs — each solver's webhook strategy +owns its own wire types (conventionally lower-camel JSON with decimal strings for big integers, +provided by that solver's `strategies/types`). + +```yaml +strategy: + name: webhook + config: + url: https://strategy.example.com/decide + timeout: 500ms + maxRequestBytes: 1048576 + maxResponseBytes: 1048576 + headers: + authorization: + env: STRATEGY_AUTH_HEADER +``` diff --git a/go.mod b/go.mod index 42f5c934..10ab27c9 100644 --- a/go.mod +++ b/go.mod @@ -5,16 +5,18 @@ go 1.26 toolchain go1.26.4 require ( + github.com/Khan/genqlient v0.8.1 github.com/danielgtaylor/huma/v2 v2.38.0 - github.com/ethereum/go-ethereum v1.17.3 - github.com/getsentry/sentry-go v0.46.2 + github.com/ethereum/go-ethereum v1.17.4 + github.com/getsentry/sentry-go v0.47.0 github.com/go-errors/errors v1.5.1 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 - github.com/prometheus/client_golang v1.15.0 + github.com/gorilla/websocket v1.5.3 + github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 - go.uber.org/zap v1.27.0 - golang.org/x/sync v0.20.0 + go.uber.org/zap v1.28.0 + golang.org/x/sync v0.21.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -27,36 +29,37 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/consensys/gnark-crypto v0.18.1 // indirect github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/deckarep/golang-set/v2 v2.6.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect + github.com/fjl/jsonw v0.1.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.4.2 // indirect github.com/holiman/uint256 v1.3.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect - github.com/prometheus/client_model v0.3.0 // indirect - github.com/prometheus/common v0.42.0 // indirect - github.com/prometheus/procfs v0.9.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/supranational/blst v0.3.16 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/vektah/gqlparser/v2 v2.5.19 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/crypto v0.50.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 9517c2c9..99fd6dd9 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs= +github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= @@ -8,6 +10,8 @@ github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDO github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= @@ -38,8 +42,8 @@ github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= github.com/danielgtaylor/huma/v2 v2.38.0 h1:fb0WZCatnaiHLphMQDDWDjygNxfMkX/ENma3QsRl7vY= github.com/danielgtaylor/huma/v2 v2.38.0/go.mod h1:k9hwjlgWFt1t2jsmQGlsgXAG2FBTZa4kkjV581qAtfo= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= @@ -56,18 +60,20 @@ github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn2 github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= -github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q= -github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A= +github.com/ethereum/go-ethereum v1.17.4 h1:uA4q+qiLp7QImBsjdRbINu8iX6OEVmj4DPc5/E5Fsxc= +github.com/ethereum/go-ethereum v1.17.4/go.mod h1:qMdgwqqRAen+aT8P7KKQKi0Qt6RzG4cfejVAbCpJgqA= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/fjl/jsonw v0.1.0 h1:V3MyR79fjLpn/+bMgvegdGUIhoJOzjmqWcKDgcOmY1I= +github.com/fjl/jsonw v0.1.0/go.mod h1:2KMLevM6FXEJnfhtk7naXu9vZdVfOma1GlnGdPRlumU= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= -github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns= -github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= +github.com/getsentry/sentry-go v0.47.0 h1:AnSMSyrYA5qZCIN/2xpgAAwv63sVULV+vBq37ajouc8= +github.com/getsentry/sentry-go v0.47.0/go.mod h1:h+b4VHpKnK7aUXB5wc+KDnPgp9ZtfliRD4eV85FbiSA= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -86,10 +92,6 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -98,8 +100,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= @@ -144,48 +146,48 @@ github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLG github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7 h1:oYW+YCJ1pachXTQmzR3rNLYGGz4g/UgFcjb28p/viDM= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= -github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= -github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= -github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= -github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= -github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= -github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= -github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= -github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= -github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= -github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= +github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY= +github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM= -github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= -github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= -github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= -github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= -github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= -github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= -github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -205,6 +207,10 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/vektah/gqlparser/v2 v2.5.19 h1:bhCPCX1D4WWzCDvkPl4+TP1N8/kLrWnp43egplt7iSg= +github.com/vektah/gqlparser/v2 v2.5.19/go.mod h1:y7kvl5bBlDeuWIvLtA9849ncyvx6/lj06RsMrEjVy3U= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= @@ -215,35 +221,37 @@ go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= +go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/internal/chain/bigmath.go b/internal/chain/bigmath.go new file mode 100644 index 00000000..4660d711 --- /dev/null +++ b/internal/chain/bigmath.go @@ -0,0 +1,11 @@ +package chain + +import "math/big" + +// Exp10 returns 10^n as a *big.Int — the canonical power-of-ten / token-decimals scale used across the +// solvers for fixed-point conversions (e.g. scaling between tokens of different decimals). n must be +// >= 0; negative exponents are not meaningful for decimal scales and yield 1 (big.Int.Exp on a negative +// exponent with a nil modulus returns 1). +func Exp10(n int) *big.Int { + return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(n)), nil) +} diff --git a/internal/chain/chain.go b/internal/chain/chain.go index 22399d01..4d481968 100644 --- a/internal/chain/chain.go +++ b/internal/chain/chain.go @@ -11,27 +11,39 @@ import ( "github.com/go-errors/errors" "github.com/go-logr/logr" - "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" "github.com/symbioticfi/vault-solver/api/bindings/multicall3" ) +// multicallB is the stateless v2 aggregate3 pack/unpack binding (no backend). +var multicallB = multicall3.NewMulticall3() + // Client is an ethclient.Client plus the chain id and the Multicall3 address, cached at dial time. +// When a separate write RPC is configured, writeClient carries transaction broadcasts only; every +// read stays on the embedded (primary) client. writeClient equals the embedded client when no +// separate write endpoint is configured. type Client struct { *ethclient.Client - chainID *big.Int - multicall common.Address + writeClient *ethclient.Client + chainID *big.Int + multicall common.Address } // Dial connects to the EVM RPC endpoint(s), records the chain id, and pins the Multicall3 address // used for batched reads. rpcURLs[0] is the primary; any extra entries are HTTP(S) fallbacks tried in // order when the primary is unavailable (see fallbackTransport). A single URL preserves the plain // ethclient dial (any scheme), so non-HTTP transports keep working when no fallback is configured. -func Dial(ctx context.Context, rpcURLs []string, multicallAddr string, log logr.Logger) (*Client, error) { +// +// writeRPCURL, when non-empty, is dialed as a SEPARATE client used only to broadcast transactions +// (see SendTransaction); every read stays on the primary. Empty reuses the primary for broadcasts, +// so behaviour is unchanged. +func Dial(ctx context.Context, rpcURLs []string, writeRPCURL, multicallAddr string, log logr.Logger) (*Client, error) { if len(rpcURLs) == 0 { return nil, errors.New("chain: no rpc url configured") } @@ -48,7 +60,37 @@ func Dial(ctx context.Context, rpcURLs []string, multicallAddr string, log logr. ec.Close() return nil, errors.Errorf("chain: get chain id: %w", err) } - return &Client{Client: ec, chainID: id, multicall: common.HexToAddress(multicallAddr)}, nil + + // A distinct write endpoint (e.g. a private/MEV-protected relay) carries only transaction + // broadcasts; reads stay on the primary. Empty reuses the primary so behaviour is unchanged. + writeClient := ec + if writeRPCURL != "" { + wc, wcErr := dialClient(ctx, []string{writeRPCURL}, log) + if wcErr != nil { + ec.Close() + return nil, errors.Errorf("chain: dial write rpc: %w", wcErr) + } + writeClient = wc + } + + return &Client{Client: ec, writeClient: writeClient, chainID: id, multicall: common.HexToAddress(multicallAddr)}, nil +} + +// SendTransaction broadcasts a signed transaction through the write client. When a separate +// writeRpcUrl is configured this is the ONLY call routed there — nonce, gas, fee, receipt and +// block-number reads all stay on the primary client — so fills can be submitted through a private +// endpoint while state is read from a normal RPC. It overrides the promoted ethclient method. +func (c *Client) SendTransaction(ctx context.Context, tx *types.Transaction) error { + return c.writeClient.SendTransaction(ctx, tx) +} + +// Close closes the primary client and, when a separate write client was dialed, that one too. It +// overrides the promoted ethclient method so the write client is not leaked. +func (c *Client) Close() { + c.Client.Close() + if c.writeClient != nil && c.writeClient != c.Client { + c.writeClient.Close() + } } // dialClient builds the ethclient. A single non-HTTP endpoint (ws/ipc) keeps a plain dial, since the @@ -92,20 +134,21 @@ type CallResult struct { ReturnData []byte } -// Multicall batches reads through Multicall3.aggregate3, collapsing N eth_calls into one round-trip. +// Multicall batches reads through Multicall3.aggregate3 at the latest block. func (c *Client) Multicall(ctx context.Context, calls []Call) ([]CallResult, error) { - caller, err := multicall3.NewMulticall3Caller(c.multicall, c.Client) - if err != nil { - return nil, errors.Errorf("chain: bind multicall3 %s: %w", c.multicall, err) - } in := make([]multicall3.Multicall3Call3, len(calls)) for i, call := range calls { in[i] = multicall3.Multicall3Call3{Target: call.Target, AllowFailure: call.AllowFailure, CallData: call.Data} } - out, err := caller.Aggregate3(&bind.CallOpts{Context: ctx}, in) + data := multicallB.PackAggregate3(in) + ret, err := c.CallContract(ctx, ethereum.CallMsg{To: &c.multicall, Data: data}, nil) if err != nil { return nil, errors.Errorf("chain: multicall aggregate3: %w", err) } + out, err := multicallB.UnpackAggregate3(ret) + if err != nil { + return nil, errors.Errorf("chain: multicall unpack aggregate3: %w", err) + } res := make([]CallResult, len(out)) for i, o := range out { res[i] = CallResult{Success: o.Success, ReturnData: o.ReturnData} diff --git a/internal/chain/decimals.go b/internal/chain/decimals.go new file mode 100644 index 00000000..1bec27b9 --- /dev/null +++ b/internal/chain/decimals.go @@ -0,0 +1,110 @@ +package chain + +import ( + "context" + "slices" + "sync" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/api/bindings/erc20" +) + +// erc20B is the generated minimal ERC-20 binding (decimals() only) this cache packs/unpacks through. +var erc20B = erc20.NewERC20() + +// Decimals is a concurrency-safe, multicall-backed cache of ERC-20 token decimals. Token decimals are +// immutable, so a value is read once and memoized. Solvers serve quotes/refreshes concurrently, so the +// cache is mutex-guarded. It is a generic cross-solver primitive (both the RFQ and OEV readers need +// it), so it lives in the chain layer next to Multicall. +type Decimals struct { + chain *Client + mu sync.Mutex + cache map[common.Address]int +} + +// NewDecimals builds a decimals cache over the given client. +func NewDecimals(c *Client) *Decimals { + return &Decimals{chain: c, cache: make(map[common.Address]int)} +} + +// Get returns token's decimals, reading (and caching) it on a miss. +func (d *Decimals) Get(ctx context.Context, token common.Address) (int, error) { + d.mu.Lock() + if v, ok := d.cache[token]; ok { + d.mu.Unlock() + return v, nil + } + d.mu.Unlock() + + res, err := d.chain.Multicall(ctx, []Call{{Target: token, Data: erc20B.PackDecimals()}}) + if err != nil { + return 0, err + } + if len(res) != 1 || !res[0].Success { + return 0, errors.Errorf("erc20.decimals() reverted for %s", token) + } + v, err := decodeDecimals(res[0].ReturnData) + if err != nil { + return 0, err + } + d.store(token, v) + return v, nil +} + +// GetMany returns decimals for several tokens, reading every uncached one in a SINGLE multicall (so +// resolving N new tokens costs one round-trip, not N). Cached tokens are served without any call. It +// is lenient per token: a token whose decimals() reverts is simply omitted from the result (the caller +// fails that item closed) rather than failing the whole batch — only a transport error is returned. +func (d *Decimals) GetMany(ctx context.Context, tokens []common.Address) (map[common.Address]int, error) { + out := make(map[common.Address]int, len(tokens)) + var miss []common.Address + d.mu.Lock() + for _, t := range tokens { + if v, ok := d.cache[t]; ok { + out[t] = v + } else if !slices.Contains(miss, t) { + miss = append(miss, t) + } + } + d.mu.Unlock() + if len(miss) == 0 { + return out, nil + } + + calls := make([]Call, len(miss)) + for i, t := range miss { + calls[i] = Call{Target: t, AllowFailure: true, Data: erc20B.PackDecimals()} + } + res, err := d.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + for i, t := range miss { + if i >= len(res) || !res[i].Success { + continue // token's decimals() reverted — omit; caller skips that market (fail closed) + } + v, derr := decodeDecimals(res[i].ReturnData) + if derr != nil { + continue + } + d.store(t, v) + out[t] = v + } + return out, nil +} + +func (d *Decimals) store(token common.Address, v int) { + d.mu.Lock() + d.cache[token] = v + d.mu.Unlock() +} + +func decodeDecimals(data []byte) (int, error) { + v, err := erc20B.UnpackDecimals(data) + if err != nil { + return 0, errors.Errorf("unpack decimals: %w", err) + } + return int(v), nil +} diff --git a/internal/chain/fallback_test.go b/internal/chain/fallback_test.go index 427b9db4..40872930 100644 --- a/internal/chain/fallback_test.go +++ b/internal/chain/fallback_test.go @@ -3,12 +3,15 @@ package chain import ( "encoding/json" "io" + "math/big" "net/http" "net/http/httptest" "net/url" + "slices" "strings" "testing" + "github.com/ethereum/go-ethereum/core/types" "github.com/go-logr/logr" ) @@ -158,7 +161,7 @@ func TestDial_SingleHTTPEndpointServesChainID(t *testing.T) { defer srv.Close() const multicall = "0xcA11bde05977b3631167028862bE2a173976CA11" - c, err := Dial(t.Context(), []string{srv.URL}, multicall, logr.Discard()) + c, err := Dial(t.Context(), []string{srv.URL}, "", multicall, logr.Discard()) if err != nil { t.Fatalf("Dial single http endpoint: %v", err) } @@ -187,7 +190,7 @@ func TestDial_FallbackServesChainID(t *testing.T) { defer fallback.Close() const multicall = "0xcA11bde05977b3631167028862bE2a173976CA11" - c, err := Dial(t.Context(), []string{primary.URL, fallback.URL}, multicall, logr.Discard()) + c, err := Dial(t.Context(), []string{primary.URL, fallback.URL}, "", multicall, logr.Discard()) if err != nil { t.Fatalf("Dial via fallback: %v", err) } @@ -196,3 +199,101 @@ func TestDial_FallbackServesChainID(t *testing.T) { t.Fatalf("chainID = %d, want 31337 (served by fallback)", got) } } + +// rpcRecorder is a JSON-RPC httptest server that records the methods it is asked and replies with a +// canned result per method. +func rpcRecorder(methods *[]string, result func(method string) string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &req) + *methods = append(*methods, req.Method) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":` + string(req.ID) + `,"result":` + result(req.Method) + `}`)) + })) +} + +// TestDial_WriteRPCRoutesOnlyBroadcasts confirms a separate writeRpcUrl carries ONLY the transaction +// broadcast (eth_sendRawTransaction); chain id, block number, and every other read stay on the +// primary endpoint. This is the mevblocker-style split: submit fills privately, read from a normal RPC. +func TestDial_WriteRPCRoutesOnlyBroadcasts(t *testing.T) { + var readMethods, writeMethods []string + read := rpcRecorder(&readMethods, func(m string) string { + if m == "eth_chainId" { + return `"0x7a69"` // 31337 + } + return `"0x1"` + }) + defer read.Close() + write := rpcRecorder(&writeMethods, func(string) string { + return `"0x0000000000000000000000000000000000000000000000000000000000000001"` + }) + defer write.Close() + + const multicall = "0xcA11bde05977b3631167028862bE2a173976CA11" + c, err := Dial(t.Context(), []string{read.URL}, write.URL, multicall, logr.Discard()) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer c.Close() + + // A read hits the primary endpoint. + if _, err := c.BlockNumber(t.Context()); err != nil { + t.Fatalf("BlockNumber: %v", err) + } + // A broadcast hits the write endpoint only. + tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: big.NewInt(31337), + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(1), + Gas: 21000, + Value: big.NewInt(0), + }) + if err := c.SendTransaction(t.Context(), tx); err != nil { + t.Fatalf("SendTransaction: %v", err) + } + + if !slices.Contains(writeMethods, "eth_sendRawTransaction") { + t.Fatalf("write endpoint did not receive the broadcast, saw: %v", writeMethods) + } + if slices.Contains(writeMethods, "eth_chainId") || slices.Contains(writeMethods, "eth_blockNumber") { + t.Fatalf("reads leaked onto the write endpoint: %v", writeMethods) + } + if slices.Contains(readMethods, "eth_sendRawTransaction") { + t.Fatalf("broadcast leaked onto the read endpoint: %v", readMethods) + } + if !slices.Contains(readMethods, "eth_blockNumber") { + t.Fatalf("read endpoint did not receive the read, saw: %v", readMethods) + } +} + +// TestDial_NoWriteRPCReusesPrimary confirms that with no writeRpcUrl, broadcasts fall back to the +// primary endpoint (unchanged behaviour). +func TestDial_NoWriteRPCReusesPrimary(t *testing.T) { + var methods []string + srv := rpcRecorder(&methods, func(m string) string { + if m == "eth_chainId" { + return `"0x7a69"` + } + return `"0x0000000000000000000000000000000000000000000000000000000000000001"` + }) + defer srv.Close() + + const multicall = "0xcA11bde05977b3631167028862bE2a173976CA11" + c, err := Dial(t.Context(), []string{srv.URL}, "", multicall, logr.Discard()) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer c.Close() + + tx := types.NewTx(&types.DynamicFeeTx{ChainID: big.NewInt(31337), GasTipCap: big.NewInt(1), GasFeeCap: big.NewInt(1), Gas: 21000, Value: big.NewInt(0)}) + if err := c.SendTransaction(t.Context(), tx); err != nil { + t.Fatalf("SendTransaction: %v", err) + } + if !slices.Contains(methods, "eth_sendRawTransaction") { + t.Fatalf("primary endpoint did not receive the broadcast, saw: %v", methods) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 8a0fa276..998b2c9f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,7 +40,13 @@ type ChainConfig struct { // RPCFallbackURLs are additional HTTP(S) RPC endpoints tried, in order, when the primary `rpcUrl` // is unavailable. All must be on the same chain. Optional; empty means no fallback. RPCFallbackURLs []string `yaml:"rpcFallbackUrls,omitempty"` - ChainID uint64 `yaml:"chainId"` + // WriteRPCURL, when set, is used ONLY to broadcast signed transactions (eth_sendRawTransaction). + // Every read — nonce, gas, fee, receipts, block number — stays on `rpcUrl`. Point this at a + // private/MEV-protected endpoint (e.g. mevblocker) to submit fills privately while reading from a + // normal RPC. Optional; empty means broadcasts also use `rpcUrl`. Expand from the environment + // with ${WRITE_RPC_URL}. + WriteRPCURL string `yaml:"writeRpcUrl,omitempty"` + ChainID uint64 `yaml:"chainId"` // WSURL is optional; when set it enables live log subscriptions (a latency optimization only). WSURL string `yaml:"wsUrl,omitempty"` // MulticallAddress overrides the Multicall3 contract used to batch reads. Defaults to the @@ -92,7 +98,7 @@ func Load(path string) (*Config, error) { // Expand ${VAR}/$VAR from the environment so non-secret, deploy-injected fields (e.g. rpcUrl) // can come from the environment. Secrets must NOT use this: they belong in the *Env name fields - // (keyEnv, passphraseEnv, apiKeyEnv), which os.Getenv at point of use and never place the secret + // (keyEnv, passphraseEnv, backendSharedSecretEnv, …), which os.Getenv at point of use and never place the secret // into this Config struct (so dumping/logging the config can't leak it). An undefined var // expands to "", which surfaces via Validate for required fields. raw = []byte(os.ExpandEnv(string(raw))) @@ -140,7 +146,7 @@ func (c *Config) Validate() error { return err } if len(c.Solvers) == 0 { - return errors.New("at least one solver is required (set `solver` or `solvers`)") + return errors.New("at least one solver is required (set `solvers`)") } seen := make(map[string]bool, len(c.Solvers)) for i, s := range c.Solvers { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6f3c08d8..f49723fe 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -127,6 +127,52 @@ solvers: } } +func TestLoad_ExpandsWriteRpcUrl(t *testing.T) { + t.Setenv("WRITE_RPC_URL", "https://write.from.env") + body := ` +chain: + rpcUrl: https://read.example + writeRpcUrl: ${WRITE_RPC_URL} + chainId: 1 +signer: + keyEnv: SOLVER_PRIVATE_KEY +solvers: + - name: x + config: {} +` + cfg, err := Load(writeTemp(t, body)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Chain.WriteRPCURL != "https://write.from.env" { + t.Fatalf("writeRpcUrl not expanded from env: %q", cfg.Chain.WriteRPCURL) + } + // The read RPC is untouched — writeRpcUrl only affects broadcasts. + if cfg.Chain.RPCURL != "https://read.example" { + t.Fatalf("rpcUrl changed unexpectedly: %q", cfg.Chain.RPCURL) + } +} + +func TestLoad_WriteRpcUrlOptional(t *testing.T) { + body := ` +chain: + rpcUrl: https://read.example + chainId: 1 +signer: + keyEnv: SOLVER_PRIVATE_KEY +solvers: + - name: x + config: {} +` + cfg, err := Load(writeTemp(t, body)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Chain.WriteRPCURL != "" { + t.Fatalf("writeRpcUrl should default to empty, got %q", cfg.Chain.WriteRPCURL) + } +} + func TestLoad_ExpandsEnvInSolverConfigBlock(t *testing.T) { // Expansion runs on the raw bytes before decode, so it reaches the opaque solver.config block // (the deferred two-stage decode) too — not just the framework-level fields. diff --git a/internal/liquidlanemath/math.go b/internal/liquidlanemath/math.go new file mode 100644 index 00000000..321b5002 --- /dev/null +++ b/internal/liquidlanemath/math.go @@ -0,0 +1,51 @@ +// Package liquidlanemath contains LiquidLane fixed-point rate calculations. +package liquidlanemath + +import "math/big" + +var rateScale = new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil) + +func pow10(n int) *big.Int { + return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(n)), nil) +} + +func AmountOutForRate(amountIn, rate *big.Int, tokenInDec, assetDec int) *big.Int { + num := new(big.Int).Mul(amountIn, rate) + num.Mul(num, pow10(assetDec)) + den := new(big.Int).Mul(rateScale, pow10(tokenInDec)) + if den.Sign() == 0 { + return new(big.Int) + } + return num.Div(num, den) +} + +func MaxAmountInForRate(maxAssets, rate *big.Int, tokenInDec, assetDec int) *big.Int { + den := new(big.Int).Mul(rate, pow10(assetDec)) + if den.Sign() == 0 { + return new(big.Int) + } + num := new(big.Int).Mul(maxAssets, rateScale) + num.Mul(num, pow10(tokenInDec)) + return num.Div(num, den) +} + +func MinAmountInForAmountOut(amountOut, rate *big.Int, tokenInDec, assetDec int) *big.Int { + den := new(big.Int).Mul(rate, pow10(assetDec)) + if den.Sign() == 0 { + return new(big.Int) + } + num := new(big.Int).Mul(amountOut, rateScale) + num.Mul(num, pow10(tokenInDec)) + num.Add(num, new(big.Int).Sub(den, big.NewInt(1))) + return num.Div(num, den) +} + +func RateForAmountOut(amountOut, amountIn *big.Int, tokenInDec, assetDec int) *big.Int { + if amountIn.Sign() == 0 { + return new(big.Int) + } + num := new(big.Int).Mul(amountOut, rateScale) + num.Mul(num, pow10(tokenInDec)) + den := new(big.Int).Mul(amountIn, pow10(assetDec)) + return num.Div(num, den) +} diff --git a/internal/liquidlanemath/math_test.go b/internal/liquidlanemath/math_test.go new file mode 100644 index 00000000..88273d76 --- /dev/null +++ b/internal/liquidlanemath/math_test.go @@ -0,0 +1,63 @@ +package liquidlanemath + +import ( + "math/big" + "testing" +) + +func mustBig(t *testing.T, s string) *big.Int { + t.Helper() + n, ok := new(big.Int).SetString(s, 10) + if !ok { + t.Fatalf("bad big.Int %q", s) + } + return n +} + +func TestAmountOutForRate(t *testing.T) { + got := AmountOutForRate( + mustBig(t, "1000000000000000000"), + mustBig(t, "1000000000000000000"), + 18, + 6, + ) + if got.String() != "1000000" { + t.Fatalf("AmountOutForRate = %s, want 1000000", got) + } +} + +func TestMaxAmountInForRate(t *testing.T) { + got := MaxAmountInForRate( + mustBig(t, "1000000"), + mustBig(t, "1000000000000000000"), + 18, + 6, + ) + if got.String() != "1000000000000000000" { + t.Fatalf("MaxAmountInForRate = %s, want 1000000000000000000", got) + } +} + +func TestMinAmountInForAmountOutRoundsUp(t *testing.T) { + got := MinAmountInForAmountOut( + mustBig(t, "1"), + mustBig(t, "3000000000000000000"), + 18, + 6, + ) + if got.String() != "333333333334" { + t.Fatalf("MinAmountInForAmountOut = %s, want 333333333334", got) + } +} + +func TestRateForAmountOut(t *testing.T) { + got := RateForAmountOut( + mustBig(t, "1000000"), + mustBig(t, "1000000000000000000"), + 18, + 6, + ) + if got.String() != "1000000000000000000" { + t.Fatalf("RateForAmountOut = %s, want 1000000000000000000", got) + } +} diff --git a/internal/morpho/math.go b/internal/morpho/math.go new file mode 100644 index 00000000..e78b6c91 --- /dev/null +++ b/internal/morpho/math.go @@ -0,0 +1,302 @@ +package morpho + +import ( + "math/big" + + "github.com/symbioticfi/vault-solver/internal/chain" +) + +// Morpho Blue math, ported verbatim from morpho-org/morpho-blue (see docs/OEV-PLAN.md §6.4). This is +// the SINGLE source of truth for health/sizing over a worker-derived candidate set. All arithmetic is +// big.Int with the exact rounding directions Morpho uses on-chain; an off-by-one here means reverted or +// unprofitable fills. Lives in internal/morpho so any solver can reuse it. + +// WAD-scaled constants (1e18 fixed point) and Morpho library constants. +var ( + one = big.NewInt(1) // reused divisor adjustment in MulDivUp (avoids a per-call alloc) + Wad = big.NewInt(1e18) + twoWad = big.NewInt(2e18) // 2·WAD — Taylor-series denominators, hoisted out of the hot path + threeWad = big.NewInt(3e18) // 3·WAD + oraclePriceScale = chain.Exp10(36) // ORACLE_PRICE_SCALE = 1e36 + virtualShares = big.NewInt(1e6) // SharesMathLib.VIRTUAL_SHARES + virtualAssets = big.NewInt(1) // SharesMathLib.VIRTUAL_ASSETS + liquidationCursor = big.NewInt(0.3e18) // ConstantsLib.LIQUIDATION_CURSOR (β) + maxLiqIncentive = big.NewInt(1.15e18) // ConstantsLib.MAX_LIQUIDATION_INCENTIVE_FACTOR (M) +) + +// MarketState is the on-chain market accounting (Morpho `market(id)`), plus the IRM rate needed to +// accrue interest locally. Amounts are big.Int (uint128 on-chain). +type MarketState struct { + TotalSupplyAssets *big.Int + TotalSupplyShares *big.Int + TotalBorrowAssets *big.Int + TotalBorrowShares *big.Int + LastUpdate uint64 + Fee *big.Int + Lltv *big.Int // from idToMarketParams; the market's liquidation LTV (wad) + BorrowRatePerSec *big.Int // IRM borrowRateView (wad/sec); zero ⇒ no accrual (irm == 0) +} + +// PositionState is a borrower's position (Morpho `position(id, borrower)`). +type PositionState struct { + BorrowShares *big.Int + Collateral *big.Int +} + +// LiquidationReplay is the local post-state of Morpho's seize-driven liquidate branch. +type LiquidationReplay struct { + Market MarketState + Position PositionState + RepaidAssets *big.Int + RepaidShares *big.Int + BadDebtAssets *big.Int + BadDebtShares *big.Int +} + +// AccruedTotalBorrowAssets returns totalBorrowAssets grown to `nowTs` using the Taylor-compounded +// borrow rate — the off-chain replica of Morpho `_accrueInterest` (borrow shares are unchanged by +// accrual; only the assets side grows). Returns the original value when elapsed is 0 or the rate is 0. +func AccruedTotalBorrowAssets(m MarketState, nowTs uint64) *big.Int { + tba := new(big.Int).Set(m.TotalBorrowAssets) + if m.BorrowRatePerSec == nil || m.BorrowRatePerSec.Sign() == 0 || nowTs <= m.LastUpdate { + return tba + } + elapsed := new(big.Int).SetUint64(nowTs - m.LastUpdate) + growth := WTaylorCompounded(m.BorrowRatePerSec, elapsed) + interest := WMulDown(tba, growth) + return tba.Add(tba, interest) +} + +// AccruedMarketState returns Morpho's market accounting after `_accrueInterest`, including the supply side +// needed for bad-debt replay. Borrow shares never change on accrual. +func AccruedMarketState(m MarketState, nowTs uint64) MarketState { + out := cloneMarketState(m) + if out.BorrowRatePerSec == nil || out.BorrowRatePerSec.Sign() == 0 || nowTs <= out.LastUpdate { + return out + } + elapsed := new(big.Int).SetUint64(nowTs - out.LastUpdate) + growth := WTaylorCompounded(out.BorrowRatePerSec, elapsed) + interest := WMulDown(out.TotalBorrowAssets, growth) + out.TotalBorrowAssets.Add(out.TotalBorrowAssets, interest) + out.TotalSupplyAssets.Add(out.TotalSupplyAssets, interest) + if out.Fee != nil && out.Fee.Sign() != 0 { + feeAmount := WMulDown(interest, out.Fee) + supplyExFee := new(big.Int).Sub(out.TotalSupplyAssets, feeAmount) + feeShares := ToSharesDown(feeAmount, supplyExFee, out.TotalSupplyShares) + out.TotalSupplyShares.Add(out.TotalSupplyShares, feeShares) + } + out.LastUpdate = nowTs + return out +} + +// BorrowedAssetsAt is BorrowedAssets given a pre-accrued total — so the hot path can accrue once per +// candidate and reuse it across the health check and sizing instead of recomputing the Taylor series. +func BorrowedAssetsAt(p PositionState, accruedTotal, totalShares *big.Int) *big.Int { + if p.BorrowShares == nil || p.BorrowShares.Sign() == 0 { + return big.NewInt(0) + } + return ToAssetsUp(p.BorrowShares, accruedTotal, totalShares) +} + +// MaxBorrow returns the largest debt the position may carry at `collateralPrice` (1e36-scaled), +// rounding down in the protocol's favor: collateral.mulDivDown(price, 1e36).wMulDown(lltv). +func MaxBorrow(collateral, collateralPrice, lltv *big.Int) *big.Int { + return WMulDown(MulDivDown(collateral, collateralPrice, oraclePriceScale), lltv) +} + +// IsLiquidatableAt is IsLiquidatable given a pre-accrued total (hot-path variant). +func IsLiquidatableAt(p PositionState, collateralPrice, lltv, accruedTotal, totalShares *big.Int) bool { + borrowed := BorrowedAssetsAt(p, accruedTotal, totalShares) + if borrowed.Sign() == 0 { + return false + } + return MaxBorrow(p.Collateral, collateralPrice, lltv).Cmp(borrowed) < 0 +} + +// LiquidationProximity returns the two quantities whose ratio is the position's distance to liquidation: +// borrowed = BorrowedAssetsAt(p, …) and maxBorrow = MaxBorrow(p.Collateral, …). Higher borrowed/maxBorrow +// ⇒ closer to (or past) liquidation; borrowed >= maxBorrow is exactly the IsLiquidatableAt boundary. A +// caller ranks without dividing by cross-multiplying the two pairs (no float, no division). +func LiquidationProximity(p PositionState, collateralPrice, lltv, accruedTotal, totalShares *big.Int) (borrowed, maxBorrow *big.Int) { + return BorrowedAssetsAt(p, accruedTotal, totalShares), MaxBorrow(p.Collateral, collateralPrice, lltv) +} + +// LiquidationIncentiveFactor = min(M, 1 / (1 - cursor*(1 - lltv))) in wad, matching liquidate(). +func LiquidationIncentiveFactor(lltv *big.Int) *big.Int { + // WAD.wDivDown(WAD - LIQUIDATION_CURSOR.wMulDown(WAD - lltv)) + oneMinusLltv := new(big.Int).Sub(Wad, lltv) + denom := new(big.Int).Sub(Wad, WMulDown(liquidationCursor, oneMinusLltv)) + lif := WDivDown(Wad, denom) + if lif.Cmp(maxLiqIncentive) > 0 { + return new(big.Int).Set(maxLiqIncentive) + } + return lif +} + +// RepaidAssetsForSeizeAt replicates liquidate()'s seize→shares→assets path with Morpho's rounding (quote +// up, divide by LIF up, shares up, assets up) given a pre-accrued total and the precomputed +// LiquidationIncentiveFactor — the hot-path variant (sizeLeg computes the LIF once and passes it here and +// to MaxSeizeForFullDebt). +func RepaidAssetsForSeizeAt(seizedAssets, collateralPrice, lif, accruedTotal, totalShares *big.Int) *big.Int { + seizedQuoted := MulDivUp(seizedAssets, collateralPrice, oraclePriceScale) + repaidShares := ToSharesUp(WDivUp(seizedQuoted, lif), accruedTotal, totalShares) + return ToAssetsUp(repaidShares, accruedTotal, totalShares) +} + +// ApplySeizeLiquidation replays Morpho Blue liquidate(market, borrower, seizedAssets, 0, data) on local +// state. It assumes m is already accrued to the settlement timestamp and returns ok=false for any state +// transition that would underflow or cannot be priced. +func ApplySeizeLiquidation(m MarketState, p PositionState, seizedAssets, collateralPrice *big.Int) (LiquidationReplay, bool) { + if seizedAssets == nil || seizedAssets.Sign() <= 0 || collateralPrice == nil || collateralPrice.Sign() <= 0 || + m.TotalBorrowAssets == nil || m.TotalBorrowShares == nil || m.TotalSupplyAssets == nil || + p.BorrowShares == nil || p.Collateral == nil || m.Lltv == nil { + return LiquidationReplay{}, false + } + lif := LiquidationIncentiveFactor(m.Lltv) + seizedQuoted := MulDivUp(seizedAssets, collateralPrice, oraclePriceScale) + repaidShares := ToSharesUp(WDivUp(seizedQuoted, lif), m.TotalBorrowAssets, m.TotalBorrowShares) + repaidAssets := ToAssetsUp(repaidShares, m.TotalBorrowAssets, m.TotalBorrowShares) + if p.BorrowShares.Cmp(repaidShares) < 0 || m.TotalBorrowShares.Cmp(repaidShares) < 0 || p.Collateral.Cmp(seizedAssets) < 0 { + return LiquidationReplay{}, false + } + out := LiquidationReplay{ + Market: cloneMarketState(m), + Position: clonePositionState(p), + RepaidAssets: repaidAssets, + RepaidShares: repaidShares, + BadDebtAssets: new(big.Int), + BadDebtShares: new(big.Int), + } + out.Position.BorrowShares.Sub(out.Position.BorrowShares, repaidShares) + out.Market.TotalBorrowShares.Sub(out.Market.TotalBorrowShares, repaidShares) + out.Market.TotalBorrowAssets = zeroFloorSub(out.Market.TotalBorrowAssets, repaidAssets) + out.Position.Collateral.Sub(out.Position.Collateral, seizedAssets) + if out.Position.Collateral.Sign() == 0 { + out.BadDebtShares = new(big.Int).Set(out.Position.BorrowShares) + out.BadDebtAssets = minBig(out.Market.TotalBorrowAssets, ToAssetsUp(out.BadDebtShares, out.Market.TotalBorrowAssets, out.Market.TotalBorrowShares)) + if out.Market.TotalSupplyAssets.Cmp(out.BadDebtAssets) < 0 || out.Market.TotalBorrowShares.Cmp(out.BadDebtShares) < 0 { + return LiquidationReplay{}, false + } + out.Market.TotalBorrowAssets.Sub(out.Market.TotalBorrowAssets, out.BadDebtAssets) + out.Market.TotalSupplyAssets.Sub(out.Market.TotalSupplyAssets, out.BadDebtAssets) + out.Market.TotalBorrowShares.Sub(out.Market.TotalBorrowShares, out.BadDebtShares) + out.Position.BorrowShares = new(big.Int) + } + return out, true +} + +// MaxSeizeForFullDebt is the largest collateral seize whose implied repayment never exceeds the borrower's +// outstanding debt — the inverse of RepaidAssetsForSeizeAt at the full-debt point. It mirrors Morpho +// liquidate()'s shares→seize path (the branch where repaidShares is the input): seize the full borrow +// shares back through assets-down → ×LIF down → ÷price down. Every step rounds DOWN, so the resulting seize +// repays AT MOST the full debt — a full liquidation clamps to this and can never round up past the debt +// (which would underflow borrowShares and revert). The leg's seize target is min(its fraction, this). lif is +// the precomputed LiquidationIncentiveFactor (sizeLeg computes it once for both this and +// RepaidAssetsForSeizeAt). +func MaxSeizeForFullDebt(borrowShares, collateralPrice, lif, accruedTotal, totalShares *big.Int) *big.Int { + if borrowShares == nil || borrowShares.Sign() <= 0 || collateralPrice == nil || collateralPrice.Sign() <= 0 { + return new(big.Int) + } + debtAssets := ToAssetsDown(borrowShares, accruedTotal, totalShares) + return MulDivDown(WMulDown(debtAssets, lif), oraclePriceScale, collateralPrice) +} + +/* ───────── whole-market convenience forms (accrue once, then forward) ───────── */ + +// BorrowedAssets accrues the market to nowTs, then forwards to BorrowedAssetsAt. The hot path accrues once +// and calls the *At forms directly; these whole-market forms are for callers (and tests) holding a raw state. +func BorrowedAssets(m MarketState, p PositionState, nowTs uint64) *big.Int { + return BorrowedAssetsAt(p, AccruedTotalBorrowAssets(m, nowTs), m.TotalBorrowShares) +} + +// IsLiquidatable accrues the market to nowTs, then forwards to IsLiquidatableAt. +func IsLiquidatable(m MarketState, p PositionState, collateralPrice *big.Int, nowTs uint64) bool { + return IsLiquidatableAt(p, collateralPrice, m.Lltv, AccruedTotalBorrowAssets(m, nowTs), m.TotalBorrowShares) +} + +// RepaidAssetsForSeize accrues the market to nowTs, then forwards to RepaidAssetsForSeizeAt. +func RepaidAssetsForSeize(m MarketState, seizedAssets, collateralPrice, lltv *big.Int, nowTs uint64) *big.Int { + return RepaidAssetsForSeizeAt(seizedAssets, collateralPrice, LiquidationIncentiveFactor(lltv), AccruedTotalBorrowAssets(m, nowTs), m.TotalBorrowShares) +} + +/* ───────── SharesMathLib (virtual shares/assets) ───────── */ + +func ToSharesUp(assets, totalAssets, totalShares *big.Int) *big.Int { + return MulDivUp(assets, new(big.Int).Add(totalShares, virtualShares), new(big.Int).Add(totalAssets, virtualAssets)) +} + +func ToAssetsUp(shares, totalAssets, totalShares *big.Int) *big.Int { + return MulDivUp(shares, new(big.Int).Add(totalAssets, virtualAssets), new(big.Int).Add(totalShares, virtualShares)) +} + +func ToSharesDown(assets, totalAssets, totalShares *big.Int) *big.Int { + return MulDivDown(assets, new(big.Int).Add(totalShares, virtualShares), new(big.Int).Add(totalAssets, virtualAssets)) +} + +// ToAssetsDown is SharesMathLib.toAssetsDown — used by liquidate()'s shares→seize path (MaxSeizeForFullDebt). +func ToAssetsDown(shares, totalAssets, totalShares *big.Int) *big.Int { + return MulDivDown(shares, new(big.Int).Add(totalAssets, virtualAssets), new(big.Int).Add(totalShares, virtualShares)) +} + +/* ───────── MathLib (wad + mulDiv) ───────── */ + +func WTaylorCompounded(ratePerSec, n *big.Int) *big.Int { + // firstTerm = x*n; second = firstTerm²/(2·WAD); third = second·firstTerm/(3·WAD) + first := new(big.Int).Mul(ratePerSec, n) + second := MulDivDown(first, first, twoWad) + third := MulDivDown(second, first, threeWad) + return new(big.Int).Add(new(big.Int).Add(first, second), third) +} + +func WMulDown(x, y *big.Int) *big.Int { return MulDivDown(x, y, Wad) } +func WDivDown(x, y *big.Int) *big.Int { return MulDivDown(x, Wad, y) } +func WDivUp(x, y *big.Int) *big.Int { return MulDivUp(x, Wad, y) } + +func MulDivDown(x, y, d *big.Int) *big.Int { + return new(big.Int).Div(new(big.Int).Mul(x, y), d) +} + +func MulDivUp(x, y, d *big.Int) *big.Int { + // (x*y + d - 1) / d + num := new(big.Int).Mul(x, y) + num.Add(num, new(big.Int).Sub(d, one)) + return num.Div(num, d) +} + +func cloneMarketState(m MarketState) MarketState { + return MarketState{ + TotalSupplyAssets: cloneBig(m.TotalSupplyAssets), + TotalSupplyShares: cloneBig(m.TotalSupplyShares), + TotalBorrowAssets: cloneBig(m.TotalBorrowAssets), + TotalBorrowShares: cloneBig(m.TotalBorrowShares), + LastUpdate: m.LastUpdate, + Fee: cloneBig(m.Fee), + Lltv: cloneBig(m.Lltv), + BorrowRatePerSec: cloneBig(m.BorrowRatePerSec), + } +} + +func clonePositionState(p PositionState) PositionState { + return PositionState{BorrowShares: cloneBig(p.BorrowShares), Collateral: cloneBig(p.Collateral)} +} + +func cloneBig(n *big.Int) *big.Int { + if n == nil { + return nil + } + return new(big.Int).Set(n) +} + +func zeroFloorSub(x, y *big.Int) *big.Int { + if x.Cmp(y) <= 0 { + return new(big.Int) + } + return new(big.Int).Sub(x, y) +} + +func minBig(a, b *big.Int) *big.Int { + if a.Cmp(b) <= 0 { + return new(big.Int).Set(a) + } + return new(big.Int).Set(b) +} diff --git a/internal/morpho/math_test.go b/internal/morpho/math_test.go new file mode 100644 index 00000000..4c0514f4 --- /dev/null +++ b/internal/morpho/math_test.go @@ -0,0 +1,264 @@ +package morpho + +import ( + "math/big" + "testing" +) + +// goldenMarket is the live Sepolia test market state read on-chain (docs/OEV-PLAN.md §6.5/§6.7): +// TLOAN(6dp)/TCOL(18dp), lltv 0.86, IRM borrowRateView = 182418302 wad/sec, lastUpdate 1780059204. +func goldenMarket() MarketState { + return MarketState{ + TotalSupplyAssets: big.NewInt(100000000068), + TotalSupplyShares: mustBig("100000000000000000"), + TotalBorrowAssets: big.NewInt(4730000068), + TotalBorrowShares: mustBig("4729999932892591"), + LastUpdate: 1780059204, + Fee: big.NewInt(0), + Lltv: mustBig("860000000000000000"), + BorrowRatePerSec: big.NewInt(182418302), + } +} + +// goldenBorrower is 0x629d… — 1.0 TCOL collateral, borrowShares 1685600000000000. +func goldenBorrower() PositionState { + return PositionState{BorrowShares: mustBig("1685600000000000"), Collateral: mustBig("1000000000000000000")} +} + +func TestAccrualMatchesOnChain(t *testing.T) { + m := goldenMarket() + // elapsed = 1781246580 - 1780059204 = 1187376s -> interest 1024624 (verified §6.7). + got := AccruedTotalBorrowAssets(m, 1781246580) + if want := big.NewInt(4731024692); got.Cmp(want) != 0 { + t.Fatalf("AccruedTotalBorrowAssets = %s, want %s", got, want) + } + // No accrual at lastUpdate. + if atLU := AccruedTotalBorrowAssets(m, m.LastUpdate); atLU.Cmp(m.TotalBorrowAssets) != 0 { + t.Fatalf("accrual at lastUpdate = %s, want %s", atLU, m.TotalBorrowAssets) + } + full := AccruedMarketState(m, 1781246580) + if want := big.NewInt(4731024692); full.TotalBorrowAssets.Cmp(want) != 0 { + t.Fatalf("AccruedMarketState borrow = %s, want %s", full.TotalBorrowAssets, want) + } + if want := big.NewInt(100001024692); full.TotalSupplyAssets.Cmp(want) != 0 { + t.Fatalf("AccruedMarketState supply = %s, want %s", full.TotalSupplyAssets, want) + } + if full.LastUpdate != 1781246580 { + t.Fatalf("AccruedMarketState lastUpdate = %d, want 1781246580", full.LastUpdate) + } +} + +func TestBorrowedAssetsUnaccrued(t *testing.T) { + // toAssetsUp at lastUpdate equals RedStone's pushed borrow_assets (1685600048) within 1-wei + // rounding (§6.7): our ToAssetsUp rounds up -> 1685600049. + got := BorrowedAssets(goldenMarket(), goldenBorrower(), goldenMarket().LastUpdate) + if want := big.NewInt(1685600049); got.Cmp(want) != 0 { + t.Fatalf("unaccrued borrowed = %s, want %s", got, want) + } +} + +func TestLiquidationIncentiveFactor(t *testing.T) { + // lltv 0.86 -> 1e36 / 0.958e18 = 1043841336116910229 (floor). + got := LiquidationIncentiveFactor(mustBig("860000000000000000")) + if want := mustBig("1043841336116910229"); got.Cmp(want) != 0 { + t.Fatalf("LIF = %s, want %s", got, want) + } +} + +// TestMaxSeizeForFullDebt pins the F2 clamp helper. The on-chain revert is a borrowShares underflow +// (position.borrowShares -= repaidShares), so the binding invariant is repaidShares(maxSeize) ≤ +// borrowShares — every inverse step rounds down so a full liquidation clamped to this can't underflow. It +// also stays ≤ the up-rounded debt (BorrowedAssetsAt), the proxy the strategy clamps against. +func TestMaxSeizeForFullDebt(t *testing.T) { + lltv := mustBig("500000000000000000") + price := mustBig("1000000000000000000000000000000000000") // 1e36 + totalAssets := mustBig("1000000000000000000000000") + totalShares := new(big.Int).Set(totalAssets) // 1:1 + lif := LiquidationIncentiveFactor(lltv) + for _, shares := range []*big.Int{ + mustBig("1"), mustBig("500000000000000001"), mustBig("123456789012345678"), mustBig("999999999999999999"), + } { + maxSeize := MaxSeizeForFullDebt(shares, price, lif, totalAssets, totalShares) + // The exact on-chain underflow condition: repaidShares must not exceed the borrower's borrowShares. + seizedQuoted := MulDivUp(maxSeize, price, oraclePriceScale) + repaidShares := ToSharesUp(WDivUp(seizedQuoted, lif), totalAssets, totalShares) + if repaidShares.Cmp(shares) > 0 { + t.Fatalf("MaxSeizeForFullDebt over-repays: shares=%s seize=%s repaidShares=%s > borrowShares=%s (underflow)", + shares, maxSeize, repaidShares, shares) + } + // And ≤ the up-rounded debt the strategy uses as its assets-level proxy. + debtUp := BorrowedAssetsAt(PositionState{BorrowShares: shares}, totalAssets, totalShares) + if repaid := RepaidAssetsForSeizeAt(maxSeize, price, lif, totalAssets, totalShares); repaid.Cmp(debtUp) > 0 { + t.Fatalf("repaidAssets %s > borrowerDebt(up) %s for shares=%s seize=%s", repaid, debtUp, shares, maxSeize) + } + } + // Degenerate inputs fail closed to 0 (no seize), not a panic. + if got := MaxSeizeForFullDebt(big.NewInt(0), price, lif, totalAssets, totalShares); got.Sign() != 0 { + t.Fatalf("zero borrowShares must give zero maxSeize, got %s", got) + } + if got := MaxSeizeForFullDebt(mustBig("1"), big.NewInt(0), lif, totalAssets, totalShares); got.Sign() != 0 { + t.Fatalf("zero price must give zero maxSeize, got %s", got) + } +} + +func TestIsLiquidatableAcrossPrices(t *testing.T) { + m := goldenMarket() + ts := uint64(1781246580) + cases := []struct { + name string + pos PositionState + price string + want bool + }{ + {"live 2000 healthy", goldenBorrower(), "2000000000000000000000000000", false}, // 2e27 + {"auctioned 1800.94 liquidatable", goldenBorrower(), "1800943620100000000000000000", true}, // 1.8009e27 + {"crashed 1550 liquidatable", goldenBorrower(), "1550000000000000000000000000", true}, // 1.55e27 + // Zero debt (BorrowShares=0) with collateral is healthy at ANY price — the debt-free branch through + // IsLiquidatable can never be underwater. Priced at the crash level that liquidates a debted position. + {"zero debt healthy at crash price", PositionState{BorrowShares: big.NewInt(0), Collateral: mustBig("1000000000000000000")}, "1550000000000000000000000000", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := IsLiquidatable(m, c.pos, mustBig(c.price), ts); got != c.want { + t.Fatalf("IsLiquidatable(%s) = %v, want %v", c.price, got, c.want) + } + }) + } +} + +// TestLiquidationProximity pins the proximity pair against BorrowedAssetsAt / MaxBorrow and checks that +// the borrowed >= maxBorrow boundary tracks IsLiquidatableAt. +func TestLiquidationProximity(t *testing.T) { + m := goldenMarket() + accrued := AccruedTotalBorrowAssets(m, m.LastUpdate) + cases := []struct { + name string + pos PositionState + price string + wantLiqable bool + }{ + {"healthy at 2000", goldenBorrower(), "2000000000000000000000000000", false}, + {"liquidatable at 1550", goldenBorrower(), "1550000000000000000000000000", true}, + {"zero debt", PositionState{BorrowShares: big.NewInt(0), Collateral: mustBig("1000000000000000000")}, "1550000000000000000000000000", false}, + {"underwater: zero maxBorrow with debt", goldenBorrower(), "0", true}, // price 0 ⇒ maxBorrow 0, borrowed > 0 + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + price := mustBig(c.price) + borrowed, maxBorrow := LiquidationProximity(c.pos, price, m.Lltv, accrued, m.TotalBorrowShares) + // The pair must equal the underlying helpers exactly. + if want := BorrowedAssetsAt(c.pos, accrued, m.TotalBorrowShares); borrowed.Cmp(want) != 0 { + t.Fatalf("borrowed = %s, want %s", borrowed, want) + } + if want := MaxBorrow(c.pos.Collateral, price, m.Lltv); maxBorrow.Cmp(want) != 0 { + t.Fatalf("maxBorrow = %s, want %s", maxBorrow, want) + } + // borrowed >= maxBorrow (with borrowed > 0) is the IsLiquidatableAt boundary. + boundary := borrowed.Sign() > 0 && borrowed.Cmp(maxBorrow) >= 0 + if boundary != c.wantLiqable { + t.Fatalf("borrowed>=maxBorrow = %v (borrowed=%s maxBorrow=%s), want %v", boundary, borrowed, maxBorrow, c.wantLiqable) + } + if liq := IsLiquidatableAt(c.pos, price, m.Lltv, accrued, m.TotalBorrowShares); liq != c.wantLiqable { + t.Fatalf("IsLiquidatableAt = %v, want %v", liq, c.wantLiqable) + } + }) + } +} + +func TestRepaidAssetsForSeizeMatchesLiveLiquidation(t *testing.T) { + // The real successful liquidation (§6.6) seized 0.5 TCOL at $1550 and repaid ~742.45 TLOAN + // (swapAmountOut 760 - profit 17.55). Assert RepaidAssetsForSeize lands in that band. + m := goldenMarket() + got := RepaidAssetsForSeize(m, mustBig("500000000000000000"), mustBig("1550000000000000000000000000"), + m.Lltv, m.LastUpdate) + lo, hi := big.NewInt(742_000_000), big.NewInt(743_000_000) + if got.Cmp(lo) < 0 || got.Cmp(hi) > 0 { + t.Fatalf("RepaidAssetsForSeize = %s, want in [%s, %s]", got, lo, hi) + } +} + +func TestApplySeizeLiquidationOrdinary(t *testing.T) { + m := MarketState{ + TotalSupplyAssets: mustBig("2000000"), + TotalSupplyShares: mustBig("2000000"), + TotalBorrowAssets: mustBig("1000000"), + TotalBorrowShares: mustBig("1000000"), + Lltv: mustBig("500000000000000000"), + } + p := PositionState{BorrowShares: mustBig("500000"), Collateral: mustBig("1000000000000000000")} + seized := mustBig("10000000000000000") + price := mustBig("1000000000000000000000000") + + got, ok := ApplySeizeLiquidation(m, p, seized, price) + if !ok { + t.Fatal("ordinary liquidation should replay") + } + lif := LiquidationIncentiveFactor(m.Lltv) + wantRepaid := RepaidAssetsForSeizeAt(seized, price, lif, m.TotalBorrowAssets, m.TotalBorrowShares) + if got.RepaidAssets.Cmp(wantRepaid) != 0 { + t.Fatalf("repaidAssets = %s, want %s", got.RepaidAssets, wantRepaid) + } + if got.Position.Collateral.Cmp(new(big.Int).Sub(p.Collateral, seized)) != 0 { + t.Fatalf("collateral = %s, want %s", got.Position.Collateral, new(big.Int).Sub(p.Collateral, seized)) + } + if got.Market.TotalBorrowShares.Cmp(new(big.Int).Sub(m.TotalBorrowShares, got.RepaidShares)) != 0 { + t.Fatalf("totalBorrowShares = %s, want initial-repaidShares", got.Market.TotalBorrowShares) + } + if got.Market.TotalBorrowAssets.Cmp(new(big.Int).Sub(m.TotalBorrowAssets, got.RepaidAssets)) != 0 { + t.Fatalf("totalBorrowAssets = %s, want initial-repaidAssets", got.Market.TotalBorrowAssets) + } + if got.BadDebtAssets.Sign() != 0 || got.BadDebtShares.Sign() != 0 { + t.Fatalf("ordinary liquidation recorded bad debt assets=%s shares=%s", got.BadDebtAssets, got.BadDebtShares) + } +} + +func TestApplySeizeLiquidationBadDebt(t *testing.T) { + m := MarketState{ + TotalSupplyAssets: mustBig("2000000"), + TotalSupplyShares: mustBig("2000000"), + TotalBorrowAssets: mustBig("1000000"), + TotalBorrowShares: mustBig("1000000"), + Lltv: mustBig("500000000000000000"), + } + p := PositionState{BorrowShares: mustBig("500000"), Collateral: mustBig("100000000000000000")} + got, ok := ApplySeizeLiquidation(m, p, p.Collateral, mustBig("1000000000000000000000000")) + if !ok { + t.Fatal("bad-debt liquidation should replay") + } + if got.Position.Collateral.Sign() != 0 || got.Position.BorrowShares.Sign() != 0 { + t.Fatalf("borrower should be closed after bad debt, got collateral=%s borrowShares=%s", got.Position.Collateral, got.Position.BorrowShares) + } + if got.BadDebtShares.Sign() == 0 || got.BadDebtAssets.Sign() == 0 { + t.Fatalf("expected bad debt, got assets=%s shares=%s", got.BadDebtAssets, got.BadDebtShares) + } + wantBorrowShares := new(big.Int).Sub(new(big.Int).Sub(m.TotalBorrowShares, got.RepaidShares), got.BadDebtShares) + if got.Market.TotalBorrowShares.Cmp(wantBorrowShares) != 0 { + t.Fatalf("totalBorrowShares = %s, want %s", got.Market.TotalBorrowShares, wantBorrowShares) + } + wantSupplyAssets := new(big.Int).Sub(m.TotalSupplyAssets, got.BadDebtAssets) + if got.Market.TotalSupplyAssets.Cmp(wantSupplyAssets) != 0 { + t.Fatalf("totalSupplyAssets = %s, want %s", got.Market.TotalSupplyAssets, wantSupplyAssets) + } +} + +func TestApplySeizeLiquidationInvalidFailsClosed(t *testing.T) { + m := MarketState{ + TotalSupplyAssets: mustBig("2000000"), + TotalSupplyShares: mustBig("2000000"), + TotalBorrowAssets: mustBig("1000000"), + TotalBorrowShares: mustBig("1000000"), + Lltv: mustBig("500000000000000000"), + } + p := PositionState{BorrowShares: big.NewInt(1), Collateral: big.NewInt(1)} + if _, ok := ApplySeizeLiquidation(m, p, mustBig("1000000000000000000"), mustBig("10000000000000000000000000")); ok { + t.Fatal("over-seize/over-repay must fail closed") + } +} + +func mustBig(s string) *big.Int { + n, ok := new(big.Int).SetString(s, 10) + if !ok { + panic("bad big int: " + s) + } + return n +} diff --git a/internal/parse/parse.go b/internal/parse/parse.go new file mode 100644 index 00000000..74e2eec0 --- /dev/null +++ b/internal/parse/parse.go @@ -0,0 +1,135 @@ +// Package parse holds the pure parse/coerce primitives shared by the solvers' config parsing. +// It is protocol-agnostic framework code: it must not import any solver or protocol package. +package parse + +import ( + "bytes" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/go-errors/errors" + "gopkg.in/yaml.v3" +) + +func Address(s, field string) (common.Address, error) { + if !common.IsHexAddress(s) { + return common.Address{}, errors.Errorf("%s: invalid address %q", field, s) + } + return common.HexToAddress(s), nil +} + +func NonZeroAddress(s, field string) (common.Address, error) { + addr, err := Address(s, field) + if err != nil { + return common.Address{}, err + } + if addr == (common.Address{}) { + return common.Address{}, errors.Errorf("%s: zero address (placeholder not replaced?)", field) + } + return addr, nil +} + +func Hash(s, field string) (common.Hash, error) { + // Decode (not just length-check) so a non-hex body fails closed instead of HexToHash silently + // zero-filling a typo'd id into the zero hash. + b, err := hexutil.Decode(s) + if err != nil || len(b) != 32 { + return common.Hash{}, errors.Errorf("%s: invalid 32-byte hex %q", field, s) + } + return common.BytesToHash(b), nil +} + +func Big(s, field string) (*big.Int, error) { + n, ok := new(big.Int).SetString(s, 10) + if !ok { + return nil, errors.Errorf("%s: invalid integer %q", field, s) + } + return n, nil +} + +// EthToWei converts a decimal ether string (e.g. "0.0005") to wei exactly (no float rounding): +// split on the point, right-pad the fraction to 18 digits, and combine. +func EthToWei(s, field string) (*big.Int, error) { + intPart, fracPart, hasDot := strings.Cut(s, ".") + if intPart == "" { + intPart = "0" + } + if len(fracPart) > 18 { + return nil, errors.Errorf("%s: more than 18 decimals: %q", field, s) + } + for len(fracPart) < 18 { + fracPart += "0" + } + combined := intPart + fracPart + if hasDot && fracPart == "" { + combined = intPart // "5." form + } + wei, ok := new(big.Int).SetString(combined, 10) + if !ok { + return nil, errors.Errorf("%s: invalid decimal %q", field, s) + } + if wei.Sign() < 0 { // an ETH amount is never negative; a "-…" would silently disable a floor/trigger + return nil, errors.Errorf("%s: must be >= 0, got %q", field, s) + } + return wei, nil +} + +// OrDefault returns v unless it is the zero value, in which case it returns fallback. +func OrDefault[T comparable](v, fallback T) T { + var zero T + if v == zero { + return fallback + } + return v +} + +// MsDuration converts a millisecond config field to a Duration: a nil pointer (field omitted) yields +// fallback, while a present value must be strictly positive — a set-but-non-positive interval is a +// misconfiguration and is rejected here rather than silently defaulted (mirrors the fail-closed +// duration handling in Duration). +func MsDuration(ms *int, fallback time.Duration, field string) (time.Duration, error) { + if ms == nil { + return fallback, nil + } + if *ms <= 0 { + return 0, errors.Errorf("%s: must be a positive duration in ms, got %d", field, *ms) + } + return time.Duration(*ms) * time.Millisecond, nil +} + +// Duration returns fallback when s is empty, but a present-but-invalid or non-positive value is +// an error rather than a silent fall back to the default — a typo'd interval should fail, not run at +// some surprising cadence. +func Duration(s string, fallback time.Duration, field string) (time.Duration, error) { + if s == "" { + return fallback, nil + } + d, err := time.ParseDuration(s) + if err != nil { + return 0, errors.Errorf("%s: invalid duration %q: %w", field, s, err) + } + if d <= 0 { + return 0, errors.Errorf("%s: duration must be positive, got %q", field, s) + } + return d, nil +} + +// DecodeStrict decodes a deferred YAML node into out, rejecting unknown keys. +func DecodeStrict(node yaml.Node, out any) error { + if node.Kind == 0 { + node = yaml.Node{Kind: yaml.MappingNode} + } + b, err := yaml.Marshal(&node) + if err != nil { + return errors.Errorf("re-encode config: %w", err) + } + dec := yaml.NewDecoder(bytes.NewReader(b)) + dec.KnownFields(true) + if err := dec.Decode(out); err != nil { + return errors.Errorf("decode config: %w", err) + } + return nil +} diff --git a/internal/parse/parse_test.go b/internal/parse/parse_test.go new file mode 100644 index 00000000..3ad1101b --- /dev/null +++ b/internal/parse/parse_test.go @@ -0,0 +1,230 @@ +package parse + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "gopkg.in/yaml.v3" +) + +func TestAddress(t *testing.T) { + tests := []struct { + name string + in string + want common.Address + wantErr bool + }{ + {name: "valid", in: "0x1111111111111111111111111111111111111111", want: common.HexToAddress("0x1111111111111111111111111111111111111111")}, + {name: "zero", in: "0x0000000000000000000000000000000000000000", want: common.Address{}}, + {name: "no prefix", in: "1111111111111111111111111111111111111111", want: common.HexToAddress("0x1111111111111111111111111111111111111111")}, + {name: "too short", in: "0x1234", wantErr: true}, + {name: "empty", in: "", wantErr: true}, + {name: "not hex", in: "0xZZZZ111111111111111111111111111111111111", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Address(tt.in, "field") + if (err != nil) != tt.wantErr { + t.Fatalf("Address(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Fatalf("Address(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestNonZeroAddress(t *testing.T) { + tests := []struct { + name string + in string + want common.Address + wantErr bool + }{ + {name: "valid", in: "0x2222222222222222222222222222222222222222", want: common.HexToAddress("0x2222222222222222222222222222222222222222")}, + {name: "zero address", in: "0x0000000000000000000000000000000000000000", wantErr: true}, + {name: "invalid", in: "0xnope", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NonZeroAddress(tt.in, "field") + if (err != nil) != tt.wantErr { + t.Fatalf("NonZeroAddress(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Fatalf("NonZeroAddress(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestHash(t *testing.T) { + tests := []struct { + name string + in string + wantErr bool + }{ + {name: "valid", in: "0x000000000000000000000000000000000000000000000000000000000000beef"}, + {name: "no prefix", in: "000000000000000000000000000000000000000000000000000000000000beef", wantErr: true}, + {name: "too short", in: "0xbeef", wantErr: true}, + {name: "too long", in: "0x00000000000000000000000000000000000000000000000000000000000000beef", wantErr: true}, + {name: "empty", in: "", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Hash(tt.in, "field") + if (err != nil) != tt.wantErr { + t.Fatalf("Hash(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err == nil && got != common.HexToHash(tt.in) { + t.Fatalf("Hash(%q) = %v, want %v", tt.in, got, common.HexToHash(tt.in)) + } + }) + } +} + +func TestBig(t *testing.T) { + tests := []struct { + name string + in string + want *big.Int + wantErr bool + }{ + {name: "positive", in: "12345", want: big.NewInt(12345)}, + {name: "zero", in: "0", want: big.NewInt(0)}, + {name: "negative", in: "-7", want: big.NewInt(-7)}, + {name: "not a number", in: "abc", wantErr: true}, + {name: "empty", in: "", wantErr: true}, + {name: "hex rejected", in: "0x10", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Big(tt.in, "field") + if (err != nil) != tt.wantErr { + t.Fatalf("Big(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err == nil && got.Cmp(tt.want) != 0 { + t.Fatalf("Big(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestEthToWei(t *testing.T) { + mustBig := func(s string) *big.Int { + n, _ := new(big.Int).SetString(s, 10) + return n + } + tests := []struct { + name string + in string + want *big.Int + wantErr bool + }{ + {name: "fractional", in: "0.0005", want: mustBig("500000000000000")}, + {name: "whole", in: "1", want: mustBig("1000000000000000000")}, + {name: "zero", in: "0", want: big.NewInt(0)}, + {name: "trailing dot", in: "5.", want: mustBig("5000000000000000000")}, + {name: "leading dot", in: ".5", want: mustBig("500000000000000000")}, + {name: "18 decimals", in: "0.000000000000000001", want: big.NewInt(1)}, + {name: "more than 18 decimals", in: "0.0000000000000000001", wantErr: true}, + {name: "negative", in: "-1", wantErr: true}, + {name: "garbage", in: "abc", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := EthToWei(tt.in, "field") + if (err != nil) != tt.wantErr { + t.Fatalf("EthToWei(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err == nil && got.Cmp(tt.want) != 0 { + t.Fatalf("EthToWei(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestOrDefault(t *testing.T) { + if got := OrDefault("", "fallback"); got != "fallback" { + t.Fatalf("OrDefault empty string = %q, want fallback", got) + } + if got := OrDefault("set", "fallback"); got != "set" { + t.Fatalf("OrDefault non-empty string = %q, want set", got) + } + if got := OrDefault(0, 42); got != 42 { + t.Fatalf("OrDefault zero int = %d, want 42", got) + } + if got := OrDefault(7, 42); got != 7 { + t.Fatalf("OrDefault non-zero int = %d, want 7", got) + } +} + +func TestMsDuration(t *testing.T) { + fallback := 3 * time.Second + ptr := func(i int) *int { return &i } + tests := []struct { + name string + in *int + want time.Duration + wantErr bool + }{ + {name: "nil uses fallback", in: nil, want: fallback}, + {name: "positive", in: ptr(1500), want: 1500 * time.Millisecond}, + {name: "zero rejected", in: ptr(0), wantErr: true}, + {name: "negative rejected", in: ptr(-5), wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := MsDuration(tt.in, fallback, "field") + if (err != nil) != tt.wantErr { + t.Fatalf("MsDuration err = %v, wantErr %v", err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Fatalf("MsDuration = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDuration(t *testing.T) { + fallback := 10 * time.Second + tests := []struct { + name string + in string + want time.Duration + wantErr bool + }{ + {name: "empty uses fallback", in: "", want: fallback}, + {name: "valid", in: "2m", want: 2 * time.Minute}, + {name: "invalid", in: "notaduration", wantErr: true}, + {name: "zero rejected", in: "0s", wantErr: true}, + {name: "negative rejected", in: "-1s", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Duration(tt.in, fallback, "field") + if (err != nil) != tt.wantErr { + t.Fatalf("Duration(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr) + } + if err == nil && got != tt.want { + t.Fatalf("Duration(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestDecodeStrict(t *testing.T) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte("known: value\nunknown: typo\n"), &doc); err != nil { + t.Fatalf("unmarshal yaml: %v", err) + } + var out struct { + Known string `yaml:"known"` + } + err := DecodeStrict(*doc.Content[0], &out) + if err == nil { + t.Fatal("expected unknown field error") + } +} diff --git a/internal/solver/solver.go b/internal/solver/solver.go index cd540bb1..d035b4ce 100644 --- a/internal/solver/solver.go +++ b/internal/solver/solver.go @@ -4,7 +4,6 @@ package solver import ( - "bytes" "context" "sort" "sync" @@ -16,6 +15,7 @@ import ( "github.com/symbioticfi/vault-solver/internal/chain" "github.com/symbioticfi/vault-solver/internal/observability" + "github.com/symbioticfi/vault-solver/internal/parse" "github.com/symbioticfi/vault-solver/internal/signer" "github.com/symbioticfi/vault-solver/internal/txmanager" ) @@ -46,16 +46,7 @@ type Factory func(raw yaml.Node, deps Deps) (Solver, error) // each solver's config opaque (yaml.Node has no KnownFields option of its own), so solvers call this // from parseConfig instead of node.Decode to get the same typo protection. func DecodeStrict(node yaml.Node, out any) error { - b, err := yaml.Marshal(&node) - if err != nil { - return errors.Errorf("re-encode solver config: %w", err) - } - dec := yaml.NewDecoder(bytes.NewReader(b)) - dec.KnownFields(true) - if err := dec.Decode(out); err != nil { - return errors.Errorf("decode solver config: %w", err) - } - return nil + return parse.DecodeStrict(node, out) } var ( @@ -110,7 +101,10 @@ func Run(ctx context.Context, s Solver, log logr.Logger) error { log.Info("solver running") err := s.Run(ctx) if err != nil && !errors.Is(err, context.Canceled) { - return errors.Errorf("solver %q: %w", s.Name(), err) + wrapped := errors.Errorf("solver %q: %w", s.Name(), err) + // Attribute the failure to this solver in the structured logs; the returned error still drives exit. + log.Error(wrapped, "solver stopped with error") + return wrapped } log.Info("solver stopped") return nil diff --git a/internal/solver/solver_test.go b/internal/solver/solver_test.go index f9d23cb1..92f8a3f2 100644 --- a/internal/solver/solver_test.go +++ b/internal/solver/solver_test.go @@ -2,8 +2,10 @@ package solver import ( "context" + "strings" "testing" + "github.com/go-errors/errors" "github.com/go-logr/logr" "gopkg.in/yaml.v3" ) @@ -79,3 +81,25 @@ func TestRunTreatsCancellationAsClean(t *testing.T) { t.Fatalf("expected nil on cancellation, got %v", err) } } + +type failingSolver struct { + name string + err error +} + +func (f failingSolver) Name() string { return f.name } +func (f failingSolver) Run(context.Context) error { return f.err } + +func TestRunWrapsNonCancellationError(t *testing.T) { + sentinel := errors.New("startup failed") + err := Run(context.Background(), failingSolver{name: "3f", err: sentinel}, logr.Discard()) + if err == nil { + t.Fatal("expected a non-nil error") + } + if !errors.Is(err, sentinel) { + t.Fatalf("error should wrap the solver's error, got %v", err) + } + if !strings.Contains(err.Error(), `"3f"`) { + t.Fatalf("error should name the solver, got %v", err) + } +} diff --git a/internal/solvers/bridgefacilitator/apiclient.go b/internal/solvers/bridgefacilitator/apiclient.go index f471d2a9..77d21198 100644 --- a/internal/solvers/bridgefacilitator/apiclient.go +++ b/internal/solvers/bridgefacilitator/apiclient.go @@ -2,8 +2,6 @@ package bridgefacilitator import ( "context" - "crypto/sha256" - "encoding/hex" "math/big" "net/http" "strings" @@ -12,282 +10,101 @@ import ( "github.com/go-errors/errors" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/hexutil" "github.com/go-logr/logr" "github.com/symbioticfi/vault-solver/api/threef" "github.com/symbioticfi/vault-solver/internal/signer" ) -// keyRegenCooldown is the minimum spacing between generate-key calls. 3F rate-limits the endpoint -// ("API key was generated recently; try again later" → HTTP 429), so once we've just minted a key -// we must not immediately mint another. Crucially, a 401/403 right after issuing a key is an -// authorization problem with the facilitator/resource, NOT an expired key — regenerating would both -// trip the rate limit and revoke the working key, so within this window we surface the failure -// instead of regenerating. Legitimate mid-run expiry (hours later) is well outside the window. -const keyRegenCooldown = 2 * time.Minute +// getOffersDeadlineWindow is how far in the future the signed GetOffers deadline is set. +const getOffersDeadlineWindow = 5 * time.Minute -// apiClient wraps the generated 3F client. It injects the x-api-key header, lazily generates the -// key (EIP-712, signed by the facilitator), and reactively re-generates on a 401/403 — the 3F key -// has no documented TTL (a new generate-key revokes the prior key), so rather than assume a -// lifetime we refresh on demonstrated auth failure, rate-limited by keyRegenCooldown. +// apiClient wraps the generated 3F client. It signs per-adapter requests via EIP-712 and injects +// the resulting Authorization: Bearer header. // -// All methods are called from the single solver Run goroutine, so the cached key needs no lock. +// All methods are called from the single solver Run goroutine; no locking is required. type apiClient struct { - c *threef.APIClient - sgnr signer.Signer - facilitator common.Address - fallbackKey string // operator-provided key (apiKeyEnv); used if self-generation is unavailable - apiKey string - lastGenerate time.Time // when generate-key was last attempted, to honor 3F's rate limit - log logr.Logger + c *threef.APIClient + sgnr signer.Signer + chainID *big.Int // operating chain; the grunt-api signing domain and the listOffers chainId query + log logr.Logger } -func newAPIClient( - baseURL string, timeout time.Duration, sgnr signer.Signer, facilitator common.Address, fallbackKey string, log logr.Logger, -) (*apiClient, error) { - if baseURL == "" { - return nil, errors.New("3f api: base URL is required") - } +func newAPIClient(baseURL string, sgnr signer.Signer, chainID *big.Int, timeout time.Duration, log logr.Logger) *apiClient { cfg := threef.NewConfiguration() cfg.Servers = threef.ServerConfigurations{{URL: baseURL}} - // Bound every call: the generated client otherwise falls back to http.DefaultClient (no timeout), - // so a hung request would stall the single solver loop, redemption scans included. + // Bound every call; the generated client otherwise uses http.DefaultClient (no timeout) and a hung + // request would stall the single solver loop, redemption scans included. cfg.HTTPClient = &http.Client{Timeout: timeout} - ac := &apiClient{ - c: threef.NewAPIClient(cfg), - sgnr: sgnr, - facilitator: facilitator, - fallbackKey: fallbackKey, - log: log, - } - if fallbackKey != "" { - ac.setKey(fallbackKey, "env fallback") - } - return ac, nil -} - -// setKey records the active x-api-key and logs a non-reversible fingerprint (not the key) so an -// operator can tell which key is active without the secret ever landing in logs. -func (ac *apiClient) setKey(key, source string) { - ac.apiKey = key - ac.log.V(1).Info("3F API key set", "source", source, "fingerprint", keyFingerprint(key)) -} - -// keyFingerprint is a short, non-reversible identifier for a secret, for log correlation only. -func keyFingerprint(key string) string { - if key == "" { - return "(empty)" - } - sum := sha256.Sum256([]byte(key)) - return hex.EncodeToString(sum[:4]) -} - -// ensureKey makes sure a key is available, generating one if needed. -func (ac *apiClient) ensureKey(ctx context.Context) error { - if ac.apiKey != "" { - return nil - } - return ac.refreshKey(ctx) -} - -// refreshKey generates a fresh key (revoking any prior one). If generation is unavailable (e.g. the -// facilitator isn't onboarded yet) and an operator key was supplied, it falls back to that. -// -// Within keyRegenCooldown of the last generate-key attempt it refuses to regenerate and returns an -// error: the existing key is the freshest 3F will issue, so a preceding 401/403 reflects an -// authorization problem (not expiry) and regenerating would only 429 and revoke the working key. -func (ac *apiClient) refreshKey(ctx context.Context) error { - if !ac.lastGenerate.IsZero() && time.Since(ac.lastGenerate) < keyRegenCooldown { - if ac.apiKey != "" { - return errors.Errorf("3f api: key generated %s ago (within the %s regen cooldown); "+ - "auth failure is not key expiry — not regenerating", - time.Since(ac.lastGenerate).Round(time.Second), keyRegenCooldown) - } - // No usable key and still cooling down (e.g. a prior process generated recently). - if ac.fallbackKey != "" { - ac.setKey(ac.fallbackKey, "env fallback") - return nil - } - return errors.Errorf("3f api: generate-key on cooldown (last attempt %s ago) and no key available", - time.Since(ac.lastGenerate).Round(time.Second)) - } - key, err := ac.generate(ctx) - if err != nil { - if ac.fallbackKey != "" { - ac.setKey(ac.fallbackKey, "env fallback") - return nil - } - return err - } - ac.setKey(key, "generated") - return nil -} - -// generate signs the EIP-712 GenerateFacilitatorApiKey message and returns the issued key. It -// records the attempt time (arming keyRegenCooldown) even on failure, so a 429 can't be hammered. -func (ac *apiClient) generate(ctx context.Context) (string, error) { - ac.lastGenerate = time.Now() - deadline := big.NewInt(time.Now().Add(generateKeyDeadline).Unix()) - sig, err := ac.sgnr.SignHash(APIKeyDigest(ac.facilitator, deadline)) - if err != nil { - return "", errors.Errorf("3f api: sign generate-key: %w", err) - } - dto := *threef.NewGenerateFacilitatorApiKeyDto( - apiKeyDomainChainID, - lowerAddr(ac.facilitator), - deadline.String(), - hexutil.Encode(sig), - ) - resp, httpResp, err := ac.c.FacilitatorAPI.AdminControllerGenerateKeyV1(ctx). - GenerateFacilitatorApiKeyDto(dto).Execute() - closeResp(httpResp) - if err != nil { - return "", errors.Errorf("3f api: generate-key: %s: %w", statusOf(httpResp), err) - } - if resp == nil { - return "", errors.Errorf("3f api: generate-key: empty response (%s)", statusOf(httpResp)) - } - apiKey, ok := resp.GetApiKeyOk() - if !ok || apiKey == nil || *apiKey == "" { - return "", errors.Errorf("3f api: generate-key: response missing apiKey (%s)", statusOf(httpResp)) - } - return *apiKey, nil -} - -// withAuth runs an authed call, ensuring a key first and regenerating + retrying once on 401/403. -// `do` performs one attempt and returns the HTTP status of that attempt (so the auth-failure retry -// can trigger) plus any transport/decoding error. -func (ac *apiClient) withAuth(ctx context.Context, do func() (int, error)) error { - if err := ac.ensureKey(ctx); err != nil { - return err - } - status, err := do() - if status == http.StatusUnauthorized || status == http.StatusForbidden { - if rErr := ac.refreshKey(ctx); rErr != nil { - return errors.Errorf("3f api: re-auth after %d: %w", status, rErr) - } - return wrapAttempt(do()) + return &apiClient{ + c: threef.NewAPIClient(cfg), + sgnr: sgnr, + chainID: chainID, + log: log, } - return err } -// wrapAttempt collapses a (status, err) attempt into a single error (status is irrelevant once the -// retry has run — the error, if any, is what the caller cares about). -func wrapAttempt(_ int, err error) error { return err } - -// listAuctions returns the current auctions, each carrying its EIP-712 domain (needed for signing). -// No auth needed here. +// listAuctions returns the current auctions, each carrying its EIP-712 domain (needed for signing); no auth required. func (ac *apiClient) listAuctions(ctx context.Context) ([]threef.AuctionDto, error) { auctions, httpResp, err := ac.c.AuctionAPI.AuctionControllerListV1(ctx).Domain(true).Execute() closeResp(httpResp) if err != nil { - return nil, errors.Errorf("3f api: list auctions: %s: %w", statusOf(httpResp), err) + return nil, apiErr("list auctions", httpResp, err) } return auctions, nil } // createOffer submits a signed offer. func (ac *apiClient) createOffer(ctx context.Context, dto threef.CreateOfferDto) error { - err := ac.withAuth(ctx, func() (int, error) { - _, httpResp, e := ac.c.OfferAPI.OfferControllerCreateV1(ctx). - XApiKey(ac.apiKey).CreateOfferDto(dto).Execute() - closeResp(httpResp) - if e != nil { - return statusCode(httpResp), errors.Errorf("3f api: create offer: %s: %w", statusOf(httpResp), e) - } - return statusCode(httpResp), nil - }) - if err != nil { - return errors.Errorf("3f api: create offer: %w", err) + _, httpResp, e := ac.c.OfferAPI.OfferControllerCreateV1(ctx).CreateOfferDto(dto).Execute() + closeResp(httpResp) + if e != nil { + return apiErr("create offer", httpResp, e) } return nil } -// listOffers returns the facilitator's offers. Used at startup to rebuild the offer-dedup cache so a -// restart doesn't re-offer on auctions we already have live offers for. -// -// On the x-api-key path the API requires `maker` to be the facilitator's own broker address (not the -// adapter); it then returns offers under that address AND under the facilitator's configured -// offer-address — which is our adapter (see ensureOfferAddress). So querying by the facilitator -// surfaces our adapter's offers. (Querying maker=adapter here returns 403: that scope needs an -// EIP-712 GetOffers signature instead of the api key.) -func (ac *apiClient) listOffers(ctx context.Context) ([]threef.OfferDto, error) { - makerLower := lowerAddr(ac.facilitator) - var offers []threef.OfferDto - err := ac.withAuth(ctx, func() (int, error) { - o, httpResp, e := ac.c.OfferAPI.OfferControllerGetV1(ctx). - Maker(makerLower).XApiKey(ac.apiKey).Execute() - closeResp(httpResp) - if e != nil { - return statusCode(httpResp), errors.Errorf("3f api: list offers: %s: %w", statusOf(httpResp), e) - } - offers = o - return statusCode(httpResp), nil - }) - if err != nil { - return nil, errors.Errorf("3f api: list offers: %w", err) - } - return offers, nil -} - -// offerAddress returns the facilitator's currently-registered offer (maker) address, or the zero -// address if none is set. -func (ac *apiClient) offerAddress(ctx context.Context) (common.Address, error) { - var addr common.Address - err := ac.withAuth(ctx, func() (int, error) { - resp, httpResp, e := ac.c.FacilitatorAPI.AdminControllerGetFacilitatorOfferAddressV1(ctx). - XApiKey(ac.apiKey).Execute() - closeResp(httpResp) - if e != nil { - return statusCode(httpResp), errors.Errorf("3f api: get offer-address: %s: %w", statusOf(httpResp), e) - } - if s, ok := resp.GetOfferAddressOk(); ok && s != nil && common.IsHexAddress(*s) { - addr = common.HexToAddress(*s) - } - return statusCode(httpResp), nil - }) - if err != nil { - return common.Address{}, errors.Errorf("3f api: get offer-address: %w", err) - } - return addr, nil -} - -// setOfferAddress registers `addr` as the facilitator's offer (maker) address. -func (ac *apiClient) setOfferAddress(ctx context.Context, addr common.Address) error { - dto := *threef.NewSetFacilitatorOfferAddressDto(lowerAddr(addr)) - err := ac.withAuth(ctx, func() (int, error) { - _, httpResp, e := ac.c.FacilitatorAPI.AdminControllerSetFacilitatorOfferAddressV1(ctx). - XApiKey(ac.apiKey).SetFacilitatorOfferAddressDto(dto).Execute() - closeResp(httpResp) - if e != nil { - return statusCode(httpResp), errors.Errorf("3f api: set offer-address: %s: %w", statusOf(httpResp), e) - } - return statusCode(httpResp), nil - }) +// listOffers returns the adapter's outstanding offers. Authenticated via a per-adapter EIP-712 +// GetOffers signature in the Authorization: Bearer header — no API key required. +func (ac *apiClient) listOffers(ctx context.Context, adapter common.Address) ([]threef.OfferDto, error) { + deadline := big.NewInt(time.Now().Add(getOffersDeadlineWindow).Unix()) + sig, err := ac.sgnr.SignHash(GetOffersDigest(adapter, deadline, ac.chainID)) if err != nil { - return errors.Errorf("3f api: set offer-address: %w", err) + return nil, errors.Errorf("3f api: sign GetOffers: %w", err) + } + o, httpResp, e := ac.c.OfferAPI.OfferControllerGetV1(ctx). + Maker(lowerAddr(adapter)). + // chainId is the operating chain; the server rebuilds the grunt-api signing domain from it to + // verify the signature and routes the EIP-1271 check to that chain. + ChainId(float32(ac.chainID.Int64())). + Deadline(deadline.String()). + Authorization("Bearer 0x" + common.Bytes2Hex(sig)). + Execute() + closeResp(httpResp) + if e != nil { + return nil, apiErr("list offers", httpResp, e) } - return nil + return o, nil } -// closeResp closes the HTTP response body. The generated client already reads the body fully and -// closes it inside Execute, so this is a harmless no-op that satisfies the "body must be closed" -// contract without a lint suppression (bodyclose can't see across the Execute call boundary). +// closeResp closes the response body. The generated client already closes it inside Execute; this +// satisfies bodyclose, which can't see across that call boundary. func closeResp(resp *http.Response) { if resp != nil && resp.Body != nil { _ = resp.Body.Close() } } -// statusCode returns the HTTP status code of resp, or 0 if resp is nil (e.g. a transport error -// before any response). The auth-retry logic keys off this, so a nil response must not look like -// a 401/403. -func statusCode(resp *http.Response) int { - if resp == nil { - return 0 +// apiErr wraps a failed 3F call with its HTTP status and the server's response body — the client's own +// error is only the status line, but 3F returns the validation detail in the body. +func apiErr(what string, resp *http.Response, err error) error { + var genErr *threef.GenericOpenAPIError + if errors.As(err, &genErr) { + if body := strings.TrimSpace(string(genErr.Body())); body != "" { + return errors.Errorf("3f api: %s: %s: %s: %w", what, statusOf(resp), body, err) + } } - return resp.StatusCode + return errors.Errorf("3f api: %s: %s: %w", what, statusOf(resp), err) } // statusOf renders an HTTP response's status for error context ("no response" if there was none). diff --git a/internal/solvers/bridgefacilitator/apiclient_test.go b/internal/solvers/bridgefacilitator/apiclient_test.go new file mode 100644 index 00000000..cc77764c --- /dev/null +++ b/internal/solvers/bridgefacilitator/apiclient_test.go @@ -0,0 +1,52 @@ +package bridgefacilitator + +import ( + "context" + "math/big" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/go-logr/logr" +) + +// fakeSigner is a minimal signer.Signer test double that signs nothing meaningful (65 zero bytes). +type fakeSigner struct{} + +func (fakeSigner) Address() common.Address { return common.Address{} } +func (fakeSigner) SignHash(_ common.Hash) ([]byte, error) { + return make([]byte, 65), nil +} +func (fakeSigner) SignTx(tx *types.Transaction, _ *big.Int) (*types.Transaction, error) { + return tx, nil +} + +func TestAPIClient_ListOffers_SignedPerAdapter(t *testing.T) { + var gotMaker, gotAuth, gotKey, gotDeadline, gotChainID string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMaker = r.URL.Query().Get("maker") + gotDeadline = r.URL.Query().Get("deadline") + gotChainID = r.URL.Query().Get("chainId") + gotAuth = r.Header.Get("Authorization") + gotKey = r.Header.Get("x-api-key") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + adapter := common.HexToAddress("0x0000000000000000000000000000000000000042") + ac := newAPIClient(srv.URL, fakeSigner{}, big.NewInt(11155111), 5*time.Second, logr.Discard()) + if _, err := ac.listOffers(context.Background(), adapter); err != nil { + t.Fatalf("listOffers: %v", err) + } + chainID, _ := strconv.ParseFloat(gotChainID, 64) // generated client serializes chainId as a float + if gotMaker != lowerAddr(adapter) || gotDeadline == "" || chainID != 11155111 || + !strings.HasPrefix(gotAuth, "Bearer 0x") || gotKey != "" { + t.Fatalf("maker=%q chainId=%q deadline=%q auth=%q key=%q", gotMaker, gotChainID, gotDeadline, gotAuth, gotKey) + } +} diff --git a/internal/solvers/bridgefacilitator/auctionview.go b/internal/solvers/bridgefacilitator/auctionview.go index 2f4e2524..3022b3a6 100644 --- a/internal/solvers/bridgefacilitator/auctionview.go +++ b/internal/solvers/bridgefacilitator/auctionview.go @@ -14,19 +14,6 @@ type auctionView struct { dto threef.AuctionDto } -// matchesAsset reports whether the auction's deposit asset (the stablecoin lent in the auction) -// equals `want` — the funding vault's collateral. This is the link between a 3F auction and a -// Symbiotic vault/adapter: the auction's `vault` is the 3F position manager, not the Symbiotic -// vault, so assets (not vault addresses) are what pair them. The adapter also enforces this on-chain -// (AssetMismatch), so this is the off-chain pre-filter. -func (a auctionView) matchesAsset(want common.Address) bool { - addr := a.depositAsset() - if !common.IsHexAddress(addr) { - return false - } - return common.HexToAddress(addr) == want -} - // depositAsset returns the auction's deposit-asset address string for logging ("" if absent). func (a auctionView) depositAsset() string { da, ok := a.dto.GetDepositAssetOk() @@ -52,14 +39,15 @@ func (a auctionView) requestAddr() common.Address { return common.HexToAddress(a.dto.RequestId) } -// maxRate returns the auction's current max rate (basis points) as a float64, or 0 if the API -// didn't resolve it (for logging only). -func (a auctionView) maxRate() float64 { +// maxRateBps returns the auction's current max rate (basis points) and whether the API resolved it. +// It prices every offer and gates the per-adapter return floor, so an unresolved rate means we can't +// bid on the auction at all. +func (a auctionView) maxRateBps() (float64, bool) { r, ok := a.dto.GetMaxRateOk() if !ok || r == nil { - return 0 + return 0, false } - return float64(*r) + return float64(*r), true } // amountRequested returns the requested principal, or nil if the API didn't resolve it. diff --git a/internal/solvers/bridgefacilitator/chainreader.go b/internal/solvers/bridgefacilitator/chainreader.go index dc92e2fa..6f6917de 100644 --- a/internal/solvers/bridgefacilitator/chainreader.go +++ b/internal/solvers/bridgefacilitator/chainreader.go @@ -2,9 +2,7 @@ package bridgefacilitator import ( "context" - "math" "math/big" - "sync" "github.com/go-errors/errors" @@ -12,139 +10,140 @@ import ( "github.com/symbioticfi/vault-solver/api/bindings/3f/adapter" "github.com/symbioticfi/vault-solver/api/bindings/3f/vaultcontroller" - "github.com/symbioticfi/vault-solver/api/bindings/delegator" "github.com/symbioticfi/vault-solver/api/bindings/erc4626" - "github.com/symbioticfi/vault-solver/api/bindings/vaultv2" "github.com/symbioticfi/vault-solver/internal/chain" ) // Contract bindings (abigen --v2): typed Pack/Unpack helpers for the Multicall3 sub-calls below, so an // ABI change fails at compile time (see CLAUDE.md "Code generation"). // -// In the redesigned core-mirror model the funding cap and asset live on different contracts than the -// vault: the collateral token is read via IERC4626(vault).asset(), and the per-adapter cap via -// UniversalDelegator(vault.delegator()).limitOf(adapter). vaultV2 is therefore only used for the -// vault.delegator() and vault.withdrawable() lookups. +// The ThreeFAdapter computes its own JIT-funding headroom on-chain via getMaxAssets() (it folds in the +// delegator's per-adapter limitOf, the vault's withdrawable liquidity, and any pending sweep), so the bot +// no longer reads the delegator/vault directly for sizing. The collateral token is still read once at +// startup via IERC4626(vault).asset() to match auctions. var ( - bfAdapter = adapter.NewBridgeFacilitatorAdapter() - vaultV2 = vaultv2.NewIVaultV2() + bfAdapter = adapter.NewThreeFAdapter() vc = vaultcontroller.NewIVaultController() erc4626b = erc4626.NewIERC4626() - deleg = delegator.NewUniversalDelegator() ) +// maxRequests mirrors MAX_REQUESTS in IThreeFAdapter — the adapter rejects a new request once it tracks +// this many. It is the bot's concurrency pre-screen cap and the clamp bound for the on-chain +// requestsLength() count. (50 is a compile-time constant, immutable per deployment, so it is mirrored +// here rather than read.) +const maxRequests = 50 + // reader performs the adapter- and Request-side on-chain reads the solver relies on, batching via // Multicall3 where calls are independent. type reader struct { chain *chain.Client - - // delegatorMu guards the per-vault delegator-address cache. Reader methods are invoked serially - // from the solver's single ticker loop, but the cache is guarded anyway so it stays correct if - // that ever changes. - delegatorMu sync.Mutex - delegatorCache map[common.Address]common.Address } func newReader(c *chain.Client) *reader { - return &reader{chain: c, delegatorCache: make(map[common.Address]common.Address)} + return &reader{chain: c} } -// adapterVault returns the vault the adapter funds (bound once at adapter initialize), so config -// carries only the adapter address and the bot derives the vault at startup. -func (r *reader) adapterVault(ctx context.Context, adapterAddr common.Address) (common.Address, error) { - res, err := r.chain.Multicall(ctx, []chain.Call{{Target: adapterAddr, Data: bfAdapter.PackVault()}}) - if err != nil { - return common.Address{}, err - } - if len(res) != 1 || !res[0].Success { - return common.Address{}, errors.New("adapter.vault() reverted") - } - return bfAdapter.UnpackVault(res[0].ReturnData) +// resolvedAdapter is one adapter's startup resolution: its vault, that vault's collateral (the +// ERC-4626 asset, used to match auctions), and its EIP-1271 offer-signer. err is set (other fields +// zero) if any read reverted, so the caller can drop just that adapter. +type resolvedAdapter struct { + vault common.Address + collateral common.Address + signer common.Address + err error } -// vaultAsset returns the vault's collateral token, used to match auctions (by deposit asset) to this -// funding vault. In the core-mirror VaultV2 the deposit/collateral token is the ERC-4626 asset, so -// this reads IERC4626(vault).asset() (the old vault.collateral() no longer exists). -func (r *reader) vaultAsset(ctx context.Context, vault common.Address) (common.Address, error) { - res, err := r.chain.Multicall(ctx, []chain.Call{{Target: vault, Data: erc4626b.PackAsset()}}) - if err != nil { - return common.Address{}, err +// decodeAddr returns the address a Multicall sub-call returned, or an error tagged with `what` if it +// reverted or failed to decode. +func decodeAddr(res chain.CallResult, unpack func([]byte) (common.Address, error), what string) (common.Address, error) { + if !res.Success { + return common.Address{}, errors.Errorf("%s reverted", what) } - if len(res) != 1 || !res[0].Success { - return common.Address{}, errors.New("vault.asset() reverted") + addr, err := unpack(res.ReturnData) + if err != nil { + return common.Address{}, errors.Errorf("decode %s: %w", what, err) } - return erc4626b.UnpackAsset(res[0].ReturnData) + return addr, nil } -// vaultDelegator resolves the vault's delegator address (the contract that holds the per-adapter -// allocation caps via limitOf). It is read once per vault and cached: a Multicall can't feed one -// call's result into another, so liquidityAndExposure needs the delegator address up front, and the -// vault's delegator is effectively fixed for the bot's lifetime. A cache miss falls through to a -// single eth_call. -func (r *reader) vaultDelegator(ctx context.Context, vault common.Address) (common.Address, error) { - r.delegatorMu.Lock() - if d, ok := r.delegatorCache[vault]; ok { - r.delegatorMu.Unlock() - return d, nil - } - r.delegatorMu.Unlock() +// resolveAdapters resolves every adapter's vault, collateral, and offer-signer in two Multicalls +// regardless of adapter count: round 1 batches each adapter's vault()+offerSigner(); round 2 batches +// asset() on the vaults round 1 returned. Per-call AllowFailure isolates a bad adapter to its own err; +// a returned error is a whole-batch RPC failure. +func (r *reader) resolveAdapters(ctx context.Context, adapters []common.Address) ([]resolvedAdapter, error) { + out := make([]resolvedAdapter, len(adapters)) - res, err := r.chain.Multicall(ctx, []chain.Call{{Target: vault, Data: vaultV2.PackDelegator()}}) + calls := make([]chain.Call, 0, 2*len(adapters)) + for _, a := range adapters { + calls = append(calls, + chain.Call{Target: a, Data: bfAdapter.PackVault(), AllowFailure: true}, + chain.Call{Target: a, Data: bfAdapter.PackOfferSigner(), AllowFailure: true}, + ) + } + res, err := r.chain.Multicall(ctx, calls) if err != nil { - return common.Address{}, err + return nil, err } - if len(res) != 1 || !res[0].Success { - return common.Address{}, errors.New("vault.delegator() reverted") + + // Decode round 1; queue an asset() call for each adapter whose vault and signer both resolved. + assetCalls := make([]chain.Call, 0, len(adapters)) + assetIdx := make([]int, 0, len(adapters)) // assetIdx[k] = out index of assetCalls[k] + for i := range adapters { + vault, derr := decodeAddr(res[2*i], bfAdapter.UnpackVault, "adapter.vault()") + if derr != nil { + out[i].err = derr + continue + } + signer, derr := decodeAddr(res[2*i+1], bfAdapter.UnpackOfferSigner, "adapter.offerSigner()") + if derr != nil { + out[i].err = derr + continue + } + out[i].vault, out[i].signer = vault, signer + assetCalls = append(assetCalls, chain.Call{Target: vault, Data: erc4626b.PackAsset(), AllowFailure: true}) + assetIdx = append(assetIdx, i) } - d, err := vaultV2.UnpackDelegator(res[0].ReturnData) - if err != nil { - return common.Address{}, err + if len(assetCalls) == 0 { + return out, nil } - r.delegatorMu.Lock() - r.delegatorCache[vault] = d - r.delegatorMu.Unlock() - return d, nil + ares, err := r.chain.Multicall(ctx, assetCalls) + if err != nil { + return nil, err + } + for k, idx := range assetIdx { + collateral, derr := decodeAddr(ares[k], erc4626b.UnpackAsset, "vault.asset()") + if derr != nil { + out[idx].err = derr + continue + } + out[idx].collateral = collateral + } + return out, nil } -// exposureState bundles the per-target liquidity and the adapter's exposure caps the offer sizer needs. -// The four caps are the adapter's authoritative on-chain risk limits (setExposureLimits, each 0 = -// disabled); the bot reads them to pre-screen offers before the contract enforces them at consume time. +// exposureState is the per-target funding headroom and per-request caps (setLimitsPerRequest) the sizer +// pre-screens against before the contract enforces them at consume time. type exposureState struct { - fundable *big.Int // max(min(limitOf - totalAssets, vault.withdrawable), 0) - outstanding *big.Int // outstandingPrincipal (live sleeve exposure), clamped >= 0 - openCount int // len(activeRequests) - perRequestMax *big.Int // perRequestMaxCollateral (0 = no limit) - totalMax *big.Int // totalMaxCollateral (0 = no limit) - minYieldBps *big.Int // minRequestYieldBps (0 = no floor) - maxConcurrent int // maxConcurrentLoans (0 = no limit; an unrepresentable value is treated as none) + fundable *big.Int // getMaxAssets(): min(limitOf - totalAssets, vault.withdrawable), 0 if a sweep is pending + openCount int // active request count (requests[] length) + maxAssets *big.Int // maxAssetsPerRequest — always-active ceiling (0 = reject-all) + minAssets *big.Int // minAssetsPerRequest (0 = no floor) + minYieldBps *big.Int // minYieldPerRequest (ppm) → bps (0 = no floor) } -// liquidityAndExposure reads the JIT-funding headroom plus the adapter's exposure caps in one multicall. -// Funding is just-in-time: at consume time the adapter pulls the principal via the delegator's -// allocateExact, which can raise at most the vault's withdrawable liquidity. So fundable is bounded by -// BOTH the per-adapter cap headroom AND vault.withdrawable() — otherwise the bot could sign an offer the -// JIT pull can't satisfy and the consume would revert (mirrors LiquidLane's getMaxAssets). A Multicall -// can't chain one call's result into another, so the delegator address is resolved first (cached; see -// vaultDelegator), then everything is read in a single batched multicall. -func (r *reader) liquidityAndExposure( - ctx context.Context, vault, adapterAddr common.Address, -) (exposureState, error) { - delegatorAddr, err := r.vaultDelegator(ctx, vault) - if err != nil { - return exposureState{}, errors.Errorf("resolve vault delegator: %w", err) - } - +// liquidityAndExposure reads the adapter's JIT-funding headroom (getMaxAssets), its per-request caps, and +// its active-request count in a single multicall. getMaxAssets() is authoritative for funding: it already +// bounds the headroom by both the delegator's per-adapter cap AND the vault's withdrawable liquidity, so +// the bot can't sign an offer the JIT pull at consume time can't satisfy. openCount is the adapter's own +// requestsLength() (a single read) feeding the concurrency pre-screen. +func (r *reader) liquidityAndExposure(ctx context.Context, adapterAddr common.Address) (exposureState, error) { calls := []chain.Call{ - {Target: delegatorAddr, Data: deleg.PackLimitOf(adapterAddr)}, - {Target: adapterAddr, Data: bfAdapter.PackTotalAssets()}, - {Target: adapterAddr, Data: bfAdapter.PackOutstandingPrincipal()}, - {Target: adapterAddr, Data: bfAdapter.PackActiveRequests()}, - {Target: vault, Data: vaultV2.PackWithdrawable()}, - {Target: adapterAddr, Data: bfAdapter.PackPerRequestMaxCollateral()}, - {Target: adapterAddr, Data: bfAdapter.PackTotalMaxCollateral()}, - {Target: adapterAddr, Data: bfAdapter.PackMinRequestYieldBps()}, - {Target: adapterAddr, Data: bfAdapter.PackMaxConcurrentLoans()}, + {Target: adapterAddr, Data: bfAdapter.PackGetMaxAssets()}, + {Target: adapterAddr, Data: bfAdapter.PackMinYieldPerRequest()}, + {Target: adapterAddr, Data: bfAdapter.PackMinAssetsPerRequest()}, + {Target: adapterAddr, Data: bfAdapter.PackMaxAssetsPerRequest()}, + {Target: adapterAddr, Data: bfAdapter.PackRequestsLength()}, } res, err := r.chain.Multicall(ctx, calls) if err != nil { @@ -159,100 +158,106 @@ func (r *reader) liquidityAndExposure( } } - limit, err := deleg.UnpackLimitOf(res[0].ReturnData) + fundable, err := bfAdapter.UnpackGetMaxAssets(res[0].ReturnData) if err != nil { return exposureState{}, err } - held, err := bfAdapter.UnpackTotalAssets(res[1].ReturnData) + minYield, err := bfAdapter.UnpackMinYieldPerRequest(res[1].ReturnData) if err != nil { return exposureState{}, err } - outstandingPrincipal, err := bfAdapter.UnpackOutstandingPrincipal(res[2].ReturnData) + minAssets, err := bfAdapter.UnpackMinAssetsPerRequest(res[2].ReturnData) if err != nil { return exposureState{}, err } - reqs, err := bfAdapter.UnpackActiveRequests(res[3].ReturnData) + maxAssets, err := bfAdapter.UnpackMaxAssetsPerRequest(res[3].ReturnData) if err != nil { return exposureState{}, err } - withdrawable, err := vaultV2.UnpackWithdrawable(res[4].ReturnData) - if err != nil { - return exposureState{}, err - } - perRequestMax, err := bfAdapter.UnpackPerRequestMaxCollateral(res[5].ReturnData) - if err != nil { - return exposureState{}, err - } - totalMax, err := bfAdapter.UnpackTotalMaxCollateral(res[6].ReturnData) - if err != nil { - return exposureState{}, err - } - minYieldBps, err := bfAdapter.UnpackMinRequestYieldBps(res[7].ReturnData) - if err != nil { - return exposureState{}, err - } - maxConcurrent, err := bfAdapter.UnpackMaxConcurrentLoans(res[8].ReturnData) + openCount, err := bfAdapter.UnpackRequestsLength(res[4].ReturnData) if err != nil { return exposureState{}, err } - fundable, outstanding := deriveLiquidity(limit, held, outstandingPrincipal, withdrawable) return exposureState{ - fundable: fundable, - outstanding: outstanding, - openCount: len(reqs), - perRequestMax: perRequestMax, - totalMax: totalMax, - minYieldBps: minYieldBps, - maxConcurrent: loanCount(maxConcurrent), + fundable: fundable, + openCount: clampCount(openCount), + maxAssets: maxAssets, + minAssets: minAssets, + minYieldBps: ppmToBps(minYield), }, nil } -// loanCount converts the on-chain maxConcurrentLoans uint256 to the sizer's int. 0 (disabled) and any -// value too large to represent both mean "no concurrency limit". -func loanCount(n *big.Int) int { +// clampCount converts the on-chain requestsLength (uint256, bounded by MAX_REQUESTS) to an int. A value +// that doesn't fit is clamped to maxRequests so the concurrency pre-screen fails closed. +func clampCount(n *big.Int) int { if n.IsInt64() { - if v := n.Int64(); v > 0 && v <= math.MaxInt32 { + if v := n.Int64(); v >= 0 && v <= int64(maxRequests) { return int(v) } } - return 0 + return maxRequests } -// deriveLiquidity reduces the raw on-chain reads to the sizer's inputs. Split out as a pure helper so -// the clamping is unit-testable without a chain backend: -// -// fundable = max(min(limit - held, withdrawable), 0) // cap headroom AND vault JIT-pull liquidity -// outstanding = max(outstandingPrincipal, 0) -func deriveLiquidity(limit, held, outstandingPrincipal, withdrawable *big.Int) (fundable, outstanding *big.Int) { - fundable = new(big.Int).Sub(limit, held) - if fundable.Cmp(withdrawable) > 0 { - fundable = new(big.Int).Set(withdrawable) - } - if fundable.Sign() < 0 { - fundable.SetInt64(0) - } - outstanding = new(big.Int).Set(outstandingPrincipal) - if outstanding.Sign() < 0 { - outstanding.SetInt64(0) +// ppmToBps converts minYieldPerRequest (ppm, YIELD_PRECISION=1e6) to bps, rounding up so the bot never +// bids below the on-chain floor. +func ppmToBps(ppm *big.Int) *big.Int { + return new(big.Int).Div(new(big.Int).Add(ppm, big.NewInt(99)), big.NewInt(100)) // ceil(ppm/100); 1 bps = 100 ppm +} + +// requestSlotCalls builds the requests(i) reads for i in [0, n) — n from requestsLength(). AllowFailure: +// a concurrent finalize can shrink the array between the length read and these, so a tail index may +// revert; collectRequests stops at that gap. +func requestSlotCalls(adapterAddr common.Address, n int) []chain.Call { + calls := make([]chain.Call, n) + for i := range calls { + calls[i] = chain.Call{Target: adapterAddr, AllowFailure: true, Data: bfAdapter.PackRequests(big.NewInt(int64(i)))} + } + return calls +} + +// collectRequests decodes the leading run of successful requests(i) results into request addresses. +// finalizeRequest keeps the array dense (swap-pop), so the first reverted/undecodable slot ends the set. +func collectRequests(res []chain.CallResult) []common.Address { + out := make([]common.Address, 0, len(res)) + for _, rr := range res { + if !rr.Success { + break + } + addr, err := bfAdapter.UnpackRequests(rr.ReturnData) + if err != nil { + break + } + out = append(out, addr) } - return fundable, outstanding + return out } -// readyToRedeem returns the adapter's active Requests that are currently redeemable. It reads the -// active set (one call) then batches every canWithdraw() into a single multicall. +// readyToRedeem returns the adapter's active Requests that are currently redeemable. It reads +// requestsLength(), enumerates exactly that many requests(i), then batches every canWithdraw() into a +// single multicall. func (r *reader) readyToRedeem(ctx context.Context, adapterAddr common.Address) ([]common.Address, error) { - areq, err := r.chain.Multicall(ctx, []chain.Call{{Target: adapterAddr, Data: bfAdapter.PackActiveRequests()}}) + lres, err := r.chain.Multicall(ctx, []chain.Call{{Target: adapterAddr, Data: bfAdapter.PackRequestsLength()}}) if err != nil { return nil, err } - if len(areq) != 1 || !areq[0].Success { - return nil, errors.New("adapter.activeRequests() reverted") + if len(lres) != 1 || !lres[0].Success { + return nil, errors.New("adapter.requestsLength() reverted") } - reqs, err := bfAdapter.UnpackActiveRequests(areq[0].ReturnData) + n, err := bfAdapter.UnpackRequestsLength(lres[0].ReturnData) if err != nil { - return nil, errors.Errorf("adapter.activeRequests(): %w", err) + return nil, errors.Errorf("adapter.requestsLength(): %w", err) } + count := clampCount(n) + if count == 0 { + return nil, nil + } + + res, err := r.chain.Multicall(ctx, requestSlotCalls(adapterAddr, count)) + if err != nil { + return nil, err + } + reqs := collectRequests(res) if len(reqs) == 0 { return nil, nil } @@ -262,7 +267,7 @@ func (r *reader) readyToRedeem(ctx context.Context, adapterAddr common.Address) // AllowFailure: a single malformed Request must not break the whole batch. calls[i] = chain.Call{Target: req, AllowFailure: true, Data: vc.PackCanWithdraw()} } - res, err := r.chain.Multicall(ctx, calls) + res, err = r.chain.Multicall(ctx, calls) if err != nil { return nil, err } diff --git a/internal/solvers/bridgefacilitator/chainreader_test.go b/internal/solvers/bridgefacilitator/chainreader_test.go index 3edc6ced..a8bc2c6e 100644 --- a/internal/solvers/bridgefacilitator/chainreader_test.go +++ b/internal/solvers/bridgefacilitator/chainreader_test.go @@ -1,121 +1,220 @@ package bridgefacilitator import ( + "context" + "encoding/json" + "fmt" "math/big" + "net/http" + "net/http/httptest" + "sync/atomic" "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/chain" ) -// TestDeriveLiquidity covers the pure reduction of the core-mirror on-chain reads -// (delegator.limitOf, adapter.totalAssets, adapter.outstandingPrincipal, vault.withdrawable) to the -// sizer's inputs: -// -// fundable = max(min(limit - held, withdrawable), 0) -// outstanding = max(outstandingPrincipal, 0) -func TestDeriveLiquidity(t *testing.T) { +// TestCollectRequests covers the enumeration-prefix logic: the adapter's requests[] is dense (kept so +// by finalizeRequest's swap-pop), and indices past the end revert, so collectRequests must take the +// leading run of decodable successes and stop at the first gap. +func TestCollectRequests(t *testing.T) { t.Parallel() - bn := big.NewInt - huge := bn(1_000_000) // vault liquidity not the binding constraint + a0 := common.HexToAddress("0x00000000000000000000000000000000000000A0") + a1 := common.HexToAddress("0x00000000000000000000000000000000000000A1") + a2 := common.HexToAddress("0x00000000000000000000000000000000000000A2") + ok := func(addr common.Address) chain.CallResult { + return chain.CallResult{Success: true, ReturnData: abiEncodeAddress(t, addr)} + } + fail := chain.CallResult{Success: false} + bad := chain.CallResult{Success: true, ReturnData: []byte{0x01}} // undecodable as an address tests := []struct { - name string - limit, held, outstandingPrincipal, withdrawable *big.Int - wantFundable, wantOutstanding *big.Int + name string + res []chain.CallResult + want []common.Address }{ - { - name: "room available (cap binds)", - limit: bn(1000), - held: bn(400), - outstandingPrincipal: bn(350), - withdrawable: huge, - wantFundable: bn(600), - wantOutstanding: bn(350), - }, - { - name: "vault liquidity binds below cap headroom", - limit: bn(1000), - held: bn(400), - outstandingPrincipal: bn(350), - withdrawable: bn(250), - wantFundable: bn(250), - wantOutstanding: bn(350), - }, - { - name: "dry vault clamps fundable to zero despite cap room", - limit: bn(1000), - held: bn(400), - outstandingPrincipal: bn(350), - withdrawable: bn(0), - wantFundable: bn(0), - wantOutstanding: bn(350), - }, - { - name: "cap fully consumed", - limit: bn(1000), - held: bn(1000), - outstandingPrincipal: bn(900), - withdrawable: huge, - wantFundable: bn(0), - wantOutstanding: bn(900), - }, - { - name: "held over cap clamps fundable to zero", - limit: bn(500), - held: bn(800), - outstandingPrincipal: bn(700), - withdrawable: huge, - wantFundable: bn(0), - wantOutstanding: bn(700), - }, - { - name: "negative outstanding clamps to zero", - limit: bn(1000), - held: bn(100), - outstandingPrincipal: bn(-5), - withdrawable: huge, - wantFundable: bn(900), - wantOutstanding: bn(0), - }, - { - name: "all zero", - limit: bn(0), - held: bn(0), - outstandingPrincipal: bn(0), - withdrawable: bn(0), - wantFundable: bn(0), - wantOutstanding: bn(0), - }, + {"empty", nil, nil}, + {"all active", []chain.CallResult{ok(a0), ok(a1), ok(a2)}, []common.Address{a0, a1, a2}}, + {"prefix then end-of-array gap", []chain.CallResult{ok(a0), ok(a1), fail, ok(a2)}, []common.Address{a0, a1}}, + {"first slot reverts", []chain.CallResult{fail, ok(a0)}, nil}, + {"undecodable slot ends the set", []chain.CallResult{ok(a0), bad, ok(a1)}, []common.Address{a0}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - gotFundable, gotOutstanding := deriveLiquidity(tc.limit, tc.held, tc.outstandingPrincipal, tc.withdrawable) - if gotFundable.Cmp(tc.wantFundable) != 0 { - t.Errorf("fundable = %s, want %s", gotFundable, tc.wantFundable) + got := collectRequests(tc.res) + if len(got) != len(tc.want) { + t.Fatalf("collectRequests = %v (len %d), want %v (len %d)", got, len(got), tc.want, len(tc.want)) } - if gotOutstanding.Cmp(tc.wantOutstanding) != 0 { - t.Errorf("outstanding = %s, want %s", gotOutstanding, tc.wantOutstanding) + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("[%d] = %s, want %s", i, got[i].Hex(), tc.want[i].Hex()) + } } }) } } -// TestDeriveLiquidityDoesNotMutateInputs guards against the clamp accidentally aliasing/mutating the -// caller's *big.Int values (deriveLiquidity must allocate its own results). -func TestDeriveLiquidityDoesNotMutateInputs(t *testing.T) { +// TestPpmToBps covers the ceil(ppm/100) conversion of minYieldPerRequest (ppm) to the bps the pre-screen +// compares against the auction maxRate — rounded up so the bot never bids below the on-chain floor. +func TestPpmToBps(t *testing.T) { + t.Parallel() + + tests := []struct { + ppm, want int64 + }{ + {0, 0}, {1, 1}, {99, 1}, {100, 1}, {150, 2}, {10_000, 100}, {1_000_000, 10_000}, + } + for _, tc := range tests { + if got := ppmToBps(big.NewInt(tc.ppm)).Int64(); got != tc.want { + t.Errorf("ppmToBps(%d) = %d, want %d", tc.ppm, got, tc.want) + } + } +} + +// newMulticallFakeClient returns a chain.Client backed by a minimal JSON-RPC httptest server. +// The server responds to eth_chainId and eth_call; ethCallReplies are the hex-encoded bytes returned +// by successive eth_call requests (i.e. each ABI-encoded Multicall3.aggregate3 Result[] array). With +// one reply it serves that every call; with several it serves them in order (e.g. round 1 then round +// 2 of resolveAdapters), sticking on the last once exhausted. +func newMulticallFakeClient(t *testing.T, ethCallReplies ...[]byte) (*chain.Client, func()) { + t.Helper() + multicallAddr := common.HexToAddress("0x0000000000000000000000000000000000000001") + var ethCallN atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID any `json:"id"` + Method string `json:"method"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + switch req.Method { + case "eth_chainId": + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%v,"result":"0x1"}`, marshalID(req.ID)) + case "eth_call": + i := int(ethCallN.Add(1)) - 1 + if i >= len(ethCallReplies) { + i = len(ethCallReplies) - 1 + } + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%v,"result":"0x%x"}`, marshalID(req.ID), ethCallReplies[i]) + default: + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%v,"error":{"code":-32601,"message":"method not found"}}`, marshalID(req.ID)) + } + })) + + c, err := chain.Dial(t.Context(), []string{srv.URL}, "", multicallAddr.Hex(), logr.Discard()) + if err != nil { + srv.Close() + t.Fatalf("chain.Dial: %v", err) + } + return c, srv.Close +} + +// marshalID renders a JSON-RPC request id (number or string) back to JSON so we can embed it in the +// response without re-encoding quotes. json.Marshal on an any holding a json.Number or string is +// always safe; if it somehow fails we fall back to a literal null which keeps the server response +// well-formed for the client. +func marshalID(id any) string { + b, err := json.Marshal(id) + if err != nil { + return "null" + } + return string(b) +} + +// abiEncodeAggregate3Results ABI-encodes a Multicall3.aggregate3 return value: one Result per inner +// payload, each Success=true with ReturnData=inner. This is the hex payload eth_call returns for a +// successful aggregate3 with len(inners) sub-call results. +func abiEncodeAggregate3Results(t *testing.T, inners ...[]byte) []byte { + t.Helper() + // aggregate3 returns (Result[] returnData) where Result = (bool success, bytes returnData). + resultTuple, err := abi.NewType("tuple[]", "", []abi.ArgumentMarshaling{ + {Name: "success", Type: "bool"}, + {Name: "returnData", Type: "bytes"}, + }) + if err != nil { + t.Fatalf("abi.NewType tuple[]: %v", err) + } + type result struct { + Success bool + ReturnData []byte + } + results := make([]result, len(inners)) + for i, inner := range inners { + results[i] = result{Success: true, ReturnData: inner} + } + encoded, err := abi.Arguments{{Type: resultTuple}}.Pack(results) + if err != nil { + t.Fatalf("abi args.Pack: %v", err) + } + return encoded +} + +// abiEncodeAddress ABI-encodes a single address as a 32-byte left-padded word (the raw returnData +// for a Solidity function returning address). +func abiEncodeAddress(t *testing.T, addr common.Address) []byte { + t.Helper() + addrType, err := abi.NewType("address", "", nil) + if err != nil { + t.Fatalf("abi.NewType address: %v", err) + } + enc, err := abi.Arguments{{Type: addrType}}.Pack(addr) + if err != nil { + t.Fatalf("abi address Pack: %v", err) + } + return enc +} + +// TestResolveAdapters verifies the two-Multicall batch resolves each adapter's vault, signer, and +// collateral and maps them back by index: round 1 returns [vault0, signer0, vault1, signer1] and +// round 2 returns [asset0, asset1], so a layout off-by-one would cross adapters' fields. +func TestResolveAdapters(t *testing.T) { t.Parallel() - limit := big.NewInt(500) - held := big.NewInt(800) // held > limit, so fundable clamps to 0 - outstandingPrincipal := big.NewInt(-1) - withdrawable := big.NewInt(1000) + adapters := []common.Address{ + common.HexToAddress("0x00000000000000000000000000000000000000A0"), + common.HexToAddress("0x00000000000000000000000000000000000000A1"), + } + vault0 := common.HexToAddress("0x00000000000000000000000000000000000000B0") + signer0 := common.HexToAddress("0x00000000000000000000000000000000000000C0") + asset0 := common.HexToAddress("0x00000000000000000000000000000000000000D0") + vault1 := common.HexToAddress("0x00000000000000000000000000000000000000B1") + signer1 := common.HexToAddress("0x00000000000000000000000000000000000000C1") + asset1 := common.HexToAddress("0x00000000000000000000000000000000000000D1") + + round1 := abiEncodeAggregate3Results(t, + abiEncodeAddress(t, vault0), abiEncodeAddress(t, signer0), + abiEncodeAddress(t, vault1), abiEncodeAddress(t, signer1), + ) + round2 := abiEncodeAggregate3Results(t, abiEncodeAddress(t, asset0), abiEncodeAddress(t, asset1)) - _, _ = deriveLiquidity(limit, held, outstandingPrincipal, withdrawable) + c, stop := newMulticallFakeClient(t, round1, round2) + defer stop() - if limit.Cmp(big.NewInt(500)) != 0 || held.Cmp(big.NewInt(800)) != 0 || - outstandingPrincipal.Cmp(big.NewInt(-1)) != 0 || withdrawable.Cmp(big.NewInt(1000)) != 0 { - t.Fatalf("deriveLiquidity mutated its inputs: limit=%s held=%s outstanding=%s withdrawable=%s", - limit, held, outstandingPrincipal, withdrawable) + got, err := newReader(c).resolveAdapters(context.Background(), adapters) + if err != nil { + t.Fatalf("resolveAdapters: %v", err) + } + want := []resolvedAdapter{ + {vault: vault0, signer: signer0, collateral: asset0}, + {vault: vault1, signer: signer1, collateral: asset1}, + } + for i, w := range want { + if got[i].err != nil { + t.Fatalf("adapter %d: unexpected err %v", i, got[i].err) + } + if got[i].vault != w.vault || got[i].signer != w.signer || got[i].collateral != w.collateral { + t.Errorf("adapter %d = {vault:%s signer:%s collateral:%s}, want {vault:%s signer:%s collateral:%s}", + i, got[i].vault.Hex(), got[i].signer.Hex(), got[i].collateral.Hex(), + w.vault.Hex(), w.signer.Hex(), w.collateral.Hex()) + } } } diff --git a/internal/solvers/bridgefacilitator/config.go b/internal/solvers/bridgefacilitator/config.go index c8825354..3c8ad891 100644 --- a/internal/solvers/bridgefacilitator/config.go +++ b/internal/solvers/bridgefacilitator/config.go @@ -1,6 +1,7 @@ package bridgefacilitator import ( + "strconv" "time" "github.com/go-errors/errors" @@ -8,18 +9,23 @@ import ( "github.com/ethereum/go-ethereum/common" "gopkg.in/yaml.v3" + cfgparse "github.com/symbioticfi/vault-solver/internal/parse" "github.com/symbioticfi/vault-solver/internal/solver" ) -// rawConfig mirrors the YAML shape; strings are parsed into typed values in parse(). 3F registers -// exactly one offer-address per facilitator, so the bot serves a single vault+adapter pair. +// rawConfig mirrors the YAML shape; strings are parsed into typed values in parse(). type rawConfig struct { - APIBaseURL string `yaml:"apiBaseUrl"` - APIKeyEnv string `yaml:"apiKeyEnv"` - RedeemBatchSize int `yaml:"redeemBatchSize"` - Adapter string `yaml:"adapter"` - HTTPTimeout string `yaml:"httpTimeout"` - Intervals rawIntervals `yaml:"intervals"` + APIBaseURL string `yaml:"apiBaseUrl"` + RedeemBatchSize int `yaml:"redeemBatchSize"` + Adapters []string `yaml:"adapters"` + HTTPTimeout string `yaml:"httpTimeout"` + Intervals rawIntervals `yaml:"intervals"` + Strategy rawStrategyConfig `yaml:"strategy"` +} + +type rawStrategyConfig struct { + Name string `yaml:"name"` + Config yaml.Node `yaml:"config"` } type rawIntervals struct { @@ -31,22 +37,25 @@ type rawIntervals struct { // Config is the validated, typed solver configuration. type Config struct { APIBaseURL string - // APIKeyEnv is the env var holding a pre-generated 3F API key (sent as the x-api-key header). - APIKeyEnv string // RedeemBatchSize caps how many Requests are redeemed in a single redeem() call (gas bound). RedeemBatchSize int // HTTPTimeout bounds every 3F API call so a hung request can't stall the single solver loop // (including redemption scans). Applied as the 3F http.Client timeout. HTTPTimeout time.Duration - // Target is the single vault+adapter pair this facilitator serves. 3F allows exactly one - // offer-address per facilitator, so this solver is single-pair by construction. - Target Target + // Targets is the list of vault+adapter pairs this facilitator serves. + Targets []Target Intervals Intervals + Strategy StrategyConfig +} + +type StrategyConfig struct { + Name string + Config yaml.Node } -// Target is the adapter the bot facilitates. Only the adapter is config: Vault (adapter.vault()) and -// Collateral (vault.asset()) are resolved on-chain at startup (see Solver.resolveTarget) and fixed for -// the adapter's lifetime. Exposure/return caps also live on-chain (setExposureLimits), read each poll. +// Target is one adapter the bot facilitates. Only the adapter is config: Vault (adapter.vault()) and +// Collateral (vault.asset()) are resolved on-chain at startup (resolveTargets); per-request caps also +// live on-chain (setLimitsPerRequest), read each poll. type Target struct { Adapter common.Address // Auctions are matched to this target by their deposit asset equalling Collateral. @@ -74,6 +83,8 @@ const defaultRedeemBatchSize = 10 // defaultHTTPTimeout bounds each 3F API call when httpTimeout is unset. const defaultHTTPTimeout = 30 * time.Second +const defaultStrategyName = "default" + // parseConfig decodes and validates the opaque solver config block. func parseConfig(node yaml.Node) (*Config, error) { var raw rawConfig @@ -89,80 +100,55 @@ func parseConfig(node yaml.Node) (*Config, error) { redeemBatch = defaultRedeemBatchSize } - target, err := parseTarget(raw) + targets, err := parseTargets(raw) if err != nil { return nil, err } - discover, err := parseDuration(raw.Intervals.Discover, defaultDiscover, "intervals.discover") + discover, err := cfgparse.Duration(raw.Intervals.Discover, defaultDiscover, "intervals.discover") if err != nil { return nil, err } - redeemPoll, err := parseDuration(raw.Intervals.RedeemPoll, defaultRedeemPoll, "intervals.redeemPoll") + redeemPoll, err := cfgparse.Duration(raw.Intervals.RedeemPoll, defaultRedeemPoll, "intervals.redeemPoll") if err != nil { return nil, err } - reconcile, err := parseDuration(raw.Intervals.Reconcile, defaultReconcile, "intervals.reconcile") + reconcile, err := cfgparse.Duration(raw.Intervals.Reconcile, defaultReconcile, "intervals.reconcile") if err != nil { return nil, err } - httpTimeout, err := parseDuration(raw.HTTPTimeout, defaultHTTPTimeout, "httpTimeout") + httpTimeout, err := cfgparse.Duration(raw.HTTPTimeout, defaultHTTPTimeout, "httpTimeout") if err != nil { return nil, err } + strategy := StrategyConfig{Name: raw.Strategy.Name, Config: raw.Strategy.Config} + if strategy.Name == "" { + strategy.Name = defaultStrategyName + } + return &Config{ APIBaseURL: raw.APIBaseURL, - APIKeyEnv: raw.APIKeyEnv, RedeemBatchSize: redeemBatch, HTTPTimeout: httpTimeout, - Target: target, + Targets: targets, Intervals: Intervals{Discover: discover, RedeemPoll: redeemPoll, Reconcile: reconcile}, + Strategy: strategy, }, nil } -func parseTarget(raw rawConfig) (Target, error) { - // The zero address is rejected so an unreplaced placeholder fails at startup rather than being - // registered as the 3F offer-address. - adapter, err := parseNonZeroAddress(raw.Adapter, "adapter") - if err != nil { - return Target{}, err - } - return Target{Adapter: adapter}, nil -} - -func parseAddress(s, field string) (common.Address, error) { - if !common.IsHexAddress(s) { - return common.Address{}, errors.Errorf("%s: invalid address %q", field, s) - } - return common.HexToAddress(s), nil -} - -func parseNonZeroAddress(s, field string) (common.Address, error) { - addr, err := parseAddress(s, field) - if err != nil { - return common.Address{}, err - } - if addr == (common.Address{}) { - return common.Address{}, errors.Errorf("%s: zero address (placeholder not replaced?)", field) - } - return addr, nil -} - -// parseDuration returns fallback when s is empty, but a present-but-invalid or non-positive value is -// an error rather than a silent fall back to the default — a typo'd interval should fail, not run at -// some surprising cadence. -func parseDuration(s string, fallback time.Duration, field string) (time.Duration, error) { - if s == "" { - return fallback, nil - } - d, err := time.ParseDuration(s) - if err != nil { - return 0, errors.Errorf("%s: invalid duration %q: %w", field, s, err) +func parseTargets(raw rawConfig) ([]Target, error) { + if len(raw.Adapters) == 0 { + return nil, errors.New("at least one adapters entry is required") } - if d <= 0 { - return 0, errors.Errorf("%s: duration must be positive, got %q", field, s) + targets := make([]Target, 0, len(raw.Adapters)) + for i, a := range raw.Adapters { + adapter, err := cfgparse.NonZeroAddress(a, "adapters["+strconv.Itoa(i)+"]") + if err != nil { + return nil, err + } + targets = append(targets, Target{Adapter: adapter}) } - return d, nil + return targets, nil } diff --git a/internal/solvers/bridgefacilitator/config_test.go b/internal/solvers/bridgefacilitator/config_test.go index 2e88fd9b..54377b04 100644 --- a/internal/solvers/bridgefacilitator/config_test.go +++ b/internal/solvers/bridgefacilitator/config_test.go @@ -3,25 +3,36 @@ package bridgefacilitator import ( "testing" + "github.com/ethereum/go-ethereum/common" "gopkg.in/yaml.v3" ) -func mustParse(t *testing.T, body string) *Config { +func parse(t *testing.T, body string) (*Config, error) { t.Helper() var doc yaml.Node if err := yaml.Unmarshal([]byte(body), &doc); err != nil { t.Fatalf("unmarshal: %v", err) } - cfg, err := parseConfig(*doc.Content[0]) // Content[0] is the mapping node (as the two-stage decode yields) + return parseConfig(*doc.Content[0]) // Content[0] is the mapping node (as the two-stage decode yields) +} + +func mustParse(t *testing.T, body string) *Config { + t.Helper() + cfg, err := parse(t, body) if err != nil { t.Fatalf("parseConfig: %v", err) } return cfg } +const minimalConfig = ` +apiBaseUrl: https://bf.example +` + const oneTarget = ` apiBaseUrl: https://bf.example -adapter: "0x0000000000000000000000000000000000000002" +adapters: + - "0x0000000000000000000000000000000000000002" ` func TestParseConfig_RedeemBatchSizeDefaults(t *testing.T) { @@ -29,6 +40,9 @@ func TestParseConfig_RedeemBatchSizeDefaults(t *testing.T) { if cfg.RedeemBatchSize != defaultRedeemBatchSize { t.Fatalf("expected default %d, got %d", defaultRedeemBatchSize, cfg.RedeemBatchSize) } + if cfg.Strategy.Name != defaultStrategyName { + t.Fatalf("strategy.name = %q, want %q", cfg.Strategy.Name, defaultStrategyName) + } } func TestParseConfig_RedeemBatchSizeOverride(t *testing.T) { @@ -61,7 +75,8 @@ func TestParseConfig_InvalidDurationRejected(t *testing.T) { func TestParseConfig_ZeroAdapterRejected(t *testing.T) { body := ` apiBaseUrl: https://bf.example -adapter: "0x0000000000000000000000000000000000000000" +adapters: + - "0x0000000000000000000000000000000000000000" ` var doc yaml.Node if err := yaml.Unmarshal([]byte(body), &doc); err != nil { @@ -71,3 +86,48 @@ adapter: "0x0000000000000000000000000000000000000000" t.Fatal("expected zero adapter address to be rejected") } } + +func TestParseConfig_AdaptersList(t *testing.T) { + cfg, err := parse(t, minimalConfig+"adapters:\n - \"0x0000000000000000000000000000000000000042\"\n - \"0x0000000000000000000000000000000000000043\"\n") + if err != nil { + t.Fatalf("parseConfig: %v", err) + } + if len(cfg.Targets) != 2 || + cfg.Targets[0].Adapter != common.HexToAddress("0x0000000000000000000000000000000000000042") || + cfg.Targets[1].Adapter != common.HexToAddress("0x0000000000000000000000000000000000000043") { + t.Fatalf("targets = %+v", cfg.Targets) + } +} + +func TestParseConfig_Strategy(t *testing.T) { + cfg, err := parse(t, oneTarget+` +strategy: + name: webhook + config: + url: https://strategy.example +`) + if err != nil { + t.Fatalf("parseConfig: %v", err) + } + if cfg.Strategy.Name != "webhook" { + t.Fatalf("strategy.name = %q, want webhook", cfg.Strategy.Name) + } + var raw struct { + URL string `yaml:"url"` + } + if err := cfg.Strategy.Config.Decode(&raw); err != nil { + t.Fatalf("decode strategy config: %v", err) + } + if raw.URL != "https://strategy.example" { + t.Fatalf("strategy url = %q", raw.URL) + } +} + +func TestParseConfig_RejectsEmptyAndZeroAdapters(t *testing.T) { + if _, err := parse(t, minimalConfig); err == nil { + t.Fatal("expected an error when no adapters are configured") + } + if _, err := parse(t, minimalConfig+"adapters:\n - \"0x0000000000000000000000000000000000000000\"\n"); err == nil { + t.Fatal("expected an error for a zero adapter address") + } +} diff --git a/internal/solvers/bridgefacilitator/eip712.go b/internal/solvers/bridgefacilitator/eip712.go index c5cbcc8b..6de16b26 100644 --- a/internal/solvers/bridgefacilitator/eip712.go +++ b/internal/solvers/bridgefacilitator/eip712.go @@ -81,33 +81,8 @@ func boolWord(v bool) []byte { return w } -// offerExpectedReturn derives the absolute expected return for `principal` at `rateBps` basis -// points. Confirmed against the live 3F API: maxRate is "in basis points ... with -// tenths-of-a-basis-point precision" (the value may carry one decimal, e.g. 694.7), so the -// denominator is 10_000. We truncate (round down) expectedReturn, which keeps us at or below the -// auction's max rate. -func offerExpectedReturn(principal *big.Int, rateBps float64) *big.Int { - // expectedReturn = principal * rateBps / RateDenominatorBps - num := new(big.Float).Mul(new(big.Float).SetInt(principal), big.NewFloat(rateBps)) - num.Quo(num, big.NewFloat(RateDenominatorBps)) - out, _ := num.Int(nil) - return out -} - -// bpsToFloat converts an on-chain bps value to float64 for comparison against the auction's float -// maxRate. An absurdly large floor yields +Inf, which fail-closes (no rate clears it → no bid). -func bpsToFloat(n *big.Int) float64 { - f, _ := new(big.Float).SetInt(n).Float64() - return f -} - -// RateDenominatorBps converts a basis-point rate to a fraction (10_000 = 100%). -const RateDenominatorBps = 10_000.0 - -// API-key generation EIP-712, validated against the live 3F dev API (a correctly-formed signature -// is accepted; an un-onboarded facilitator returns 403, not a signature error). The domain omits -// verifyingContract and pins chainId = 1 even on testnets ("current implementation only accepts -// chainId = 1", per the spec). +// grunt-api EIP-712 domain (no verifyingContract). chainId is per-flow: the (test-only) API-key +// generation domain uses 1; the GetOffers listing domain uses the bot's operating chain. const ( apiKeyDomainName = "grunt-api" apiKeyDomainVersion = "1" @@ -121,14 +96,30 @@ var ( []byte("EIP712Domain(string name,string version,uint256 chainId)")) ) -// APIKeyDigest computes the EIP-712 digest a facilitator signs to generate a 3F API key. -func APIKeyDigest(facilitator common.Address, deadline *big.Int) common.Hash { - ds := crypto.Keccak256Hash( +// gruntAPIDomainSeparator builds the grunt-api domain separator (name/version, no verifyingContract) +// for chainID; the 3F server rebuilds it from the request's chainId query param to verify the signature. +func gruntAPIDomainSeparator(chainID *big.Int) common.Hash { + return crypto.Keccak256Hash( apiKeyDomainTypeHash.Bytes(), crypto.Keccak256([]byte(apiKeyDomainName)), crypto.Keccak256([]byte(apiKeyDomainVersion)), - word(big.NewInt(apiKeyDomainChainID).Bytes()), + word(chainID.Bytes()), ) +} + +// getOffersTypeHash is the EIP-712 type the maker signs to list its offers via the Authorization +// header; the field set is checked against the live 3F API in the GetOffers golden test. +var getOffersTypeHash = crypto.Keccak256Hash([]byte("GetOffers(address maker,uint256 deadline)")) + +// GetOffersDigest computes the EIP-712 digest signed for an authenticated GET /v1/offer (maker=adapter) +// over the grunt-api domain at chainID (the bot's operating chain). +func GetOffersDigest(maker common.Address, deadline, chainID *big.Int) common.Hash { + sh := crypto.Keccak256Hash(getOffersTypeHash.Bytes(), word(maker.Bytes()), word(deadline.Bytes())) + return crypto.Keccak256Hash([]byte{0x19, 0x01}, gruntAPIDomainSeparator(chainID).Bytes(), sh.Bytes()) +} + +// APIKeyDigest computes the EIP-712 digest a facilitator signs to generate a 3F API key (chainId 1). +func APIKeyDigest(facilitator common.Address, deadline *big.Int) common.Hash { sh := crypto.Keccak256Hash(apiKeyTypeHash.Bytes(), word(facilitator.Bytes()), word(deadline.Bytes())) - return crypto.Keccak256Hash([]byte{0x19, 0x01}, ds.Bytes(), sh.Bytes()) + return crypto.Keccak256Hash([]byte{0x19, 0x01}, gruntAPIDomainSeparator(big.NewInt(apiKeyDomainChainID)).Bytes(), sh.Bytes()) } diff --git a/internal/solvers/bridgefacilitator/eip712_test.go b/internal/solvers/bridgefacilitator/eip712_test.go index bfd67e04..dad21c05 100644 --- a/internal/solvers/bridgefacilitator/eip712_test.go +++ b/internal/solvers/bridgefacilitator/eip712_test.go @@ -1,8 +1,14 @@ package bridgefacilitator import ( + "context" + "fmt" "math/big" + "net/http" + "os" + "strings" "testing" + "time" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" @@ -111,12 +117,116 @@ func TestAPIKeyDigest_MatchesLiveAcceptedSignature(t *testing.T) { } } -func TestOfferExpectedReturn(t *testing.T) { - // 100,000 USDC (6 dp) at 200 bps (2%) => 2,000 USDC. - principal := new(big.Int).SetUint64(100_000_000_000) - got := offerExpectedReturn(principal, 200) - want := new(big.Int).SetUint64(2_000_000_000) - if got.Cmp(want) != 0 { - t.Fatalf("expected %s, got %s", want, got) +func TestGetOffersDigest_Golden(t *testing.T) { + maker := common.HexToAddress("0x0000000000000000000000000000000000000042") + got := GetOffersDigest(maker, big.NewInt(4102444800), big.NewInt(apiKeyDomainChainID)).Hex() + // GOLDEN: pinned from TestGetOffersDigest_MatchesApitypes cross-check (chainId 1). + want := "0x9d4c2e5ccaaeb6884d2d2fd8e306e57cf781ef424db9e8801c703eac794fa6a5" + if got != want { + t.Fatalf("digest = %s, want %s", got, want) + } +} + +// TestGetOffersDigest_MatchesApitypes cross-checks our hand-rolled GetOffers digest against +// go-ethereum's independent EIP-712 implementation. The grunt-api domain has no verifyingContract +// (name/version/chainId=1 only), matching the same domain as APIKeyDigest. +func TestGetOffersDigest_MatchesApitypes(t *testing.T) { + maker := common.HexToAddress("0x0000000000000000000000000000000000000042") + deadline := big.NewInt(4102444800) + + got := GetOffersDigest(maker, deadline, big.NewInt(apiKeyDomainChainID)) + + typed := apitypes.TypedData{ + Types: apitypes.Types{ + "EIP712Domain": { + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + }, + "GetOffers": { + {Name: "maker", Type: "address"}, + {Name: "deadline", Type: "uint256"}, + }, + }, + PrimaryType: "GetOffers", + Domain: apitypes.TypedDataDomain{ + Name: apiKeyDomainName, + Version: apiKeyDomainVersion, + ChainId: math.NewHexOrDecimal256(apiKeyDomainChainID), + }, + Message: apitypes.TypedDataMessage{ + "maker": maker.Hex(), + "deadline": deadline.String(), + }, + } + domainSep, err := typed.HashStruct("EIP712Domain", typed.Domain.Map()) + if err != nil { + t.Fatalf("hash domain: %v", err) + } + msgHash, err := typed.HashStruct("GetOffers", typed.Message) + if err != nil { + t.Fatalf("hash message: %v", err) + } + want := crypto.Keccak256Hash([]byte{0x19, 0x01}, domainSep, msgHash) + + if got != want { + t.Fatalf("digest mismatch:\n manual %s\n apitypes %s", got.Hex(), want.Hex()) + } +} + +// TestGetOffersDigest_MatchesLiveAcceptedSignature verifies that the scaffolded GetOffers type +// string is accepted by the live 3F API. Skipped offline (SOLVER_LIVE_AUTH != "1"). +// A correctly-formed sig returns 200/empty or 403 (unauthorized maker) — NOT a signature error. +// If the type string is wrong the API returns a 401/signature-error, which fails the test. +func TestGetOffersDigest_MatchesLiveAcceptedSignature(t *testing.T) { + if os.Getenv("SOLVER_LIVE_AUTH") != "1" { + t.Skip("set SOLVER_LIVE_AUTH=1 and SOLVER_PRIVATE_KEY to run the live 3F GetOffers auth check") + } + pk := os.Getenv("SOLVER_PRIVATE_KEY") + if pk == "" { + t.Fatal("SOLVER_PRIVATE_KEY not set") + } + key, err := crypto.HexToECDSA(strings.TrimPrefix(pk, "0x")) + if err != nil { + t.Fatalf("key: %v", err) + } + maker := crypto.PubkeyToAddress(key.PublicKey) + deadline := big.NewInt(4_102_444_800) + chainID := big.NewInt(11155111) // Sepolia; the grunt-api domain + query chainId must agree + if v := os.Getenv("SOLVER_CHAIN_ID"); v != "" { + chainID, _ = new(big.Int).SetString(v, 10) + } + + sig, err := crypto.Sign(GetOffersDigest(maker, deadline, chainID).Bytes(), key) + if err != nil { + t.Fatalf("sign: %v", err) + } + sig[64] += 27 // normalize V to {27,28} + + baseURL := os.Getenv("SOLVER_3F_BASE_URL") + if baseURL == "" { + baseURL = "https://bf.dev.gcp.3f.xyz" + } + + url := fmt.Sprintf("%s/v1/offer?maker=%s&chainId=%s&deadline=%s", + baseURL, strings.ToLower(maker.Hex()), chainID.String(), deadline.String()) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) //nolint:gosec // G704: URL is operator-supplied via SOLVER_3F_BASE_URL in this live integration test + if err != nil { + t.Fatalf("build request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+hexutil.Encode(sig)) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) //nolint:gosec // G704: intentional operator-controlled target in live integration test + if err != nil { + t.Fatalf("GET /v1/offer: %v", err) + } + defer resp.Body.Close() + + // 200 (authorized) or 403 (maker not registered) both mean signature verification passed. + // Anything in the 4xx range that is specifically a signature error means the type string is wrong. + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusForbidden { + t.Fatalf("unexpected status %d — expected 200 or 403 (sig accepted); a 401 means the type string may be wrong", resp.StatusCode) } + t.Logf("GET /v1/offer status %d (maker=%s) — signature accepted by 3F API", resp.StatusCode, maker.Hex()) } diff --git a/internal/solvers/bridgefacilitator/liveauth_test.go b/internal/solvers/bridgefacilitator/liveauth_test.go index 04690cb2..8c420b5c 100644 --- a/internal/solvers/bridgefacilitator/liveauth_test.go +++ b/internal/solvers/bridgefacilitator/liveauth_test.go @@ -2,7 +2,9 @@ package bridgefacilitator import ( "context" + "math/big" "os" + "strings" "testing" "time" @@ -11,19 +13,18 @@ import ( "github.com/symbioticfi/vault-solver/internal/signer" ) -// TestLiveGenerateKey exercises the real 3F generate-key flow against the live API. It is skipped -// unless SOLVER_LIVE_AUTH=1 (so it never runs in CI), and needs SOLVER_PRIVATE_KEY in the env. A -// pass means the facilitator (the signer EOA) is onboarded and a key was issued; a 403 means the -// signature is accepted but the address isn't registered with 3F yet. -func TestLiveGenerateKey(t *testing.T) { +// TestLiveListOffers exercises the signed per-adapter GET /v1/offer flow against the live API. It is +// skipped unless SOLVER_LIVE_AUTH=1 (so it never runs in CI), and needs SOLVER_PRIVATE_KEY in the +// env. A pass (200 or 403) means the EIP-712 signature was accepted by the 3F API. +func TestLiveListOffers(t *testing.T) { if os.Getenv("SOLVER_LIVE_AUTH") != "1" { - t.Skip("set SOLVER_LIVE_AUTH=1 and SOLVER_PRIVATE_KEY to run the live 3F auth check") + t.Skip("set SOLVER_LIVE_AUTH=1 and SOLVER_PRIVATE_KEY to run the live 3F listOffers auth check") } pk := os.Getenv("SOLVER_PRIVATE_KEY") if pk == "" { t.Fatal("SOLVER_PRIVATE_KEY not set") } - sgnr, err := signer.NewFromHexKey(pk) + sgnr, err := signer.NewFromHexKey(strings.TrimPrefix(pk, "0x")) if err != nil { t.Fatalf("signer: %v", err) } @@ -33,24 +34,19 @@ func TestLiveGenerateKey(t *testing.T) { baseURL = "https://bf.dev.gcp.3f.xyz" } - ac, err := newAPIClient(baseURL, 30*time.Second, sgnr, sgnr.Address(), "", logr.Discard()) - if err != nil { - t.Fatalf("client: %v", err) + chainID := big.NewInt(11155111) // Sepolia; override for another chain + if v := os.Getenv("SOLVER_CHAIN_ID"); v != "" { + chainID, _ = new(big.Int).SetString(v, 10) } + ac := newAPIClient(baseURL, sgnr, chainID, 30*time.Second, logr.Discard()) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - key, err := ac.generate(ctx) + // Use the signer's own address as the adapter for the live auth check. + offers, err := ac.listOffers(ctx, sgnr.Address()) if err != nil { - t.Fatalf("generate-key failed for facilitator %s:\n %v", sgnr.Address().Hex(), err) - } - t.Logf("AUTH OK — facilitator %s issued key %s", sgnr.Address().Hex(), maskKey(key)) -} - -func maskKey(s string) string { - if len(s) <= 10 { - return "***" + t.Fatalf("listOffers failed for adapter %s:\n %v", sgnr.Address().Hex(), err) } - return s[:10] + "…(redacted)" + t.Logf("AUTH OK — adapter %s returned %d offers", sgnr.Address().Hex(), len(offers)) } diff --git a/internal/solvers/bridgefacilitator/offer.go b/internal/solvers/bridgefacilitator/offer.go index e06d59bd..7c101662 100644 --- a/internal/solvers/bridgefacilitator/offer.go +++ b/internal/solvers/bridgefacilitator/offer.go @@ -6,52 +6,36 @@ import ( "github.com/go-errors/errors" - "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/symbioticfi/vault-solver/api/threef" + "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies/types" ) // offerTTL is how long a signed offer stays valid. const offerTTL = 30 * time.Minute -// generateKeyDeadline is the EIP-712 `deadline` for the generate-key request. The 3F spec labels -// it the "Signature deadline" (how long the signed request is valid, for replay protection) and -// its example uses a year-2100 value — it is NOT documented as the API key's TTL. We set it far -// out so it's safe under both readings: a non-expiring signature window, or (if 3F ties key life -// to it) a long-lived key. Either way, reactive regeneration on a 401/403 covers revoke/expire. -const generateKeyDeadline = 100 * 365 * 24 * time.Hour - -// buildSignedOffer prices and signs an offer for `request` at `principal`, with `maker` (the adapter) -// as the on-chain maker. `minYieldBps` is the adapter's on-chain return floor (0 = none); it returns -// ok=false (no error) when the auction's rate is below it, so the bot doesn't bid (the contract -// enforces the same floor at consume time). +// buildSignedOffer signs a trusted strategy execution offer. Strategy owns pricing and sizing; solver +// only supplies the auction EIP-712 domain and signature. func (s *Solver) buildSignedOffer( - av auctionView, request, maker common.Address, principal, minYieldBps *big.Int, -) (threef.CreateOfferDto, bool, error) { + av auctionView, offer types.OfferExecution, +) (threef.CreateOfferDto, error) { auction := av.dto - maxRate, ok := auction.GetMaxRateOk() - if !ok || maxRate == nil { - return threef.CreateOfferDto{}, false, nil - } - rateBps := float64(*maxRate) - if minYieldBps != nil && minYieldBps.Sign() > 0 && rateBps < bpsToFloat(minYieldBps) { - return threef.CreateOfferDto{}, false, nil // below the adapter's on-chain return floor + if offer.Principal == nil || offer.ExpectedReturn == nil { + return threef.CreateOfferDto{}, errors.Errorf("auction %v: strategy offer is missing amounts", auction.Id) } - expectedReturn := offerExpectedReturn(principal, rateBps) - domain, ok := auction.GetEip712DomainOk() if !ok || domain == nil { - return threef.CreateOfferDto{}, false, errors.Errorf("auction %v: missing EIP-712 domain", auction.Id) + return threef.CreateOfferDto{}, errors.Errorf("auction %v: missing EIP-712 domain", auction.Id) } domainName, ok := domain.GetNameOk() if !ok || domainName == nil { - return threef.CreateOfferDto{}, false, errors.Errorf("auction %v: missing EIP-712 domain name", auction.Id) + return threef.CreateOfferDto{}, errors.Errorf("auction %v: missing EIP-712 domain name", auction.Id) } domainChainID, ok := domain.GetChainIdOk() if !ok || domainChainID == nil { - return threef.CreateOfferDto{}, false, errors.Errorf("auction %v: missing EIP-712 domain chainId", auction.Id) + return threef.CreateOfferDto{}, errors.Errorf("auction %v: missing EIP-712 domain chainId", auction.Id) } chainID := big.NewInt(int64(*domainChainID)) // The EIP-712 domain version comes from the auction; fall back to grunt's known default only when @@ -64,30 +48,30 @@ func (s *Solver) buildSignedOffer( nonce := new(big.Int).SetUint64(s.nextNonce()) expiration := big.NewInt(time.Now().Add(offerTTL).Unix()) - offer := Offer{ - Maker: maker, - Amount: principal, - ExpectedReturn: expectedReturn, + signedOffer := Offer{ + Maker: offer.Maker, + Amount: offer.Principal, + ExpectedReturn: offer.ExpectedReturn, Nonce: nonce, Expiration: expiration, UseCallback: true, } - digest := OfferDigest(offer, *domainName, domainVersion, chainID, request) + digest := OfferDigest(signedOffer, *domainName, domainVersion, chainID, offer.Request) sig, err := s.deps.Signer.SignHash(digest) if err != nil { - return threef.CreateOfferDto{}, false, errors.Errorf("sign offer: %w", err) + return threef.CreateOfferDto{}, errors.Errorf("sign offer: %w", err) } dto := threef.NewCreateOfferDto( auction.Id, - lowerAddr(maker), // API rejects checksummed addresses (confirmed live) - principal.String(), - expectedReturn.String(), + lowerAddr(offer.Maker), // API rejects checksummed addresses (confirmed live) + offer.Principal.String(), + offer.ExpectedReturn.String(), nonce.String(), expiration.String(), true, // useCallback ) dto.SetChainId(float32(chainID.Int64())) dto.SetSignature(hexutil.Encode(sig)) - return *dto, true, nil + return *dto, nil } diff --git a/internal/solvers/bridgefacilitator/offercache.go b/internal/solvers/bridgefacilitator/offercache.go index 6589cb17..b710eded 100644 --- a/internal/solvers/bridgefacilitator/offercache.go +++ b/internal/solvers/bridgefacilitator/offercache.go @@ -1,37 +1,72 @@ package bridgefacilitator import ( + "math/big" "strconv" "time" + + "github.com/ethereum/go-ethereum/common" ) -// offerTracker remembers, per auction, when our currently-outstanding offer expires, so we don't -// re-offer while a live offer exists. It is rebuilt from the 3F API at startup (restart-safe) and -// updated in memory as offers are submitted. Accessed only from the Run goroutine; no locking. +// offerKey identifies our offer on a given auction made on behalf of a given adapter (the maker). +// Dedup is per-adapter: two adapters may each hold a live offer on the same auction. +type offerKey struct { + adapter common.Address + auction int64 +} + +// offerState is one outstanding offer: when it expires and the principal it covers. +type offerState struct { + expiry time.Time + principal *big.Int +} + +// offerTracker remembers our outstanding offers per (adapter, auction) so we don't re-offer through +// the same adapter while one is live, and so we can tell when an auction is fully covered. Rebuilt +// from the 3F API at startup (restart-safe), updated in memory as offers are submitted; Run goroutine +// only, no locking. type offerTracker struct { - expiry map[int64]time.Time // auctionID -> our offer's expiration + offers map[offerKey]offerState } func newOfferTracker() *offerTracker { - return &offerTracker{expiry: make(map[int64]time.Time)} + return &offerTracker{offers: make(map[offerKey]offerState)} } -// hasLive reports whether we hold an unexpired offer for auctionID as of now. -func (t *offerTracker) hasLive(auctionID int64, now time.Time) bool { - exp, ok := t.expiry[auctionID] - return ok && exp.After(now) +// liveEntries returns the (adapter, auction) keys of every unexpired offer as of now, for the strategy +// to dedup against. Cheaper than probing each adapter/auction pair: it walks only the offers we hold. +func (t *offerTracker) liveEntries(now time.Time) []offerKey { + keys := make([]offerKey, 0, len(t.offers)) + for k, st := range t.offers { + if st.expiry.After(now) { + keys = append(keys, k) + } + } + return keys +} + +// record stores the expiration and principal of an offer we hold through adapter for auctionID. +func (t *offerTracker) record(adapter common.Address, auctionID int64, expiration time.Time, principal *big.Int) { + t.offers[offerKey{adapter, auctionID}] = offerState{expiry: expiration, principal: new(big.Int).Set(principal)} } -// record stores the expiration of an offer we hold for auctionID. -func (t *offerTracker) record(auctionID int64, expiration time.Time) { - t.expiry[auctionID] = expiration +// liveCoverage sums the principal of our unexpired offers on auctionID across every adapter — how much +// of the auction's requested amount we already cover. +func (t *offerTracker) liveCoverage(auctionID int64, now time.Time) *big.Int { + total := new(big.Int) + for k, st := range t.offers { + if k.auction == auctionID && st.expiry.After(now) { + total.Add(total, st.principal) + } + } + return total } // pruneExpired drops entries whose offer has already expired, keeping the map bounded over a long run. func (t *offerTracker) pruneExpired(now time.Time) { - for id, exp := range t.expiry { - if !exp.After(now) { - delete(t.expiry, id) + for k, st := range t.offers { + if !st.expiry.After(now) { + delete(t.offers, k) } } } diff --git a/internal/solvers/bridgefacilitator/offercache_test.go b/internal/solvers/bridgefacilitator/offercache_test.go index 08279a30..0b763727 100644 --- a/internal/solvers/bridgefacilitator/offercache_test.go +++ b/internal/solvers/bridgefacilitator/offercache_test.go @@ -1,30 +1,72 @@ package bridgefacilitator import ( + "math/big" "testing" "time" + + "github.com/ethereum/go-ethereum/common" ) func TestOfferTracker(t *testing.T) { tr := newOfferTracker() now := time.Unix(1_000_000, 0) + adapterA := common.Address{0xAA} + adapterB := common.Address{0xBB} + + live := func(at time.Time, adapter common.Address, auction int64) bool { + for _, k := range tr.liveEntries(at) { + if k.adapter == adapter && k.auction == auction { + return true + } + } + return false + } - if tr.hasLive(42, now) { - t.Fatal("empty tracker should report no live offer") + if len(tr.liveEntries(now)) != 0 { + t.Fatal("empty tracker should report no live offers") } - tr.record(42, now.Add(30*time.Minute)) - if !tr.hasLive(42, now) { + tr.record(adapterA, 42, now.Add(30*time.Minute), big.NewInt(100)) + if !live(now, adapterA, 42) { t.Fatal("offer should be live before expiry") } - if tr.hasLive(42, now.Add(31*time.Minute)) { + // Dedup is per-adapter: A's offer on auction 42 must not suppress B's offer on the same auction. + if live(now, adapterB, 42) { + t.Fatal("an offer through adapter A must not mark adapter B's offer on the same auction as live") + } + if live(now.Add(31*time.Minute), adapterA, 42) { t.Fatal("offer should be expired after its TTL") } - if tr.hasLive(7, now) { + if live(now, adapterA, 7) { t.Fatal("unknown auction should not be live") } } +func TestOfferTrackerLiveCoverage(t *testing.T) { + tr := newOfferTracker() + now := time.Unix(1_000_000, 0) + adapterA := common.Address{0xAA} + adapterB := common.Address{0xBB} + + if got := tr.liveCoverage(42, now); got.Sign() != 0 { + t.Fatalf("empty tracker coverage = %s, want 0", got) + } + + // Coverage sums principals across adapters on the same auction. + tr.record(adapterA, 42, now.Add(30*time.Minute), big.NewInt(100)) + tr.record(adapterB, 42, now.Add(30*time.Minute), big.NewInt(60)) + tr.record(adapterA, 7, now.Add(30*time.Minute), big.NewInt(999)) // other auction, excluded + if got := tr.liveCoverage(42, now); got.Cmp(big.NewInt(160)) != 0 { + t.Fatalf("coverage = %s, want 160", got) + } + + // Expired offers don't count toward coverage. + if got := tr.liveCoverage(42, now.Add(31*time.Minute)); got.Sign() != 0 { + t.Fatalf("coverage after expiry = %s, want 0", got) + } +} + func TestParseUnixTime(t *testing.T) { got, err := parseUnixTime("4102444800") if err != nil { diff --git a/internal/solvers/bridgefacilitator/redeemer.go b/internal/solvers/bridgefacilitator/redeemer.go index e9933076..b035dd5c 100644 --- a/internal/solvers/bridgefacilitator/redeemer.go +++ b/internal/solvers/bridgefacilitator/redeemer.go @@ -6,8 +6,8 @@ import ( "github.com/symbioticfi/vault-solver/internal/txmanager" ) -// redeemReady finds the target's redeemable Requests (batched canWithdraw via multicall) and submits -// a single bounded redeem through the shared txmanager. +// redeemReady finds the target's redeemable Requests (batched canWithdraw via multicall) and finalizes +// them in a single bounded adapter.multicall(finalizeRequest...) through the shared txmanager. func (s *Solver) redeemReady(ctx context.Context, target Target) { ready, err := s.reader.readyToRedeem(ctx, target.Adapter) if err != nil { @@ -18,18 +18,20 @@ func (s *Solver) redeemReady(ctx context.Context, target Target) { if len(ready) == 0 { return } - // Bound the batch so redeem() calldata + gas stay predictable; the remainder is picked up on - // the next redeem-poll cycle (Requests stay active until redeemed). + // Bound the batch so the multicall calldata + gas stay predictable; the remainder is picked up on + // the next redeem-poll cycle (Requests stay active until finalized). if len(ready) > s.cfg.RedeemBatchSize { s.log.Info("capping redeem batch", "ready", len(ready), "limit", s.cfg.RedeemBatchSize) ready = ready[:s.cfg.RedeemBatchSize] } - data, err := bfAdapter.TryPackRedeem(ready) - if err != nil { - s.log.Error(err, "redeem: pack calldata") - return + // finalizeRequest takes one request; batch them into the adapter's own multicall so all ready + // requests finalize in a single tx. + finalize := make([][]byte, len(ready)) + for i, req := range ready { + finalize[i] = bfAdapter.PackFinalizeRequest(req) } + data := bfAdapter.PackMulticall(finalize) res := s.deps.TxManager.Send(ctx, txmanager.Request{ To: target.Adapter, @@ -40,5 +42,5 @@ func (s *Solver) redeemReady(ctx context.Context, target Target) { s.log.Error(res.Err, "redeem: tx failed", "requests", len(ready)) return } - s.log.Info("redeemed ready requests", "count", len(ready), "tx", res.Hash.Hex()) + s.log.Info("finalized ready requests", "count", len(ready), "tx", res.Hash.Hex()) } diff --git a/internal/solvers/bridgefacilitator/sizer.go b/internal/solvers/bridgefacilitator/sizer.go deleted file mode 100644 index 3dd89dc7..00000000 --- a/internal/solvers/bridgefacilitator/sizer.go +++ /dev/null @@ -1,54 +0,0 @@ -package bridgefacilitator - -import ( - "math/big" -) - -// sizeInputs are the bounds that constrain how much principal the bot may offer for one Request. The -// caps mirror the adapter's authoritative on-chain exposure limits (each 0 = disabled). -type sizeInputs struct { - perRequestMax *big.Int // adapter perRequestMaxCollateral (0 = no limit) - fundable *big.Int // delegator-cap + vault-liquidity headroom (chain read) - amountRequested *big.Int // auction ask; nil if unknown - sleeveMax *big.Int // adapter totalMaxCollateral (0 = no limit) - outstanding *big.Int // live sleeve exposure (sum of open principals) - - openCount int - maxConcurrent int // adapter maxConcurrentLoans (0 = no limit) -} - -// sizeOffer returns the principal to offer and whether to bid at all. `fundable` is always a hard cap — -// committing more would make the just-in-time allocation inside the consume callback revert. The -// per-Request, sleeve, and concurrency caps apply only when set (0 = disabled). Request authorization -// is enforced on-chain by the 3F whitelist at consume time, so the bot applies only these risk caps. -func sizeOffer(in sizeInputs) (*big.Int, bool) { - if in.maxConcurrent > 0 && in.openCount >= in.maxConcurrent { - return nil, false - } - - amount := new(big.Int).Set(in.fundable) - if in.perRequestMax != nil && in.perRequestMax.Sign() > 0 { - amount = minBig(amount, in.perRequestMax) - } - if in.sleeveMax != nil && in.sleeveMax.Sign() > 0 { - sleeveRoom := new(big.Int).Sub(in.sleeveMax, in.outstanding) - if sleeveRoom.Sign() <= 0 { - return nil, false - } - amount = minBig(amount, sleeveRoom) - } - if in.amountRequested != nil && in.amountRequested.Sign() > 0 { - amount = minBig(amount, in.amountRequested) - } - if amount.Sign() <= 0 { - return nil, false - } - return amount, true -} - -func minBig(a, b *big.Int) *big.Int { - if a.Cmp(b) <= 0 { - return new(big.Int).Set(a) - } - return new(big.Int).Set(b) -} diff --git a/internal/solvers/bridgefacilitator/sizer_test.go b/internal/solvers/bridgefacilitator/sizer_test.go deleted file mode 100644 index 462e15cc..00000000 --- a/internal/solvers/bridgefacilitator/sizer_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package bridgefacilitator - -import ( - "math/big" - "testing" -) - -func bi(n int64) *big.Int { return big.NewInt(n) } - -func TestSizeOffer(t *testing.T) { - base := func() sizeInputs { - return sizeInputs{ - perRequestMax: bi(250_000), - fundable: bi(500_000), - amountRequested: bi(1_000_000), - sleeveMax: bi(1_000_000), - outstanding: bi(0), - openCount: 0, - maxConcurrent: 10, - } - } - - tests := []struct { - name string - mutate func(*sizeInputs) - wantOK bool - want *big.Int - }{ - { - name: "perRequestMax binds", - mutate: func(in *sizeInputs) {}, - wantOK: true, - want: bi(250_000), - }, - { - name: "fundable binds", - mutate: func(in *sizeInputs) { in.fundable = bi(100_000) }, - wantOK: true, - want: bi(100_000), - }, - { - name: "sleeve headroom binds", - mutate: func(in *sizeInputs) { in.outstanding = bi(900_000) }, // 100k room - wantOK: true, - want: bi(100_000), - }, - { - name: "amountRequested binds", - mutate: func(in *sizeInputs) { in.amountRequested = bi(50_000) }, - wantOK: true, - want: bi(50_000), - }, - { - name: "concurrency cap reached", - mutate: func(in *sizeInputs) { in.openCount = 10 }, - wantOK: false, - }, - { - name: "sleeve full", - mutate: func(in *sizeInputs) { in.outstanding = bi(1_000_000) }, - wantOK: false, - }, - { - name: "perRequestMax disabled (0): fundable binds", - mutate: func(in *sizeInputs) { in.perRequestMax = bi(0) }, - wantOK: true, - want: bi(500_000), - }, - { - name: "sleeveMax disabled (0): sleeve ignored even when outstanding is high", - mutate: func(in *sizeInputs) { in.sleeveMax = bi(0); in.outstanding = bi(900_000) }, - wantOK: true, - want: bi(250_000), // perRequestMax binds; no sleeve cap - }, - { - name: "maxConcurrent disabled (0): no concurrency limit", - mutate: func(in *sizeInputs) { in.maxConcurrent = 0; in.openCount = 100 }, - wantOK: true, - want: bi(250_000), - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - in := base() - tc.mutate(&in) - got, ok := sizeOffer(in) - if ok != tc.wantOK { - t.Fatalf("ok = %v, want %v", ok, tc.wantOK) - } - if tc.wantOK && got.Cmp(tc.want) != 0 { - t.Fatalf("amount = %s, want %s", got, tc.want) - } - }) - } -} diff --git a/internal/solvers/bridgefacilitator/solver.go b/internal/solvers/bridgefacilitator/solver.go index 0779871f..c7ffa5a2 100644 --- a/internal/solvers/bridgefacilitator/solver.go +++ b/internal/solvers/bridgefacilitator/solver.go @@ -1,26 +1,32 @@ // Package bridgefacilitator implements the 3F (Grunt) Bridge Facilitator solver: it discovers -// bridge-loan auctions via the 3F API, prices and signs offers sized to vault liquidity and the -// adapter's on-chain policy, and realizes repaid loans back into the vault. It self-registers with -// the solver framework via init(). +// bridge-loan auctions via the 3F API, snapshots adapter state for a trusted strategy, signs the +// returned offers, and realizes repaid loans back into the vault. It self-registers with the solver +// framework via init(). package bridgefacilitator import ( "context" "math/big" - "os" + "strings" "sync/atomic" "time" - "github.com/go-errors/errors" - "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" "github.com/go-logr/logr" "gopkg.in/yaml.v3" - "github.com/symbioticfi/vault-solver/api/threef" "github.com/symbioticfi/vault-solver/internal/solver" + "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies/types" ) +// offerStatusIgnored are 3F offer statuses that are not live coverage when rebuilding the cache: a +// FAILED consume or a NOT_ACCEPTED bid won't cover the auction, so discovery should re-offer. +var offerStatusIgnored = map[string]bool{ + "FAILED": true, + "NOT_ACCEPTED": true, +} + // Name is the registry key that selects this solver from config. const Name = "3f-bridge-facilitator" @@ -29,16 +35,17 @@ func init() { solver.Register(Name, factory) } -// Solver is the 3F Bridge Facilitator strategy. +// Solver owns the 3F Bridge Facilitator lifecycle and delegates offer decisions to strategy. type Solver struct { - cfg *Config - deps solver.Deps - api *apiClient - reader *reader - log logr.Logger - nonceSeq atomic.Uint64 - onboarded bool // set once the 3F API key + offer-address are in place (Run goroutine only) - offers *offerTracker // dedup: auctions we hold a live offer for (Run goroutine only) + cfg *Config + deps solver.Deps + api *apiClient + reader *reader + strategy types.Strategy + log logr.Logger + signerAddr common.Address // the solver's own EIP-1271 signer address, set in factory + nonceSeq atomic.Uint64 + offers *offerTracker // dedup: (adapter, auction) pairs we hold a live offer for (Run goroutine only) } func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { @@ -47,27 +54,21 @@ func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { return nil, err } - var fallbackKey string - if cfg.APIKeyEnv != "" { - fallbackKey = os.Getenv(cfg.APIKeyEnv) - if fallbackKey == "" { - return nil, errors.Errorf("%s: api key env %q is empty", Name, cfg.APIKeyEnv) - } - } - // The facilitator (API-key owner) is the signing EOA; offers carry the per-target adapter as - // maker, registered as the facilitator offer-address at startup. - api, err := newAPIClient(cfg.APIBaseURL, cfg.HTTPTimeout, deps.Signer, deps.Signer.Address(), fallbackKey, deps.Log.WithName(Name)) + api := newAPIClient(cfg.APIBaseURL, deps.Signer, deps.Chain.ChainID(), cfg.HTTPTimeout, deps.Log.WithName(Name)) + offerStrategy, err := newStrategy(cfg.Strategy) if err != nil { return nil, err } s := &Solver{ - cfg: cfg, - deps: deps, - api: api, - reader: newReader(deps.Chain), - log: deps.Log.WithName(Name), - offers: newOfferTracker(), + cfg: cfg, + deps: deps, + api: api, + reader: newReader(deps.Chain), + strategy: offerStrategy, + log: deps.Log.WithName(Name), + signerAddr: deps.Signer.Address(), + offers: newOfferTracker(), } // Seed the offer nonce sequence from the wall clock so it stays monotonic across restarts. s.nonceSeq.Store(uint64(time.Now().UnixNano())) @@ -80,21 +81,23 @@ func (s *Solver) Name() string { return Name } // Run drives discovery/offer, redemption, and reconciliation on their configured cadences until // ctx is cancelled. func (s *Solver) Run(ctx context.Context) error { - // Resolve the target's vault and collateral from the adapter once at startup (see resolveTarget). - s.resolveTarget(ctx) + // Resolve every adapter's vault/collateral and drop any for which this solver is not the + // authorised EIP-1271 signer (see resolveTargets). + if err := s.resolveTargets(ctx); err != nil { + return err + } s.log.Info("starting", - "adapter", s.cfg.Target.Adapter.Hex(), - "vault", s.cfg.Target.Vault.Hex(), + "adapters", len(s.cfg.Targets), "apiBaseUrl", s.cfg.APIBaseURL, "discover", s.cfg.Intervals.Discover.String(), ) - // Onboard once at startup. On failure the bot runs redeem-only (offers disabled) until restart; - // redemption and reconciliation are on-chain and need no API auth. - if err := s.onboard(ctx); err != nil { - s.log.Error(err, "3F onboarding failed; running redeem-only (offers disabled until restart)") - } + // Best-effort at startup: load existing offers so a restart doesn't re-offer where we already hold + // a live offer. Per-adapter failures are logged and skipped; a missing entry costs at most one + // redundant, bounded-safe offer. There is no redeem-only mode — startup either kept ≥1 matching + // adapter (above) and runs offers + redeems, or resolveTargets already shut the solver down. + s.rebuildOfferCache(ctx) discoverT := time.NewTicker(s.cfg.Intervals.Discover) redeemT := time.NewTicker(s.cfg.Intervals.RedeemPoll) @@ -121,193 +124,126 @@ func (s *Solver) Run(ctx context.Context) error { } } -// onboard ensures the 3F API key and offer-address registration are in place. It runs once at -// startup and sets s.onboarded, which gates discovery/offers. Mid-run key expiry is handled -// separately by apiClient.withAuth (regenerate + retry on 401/403), so onboard never needs to rerun. -func (s *Solver) onboard(ctx context.Context) error { - if err := s.api.ensureKey(ctx); err != nil { - return errors.Errorf("api key: %w", err) - } - if err := s.ensureOfferAddress(ctx); err != nil { - return err - } - // Rebuild the offer-dedup cache from the API so a restart doesn't re-offer on auctions we - // already hold live offers for. Non-fatal: an empty cache just risks one redundant (bounded-safe) - // offer per auction. - if err := s.rebuildOfferCache(ctx); err != nil { - s.log.Error(err, "could not load existing offers; starting with an empty offer cache") - } - s.onboarded = true - return nil -} - -// rebuildOfferCache loads the facilitator's outstanding offers (a single API call covering its -// broker address and its configured offer-address, i.e. our adapter) and records the expiration of -// each still-unexpired one, so discovery skips auctions we already cover. -func (s *Solver) rebuildOfferCache(ctx context.Context) error { +// rebuildOfferCache records each adapter's still-unexpired offers so discovery skips auctions we +// already cover. Best-effort: a per-adapter list failure is logged and skipped so one bad adapter +// can't blank the others' caches. +func (s *Solver) rebuildOfferCache(ctx context.Context) { now := time.Now() - offers, err := s.api.listOffers(ctx) - if err != nil { - return err - } live := 0 - for _, o := range offers { - exp, perr := parseUnixTime(o.Expiration) - if perr != nil || !exp.After(now) { - continue // unparseable or already expired — we may freely re-offer + for _, t := range s.cfg.Targets { + offers, err := s.api.listOffers(ctx, t.Adapter) + if err != nil { + s.log.Error(err, "rebuild offer cache: list offers", "adapter", t.Adapter.Hex()) + continue + } + for _, o := range offers { + if offerStatusIgnored[strings.ToUpper(strings.TrimSpace(o.Status))] { + continue // failed/not-accepted offers aren't live coverage — let discovery re-offer + } + exp, perr := parseUnixTime(o.Expiration) + if perr != nil || !exp.After(now) { + continue // unparseable or already expired — we may freely re-offer + } + principal, ok := new(big.Int).SetString(o.Amount, 10) + if !ok { + s.log.V(1).Info("offer cache: unparseable amount; coverage may undercount", + "adapter", t.Adapter.Hex(), "amount", o.Amount) + principal = new(big.Int) + } + s.offers.record(t.Adapter, int64(o.AuctionId), exp, principal) + live++ } - s.offers.record(int64(o.AuctionId), exp) - live++ } s.log.Info("loaded existing offers into dedup cache", "live", live) - return nil } -// discoverAndOffer lists auctions and offers per target. It runs only when onboarding succeeded; in -// redeem-only mode (onboarding failed at startup) it is a no-op — redemption and reconciliation run -// independently, since they're on-chain only. +// adapterOffering tracks one adapter's liquidity/exposure snapshot for one offer pass. +type adapterOffering struct { + target Target + st exposureState +} + +// discoverAndOffer lists open auctions, snapshots adapter liquidity/exposure once, delegates offer +// selection to the configured strategy, then signs and submits the returned execution offers. func (s *Solver) discoverAndOffer(ctx context.Context) { - if !s.onboarded { - s.log.V(1).Info("not onboarded; skipping discovery/offers (redeem-only mode)") - return - } auctions, err := s.api.listAuctions(ctx) if err != nil { s.log.Error(err, "discover: list auctions") return } + s.log.V(1).Info("discovered auctions", "count", len(auctions)) - s.log.V(1).Info("discovered auctions", "count", len(auctions), "auctions", auctions) - s.offerForTarget(ctx, s.cfg.Target, auctions) -} + offerings := make([]*adapterOffering, 0, len(s.cfg.Targets)) + for _, t := range s.cfg.Targets { + st, lerr := s.reader.liquidityAndExposure(ctx, t.Adapter) + if lerr != nil { + s.log.Error(lerr, "offer: liquidity/exposure", "adapter", t.Adapter.Hex()) + continue + } + s.log.V(1).Info("adapter liquidity", + "adapter", t.Adapter.Hex(), "fundable", st.fundable.String(), "openRequests", st.openCount, + "maxAssets", st.maxAssets.String(), "minAssets", st.minAssets.String(), + "minYieldBps", st.minYieldBps.String()) + offerings = append(offerings, &adapterOffering{target: t, st: st}) + } + if len(offerings) == 0 { + return // every adapter's liquidity read failed this pass + } -// offerForTarget reads the target's vault/adapter liquidity and exposure once, then bids on each -// matching auction. `committed`/`opened` accumulate this pass's offers so successive bids see the -// reduced capacity — preserving the no-over-commit guarantee without re-reading per auction. -func (s *Solver) offerForTarget(ctx context.Context, target Target, auctions []threef.AuctionDto) { - // One multicall fetches liquidity, live exposure, the open-loan count, and the adapter's caps. - st, err := s.reader.liquidityAndExposure(ctx, target.Vault, target.Adapter) + now := time.Now() + s.offers.pruneExpired(now) // keep the dedup map bounded + input := buildStrategyInput(auctions, offerings, s.offers, now) + if len(input.Auctions) == 0 { + return // no open, offerable auctions this pass + } + out, err := s.strategy.DecideOffers(ctx, input) if err != nil { - s.log.Error(err, "offer: liquidity/exposure", "adapter", target.Adapter.Hex()) + s.log.Error(err, "offer: strategy") return } - s.log.V(1).Info("target liquidity", - "adapter", target.Adapter.Hex(), "vault", target.Vault.Hex(), - "fundable", st.fundable.String(), "outstanding", st.outstanding.String(), "openLoans", st.openCount, - "perRequestMax", st.perRequestMax.String(), "totalMax", st.totalMax.String(), - "minYieldBps", st.minYieldBps.String(), "maxConcurrent", st.maxConcurrent) - - committed := new(big.Int) - opened := 0 - now := time.Now() - s.offers.pruneExpired(now) // drop expired offer-tracking entries so the map stays bounded - for i := range auctions { - av := auctionView{auctions[i]} - auctionID := int64(av.dto.Id) - - // Asset gate: the auction's deposit asset must equal this target vault's collateral. - if !av.matchesAsset(target.Collateral) { - s.log.V(1).Info("skip auction: deposit asset != target collateral", "auctionId", auctionID, - "depositAsset", av.depositAsset(), "collateral", target.Collateral.Hex()) - continue - } - // From here on the auction concerns this target, so decisions are logged at info for visibility. - if !av.isOpen() { - s.log.Info("skip auction: status not open/solvable", "auctionId", auctionID, "status", av.dto.Status) - continue - } - - request := av.requestAddr() - if request == (common.Address{}) { - s.log.Info("skip auction: missing/invalid requestId", "auctionId", auctionID, "requestId", av.dto.RequestId) - continue - } - - // Skip auctions we already hold a live (unexpired) offer for. Expired entries fall through so - // we re-offer. - if s.offers.hasLive(auctionID, now) { - s.log.Info("skip auction: live offer already outstanding", "auctionId", auctionID) - continue - } - - principal, ok := sizeOffer(sizeInputs{ - perRequestMax: st.perRequestMax, - fundable: new(big.Int).Sub(st.fundable, committed), - amountRequested: av.amountRequested(), - sleeveMax: st.totalMax, - outstanding: new(big.Int).Add(st.outstanding, committed), - openCount: st.openCount + opened, - maxConcurrent: st.maxConcurrent, - }) + auctionByID := auctionViewsByID(auctions) + for _, offer := range out.Offers { + av, ok := auctionByID[offer.AuctionID] if !ok { - s.log.Info("skip auction: not biddable under policy/liquidity", "auctionId", auctionID, - "request", request.Hex(), "fundableRemaining", new(big.Int).Sub(st.fundable, committed).String(), - "openLoans", st.openCount+opened, "maxConcurrent", st.maxConcurrent) + s.log.Error(errors.Errorf("auction %d not found", offer.AuctionID), "offer: build") continue } - - // The adapter configured for this target is the offer maker (validated via EIP-1271). - dto, bid, buildErr := s.buildSignedOffer(av, request, target.Adapter, principal, st.minYieldBps) + dto, buildErr := s.buildSignedOffer(av, offer) if buildErr != nil { - s.log.Error(buildErr, "offer: build", "request", request.Hex()) - continue - } - if !bid { - s.log.Info("skip auction: rate below on-chain return floor", "auctionId", auctionID, - "request", request.Hex(), "maxRateBps", av.maxRate(), "minYieldBps", st.minYieldBps.String()) + s.log.Error(buildErr, "offer: build", "auctionId", offer.AuctionID, "adapter", offer.Maker.Hex()) continue } if subErr := s.api.createOffer(ctx, dto); subErr != nil { - s.log.Error(subErr, "offer: submit", "request", request.Hex()) + s.log.Error(subErr, "offer: submit", "auctionId", offer.AuctionID, "adapter", offer.Maker.Hex()) continue } - committed.Add(committed, principal) - opened++ - // Record so we don't re-offer until this offer expires. if exp, perr := parseUnixTime(dto.Expiration); perr == nil { - s.offers.record(auctionID, exp) + s.offers.record(offer.Maker, offer.AuctionID, exp, offer.Principal) } - s.log.Info("offer submitted", - "request", request.Hex(), "principal", principal.String(), "expectedReturn", dto.ExpectedReturn) + s.log.Info("offer submitted", "auctionId", offer.AuctionID, "adapter", offer.Maker.Hex(), + "request", offer.Request.Hex(), "principal", offer.Principal.String(), "expectedReturn", dto.ExpectedReturn) } } -// ensureOfferAddress makes the 3F-registered facilitator offer-address match our maker (the target -// adapter). The offer-address is a facilitator-level singleton — the reason this solver serves a -// single vault+adapter pair. Read/write failures are returned so the caller can degrade to -// redeem-only. -func (s *Solver) ensureOfferAddress(ctx context.Context) error { - desired := s.cfg.Target.Adapter - current, err := s.api.offerAddress(ctx) - if err != nil { - return errors.Errorf("read offer-address: %w", err) - } - if current == desired { - return nil - } - if err := s.api.setOfferAddress(ctx, desired); err != nil { - return errors.Errorf("set offer-address to %s: %w", desired.Hex(), err) - } - s.log.Info("registered facilitator offer-address", "offerAddress", desired.Hex(), "previous", current.Hex()) - return nil -} - -// redeemAll runs the redeemer for the configured target. +// redeemAll runs the redeemer for every matched adapter. func (s *Solver) redeemAll(ctx context.Context) { - s.redeemReady(ctx, s.cfg.Target) + for _, t := range s.cfg.Targets { + s.redeemReady(ctx, t) + } } -// reconcile reports the live open-position set — a stateless health/observability tick. +// reconcile reports each adapter's live open-position set — a stateless health/observability tick. func (s *Solver) reconcile(ctx context.Context) { - target := s.cfg.Target - st, err := s.reader.liquidityAndExposure(ctx, target.Vault, target.Adapter) - if err != nil { - s.log.Error(err, "reconcile", "adapter", target.Adapter.Hex()) - return + for _, t := range s.cfg.Targets { + st, err := s.reader.liquidityAndExposure(ctx, t.Adapter) + if err != nil { + s.log.Error(err, "reconcile", "adapter", t.Adapter.Hex()) + continue + } + s.log.Info("reconcile", "adapter", t.Adapter.Hex(), + "openRequests", st.openCount, "fundable", st.fundable.String()) } - s.log.Info("reconcile", "adapter", target.Adapter.Hex(), - "openLoans", st.openCount, "outstandingPrincipal", st.outstanding.String()) } // nextNonce returns a strictly-increasing offer nonce. @@ -315,23 +251,43 @@ func (s *Solver) nextNonce() uint64 { return s.nonceSeq.Add(1) } -// resolveTarget reads the adapter's vault and the vault's collateral asset once at startup. Both are -// fixed for the adapter's lifetime, so config only carries the adapter address. On a read failure the -// fields stay zero (no auction matches; offers disabled) but redemption still runs off the adapter. -func (s *Solver) resolveTarget(ctx context.Context) { - t := &s.cfg.Target - vault, err := s.reader.adapterVault(ctx, t.Adapter) - if err != nil { - s.log.Error(err, "resolve adapter vault; will match no auctions until restart", "adapter", t.Adapter.Hex()) - return +// resolveTargets resolves every adapter's vault, collateral, and EIP-1271 signer at startup (two +// batched Multicalls via reader.resolveAdapters) and keeps only the adapters that resolved and have +// this solver as their on-chain offerSigner — the rest are dropped with a warning. If none remain, +// it returns a startup error. +func (s *Solver) resolveTargets(ctx context.Context) error { + adapters := make([]common.Address, len(s.cfg.Targets)) + for i := range s.cfg.Targets { + adapters[i] = s.cfg.Targets[i].Adapter } - t.Vault = vault - collateral, err := s.reader.vaultAsset(ctx, vault) + resolved, err := s.reader.resolveAdapters(ctx, adapters) if err != nil { - s.log.Error(err, "resolve collateral; will match no auctions until restart", "vault", vault.Hex()) - return + return err // whole-batch transport/RPC failure, not a per-adapter revert } - t.Collateral = collateral - s.log.Info("resolved target", - "adapter", t.Adapter.Hex(), "vault", vault.Hex(), "collateral", collateral.Hex()) + + kept := make([]Target, 0, len(s.cfg.Targets)) + for i, t := range s.cfg.Targets { + r := resolved[i] + if r.err != nil { + s.log.Error(r.err, "skipping adapter: resolution failed", "adapter", t.Adapter.Hex()) + continue + } + if r.signer != s.signerAddr { + s.log.Info("skipping adapter: solver is not its EIP-1271 signer", + "adapter", t.Adapter.Hex(), + "want", s.signerAddr.Hex(), + "got", r.signer.Hex()) + continue + } + t.Vault, t.Collateral = r.vault, r.collateral + s.log.Info("resolved target", + "adapter", t.Adapter.Hex(), "vault", r.vault.Hex(), "collateral", r.collateral.Hex()) + kept = append(kept, t) + } + + s.cfg.Targets = kept + if len(s.cfg.Targets) == 0 { + return errors.Errorf("no configured adapter passed startup validation (must resolve and have this solver as its EIP-1271 signer, want %s); see per-adapter warnings above", s.signerAddr.Hex()) + } + return nil } diff --git a/internal/solvers/bridgefacilitator/strategies/default/strategy.go b/internal/solvers/bridgefacilitator/strategies/default/strategy.go new file mode 100644 index 00000000..e2f2586c --- /dev/null +++ b/internal/solvers/bridgefacilitator/strategies/default/strategy.go @@ -0,0 +1,196 @@ +package defaultstrategy + +import ( + "context" + "math/big" + "sort" + + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/solver" + "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies" + "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies/types" +) + +const Name = "default" + +type Config struct{} + +type Strategy struct{} + +//nolint:gochecknoinits // solver-local strategy self-registration mirrors solver registration. +func init() { + strategies.Register(Name, NewFromConfig) +} + +func NewFromConfig(raw yaml.Node, _ strategies.Deps) (types.Strategy, error) { + var cfg Config + if err := decodeConfig(raw, &cfg); err != nil { + return nil, err + } + return New(), nil +} + +func New() *Strategy { + return &Strategy{} +} + +func decodeConfig(node yaml.Node, out any) error { + if node.Kind == 0 { + node = yaml.Node{Kind: yaml.MappingNode} + } + return solver.DecodeStrict(node, out) +} + +func (s *Strategy) DecideOffers( + _ context.Context, + input types.OfferInput, +) (types.OfferOutput, error) { + order := make([]*adapterState, 0, len(input.Adapters)) + for i := range input.Adapters { + order = append(order, &adapterState{snapshot: input.Adapters[i], committed: new(big.Int)}) + } + + live := make(map[liveKey]bool, len(input.LiveOffers)) + for _, l := range input.LiveOffers { + live[liveKey{l.AdapterID, l.AuctionID}] = true + } + + var offers []types.OfferExecution + for _, auction := range input.Auctions { + remaining := cloneBig(auction.RemainingAmount) + if remaining == nil || remaining.Sign() <= 0 { + continue + } + for _, st := range rankEligibleAdapters(auction, order, live) { + if remaining.Sign() <= 0 { + break + } + capacity := st.capacity() + if capacity.Sign() <= 0 { + continue + } + principal := cloneBig(capacity) + if principal.Cmp(remaining) > 0 { + principal.Set(remaining) + } + if st.belowMinAssets(principal) { + continue + } + offers = append(offers, types.OfferExecution{ + AuctionID: auction.AuctionID, + Request: auction.Request, + Maker: st.snapshot.Adapter, + Principal: principal, + ExpectedReturn: types.ExpectedReturn(principal, auction.MaxRateBps), + }) + st.committed.Add(st.committed, principal) + st.opened++ + remaining.Sub(remaining, principal) + } + } + return types.OfferOutput{Offers: offers}, nil +} + +// liveKey dedups an adapter's live offer on a given auction. +type liveKey struct { + adapterID string + auctionID int64 +} + +// rankEligibleAdapters filters adapters eligible to offer on the auction (no live offer, matching +// collateral, meeting the adapter's min-yield floor) and orders them by available capacity, largest +// first. Capacity is computed once per adapter (not inside the comparator). +func rankEligibleAdapters( + auction types.AuctionSnapshot, + order []*adapterState, + live map[liveKey]bool, +) []*adapterState { + type scored struct { + st *adapterState + capacity *big.Int + } + eligible := make([]scored, 0, len(order)) + for _, st := range order { + if live[liveKey{st.snapshot.ID, auction.AuctionID}] { + continue + } + if auction.DepositAsset != st.snapshot.Collateral { + continue + } + if st.snapshot.MinYieldBps != nil && st.snapshot.MinYieldBps.Sign() > 0 && + auction.MaxRateBps < types.BpsToFloat(st.snapshot.MinYieldBps) { + continue + } + eligible = append(eligible, scored{st, st.capacity()}) + } + sort.SliceStable(eligible, func(i, j int) bool { + return eligible[i].capacity.Cmp(eligible[j].capacity) > 0 + }) + ranked := make([]*adapterState, len(eligible)) + for i := range eligible { + ranked[i] = eligible[i].st + } + return ranked +} + +type adapterState struct { + snapshot types.AdapterSnapshot + committed *big.Int + opened int +} + +// capacity is the max principal this adapter can fund for one more request: the smaller of its +// per-request ceiling (min(fundable, maxAssets); maxAssets 0 ⇒ reject-all) and its remaining budget +// (fundable minus this pass's commitments), gated by the concurrency and min-request-size limits. +func (s *adapterState) capacity() *big.Int { + if s.full() || s.snapshot.Fundable == nil { + return new(big.Int) + } + ceiling := new(big.Int).Set(s.snapshot.Fundable) + if s.snapshot.MaxAssets != nil { + ceiling = minBig(ceiling, s.snapshot.MaxAssets) // always-active ceiling; 0 ⇒ no bid + } + if ceiling.Sign() <= 0 { + return new(big.Int) + } + if s.snapshot.MinAssets != nil && ceiling.Cmp(s.snapshot.MinAssets) < 0 { + return new(big.Int) // capacity below the on-chain minimum request size + } + budget := s.remainingBudget() + if budget.Sign() <= 0 { + return new(big.Int) + } + return minBig(ceiling, budget) +} + +func (s *adapterState) full() bool { + return s.snapshot.MaxConcurrent > 0 && s.snapshot.OpenCount+s.opened >= s.snapshot.MaxConcurrent +} + +func (s *adapterState) remainingBudget() *big.Int { + if s.snapshot.Fundable == nil { + return new(big.Int) + } + return new(big.Int).Sub(s.snapshot.Fundable, s.committed) +} + +func (s *adapterState) belowMinAssets(amount *big.Int) bool { + return s.snapshot.MinAssets != nil && s.snapshot.MinAssets.Sign() > 0 && amount.Cmp(s.snapshot.MinAssets) < 0 +} + +func minBig(a, b *big.Int) *big.Int { + if a.Cmp(b) <= 0 { + return new(big.Int).Set(a) + } + return new(big.Int).Set(b) +} + +func cloneBig(n *big.Int) *big.Int { + if n == nil { + return nil + } + return new(big.Int).Set(n) +} + +var _ types.Strategy = (*Strategy)(nil) diff --git a/internal/solvers/bridgefacilitator/strategies/default/strategy_test.go b/internal/solvers/bridgefacilitator/strategies/default/strategy_test.go new file mode 100644 index 00000000..b4ca3dbb --- /dev/null +++ b/internal/solvers/bridgefacilitator/strategies/default/strategy_test.go @@ -0,0 +1,174 @@ +package defaultstrategy + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies/types" +) + +func testAdapter(id byte, fundable int64) types.AdapterSnapshot { + addr := common.Address{id} + return types.AdapterSnapshot{ + ID: addr.Hex(), + Adapter: addr, + Vault: common.Address{0x99, id}, + Collateral: common.Address{0xaa}, + Fundable: big.NewInt(fundable), + MaxAssets: big.NewInt(fundable), + MinAssets: new(big.Int), + MinYieldBps: new(big.Int), + MaxConcurrent: 50, + } +} + +func testAuction(id int64, remaining int64) types.AuctionSnapshot { + return types.AuctionSnapshot{ + ID: big.NewInt(id).String(), + AuctionID: id, + OriginalIndex: int(id), + Request: common.Address{0xbb, byte(id)}, + Status: "open", + DepositAsset: common.Address{0xaa}, + AmountRequested: big.NewInt(remaining), + RemainingAmount: big.NewInt(remaining), + MaxRateBps: 200, + } +} + +func TestStrategyLargestFirstClampsLastOffer(t *testing.T) { + a1 := testAdapter(1, 50) // capacity 50 + a2 := testAdapter(2, 80) // capacity 80 + input := types.OfferInput{ + Now: time.Unix(0, 0), + Adapters: []types.AdapterSnapshot{a1, a2}, + Auctions: []types.AuctionSnapshot{testAuction(10, 100)}, + } + + got, err := New().DecideOffers(t.Context(), input) + if err != nil { + t.Fatalf("DecideOffers: %v", err) + } + if len(got.Offers) != 2 { + t.Fatalf("offers = %d, want 2", len(got.Offers)) + } + if got.Offers[0].Maker != a2.Adapter || got.Offers[0].Principal.Int64() != 80 { + t.Fatalf("offer0 = %+v, want adapter 2 / 80", got.Offers[0]) + } + if got.Offers[1].Maker != a1.Adapter || got.Offers[1].Principal.Int64() != 20 { + t.Fatalf("offer1 = %+v, want adapter 1 / 20", got.Offers[1]) + } + if got.Offers[0].ExpectedReturn.String() != "1" || got.Offers[1].ExpectedReturn.Sign() != 0 { + t.Fatalf("expected returns = %s/%s, want 1/0", got.Offers[0].ExpectedReturn, got.Offers[1].ExpectedReturn) + } +} + +func TestStrategyClampsOfferToAdapterCapacity(t *testing.T) { + a1 := testAdapter(1, 1000) + a1.MaxAssets = big.NewInt(100) // per-request ceiling below fundable ⇒ capacity 100 + input := types.OfferInput{ + Now: time.Unix(0, 0), + Adapters: []types.AdapterSnapshot{a1}, + Auctions: []types.AuctionSnapshot{testAuction(10, 500)}, + } + + got, err := New().DecideOffers(t.Context(), input) + if err != nil { + t.Fatalf("DecideOffers: %v", err) + } + if len(got.Offers) != 1 { + t.Fatalf("offers = %d, want 1", len(got.Offers)) + } + if got.Offers[0].Principal.Int64() != 100 { + t.Fatalf("principal = %s, want adapter capacity 100", got.Offers[0].Principal) + } +} + +func TestStrategyRejectsZeroAdapterCapacity(t *testing.T) { + a1 := testAdapter(1, 1000) + a1.MaxAssets = new(big.Int) // maxAssets 0 ⇒ reject-all + input := types.OfferInput{ + Now: time.Unix(0, 0), + Adapters: []types.AdapterSnapshot{a1}, + Auctions: []types.AuctionSnapshot{testAuction(10, 500)}, + } + + got, err := New().DecideOffers(t.Context(), input) + if err != nil { + t.Fatalf("DecideOffers: %v", err) + } + if len(got.Offers) != 0 { + t.Fatalf("offers = %+v, want none because adapter capacity is zero", got.Offers) + } +} + +func TestStrategyReplaysAdapterCapacityAcrossAuctions(t *testing.T) { + a1 := testAdapter(1, 100) + a1.MaxAssets = big.NewInt(80) + input := types.OfferInput{ + Now: time.Unix(0, 0), + Adapters: []types.AdapterSnapshot{a1}, + Auctions: []types.AuctionSnapshot{ + testAuction(10, 70), + testAuction(11, 70), + }, + } + + got, err := New().DecideOffers(t.Context(), input) + if err != nil { + t.Fatalf("DecideOffers: %v", err) + } + if len(got.Offers) != 2 { + t.Fatalf("offers = %d, want 2", len(got.Offers)) + } + // Auction 10 takes 70 of the 80 ceiling; auction 11 sees only 100-70=30 of budget left. + if got.Offers[0].Principal.Int64() != 70 || got.Offers[1].Principal.Int64() != 30 { + t.Fatalf("principals = %s/%s, want 70/30", got.Offers[0].Principal, got.Offers[1].Principal) + } +} + +func TestStrategySkipsClampedOfferBelowMinAssets(t *testing.T) { + a1 := testAdapter(1, 50) + a1.MinAssets = big.NewInt(20) + a2 := testAdapter(2, 80) + input := types.OfferInput{ + Now: time.Unix(0, 0), + Adapters: []types.AdapterSnapshot{a1, a2}, + Auctions: []types.AuctionSnapshot{testAuction(10, 90)}, + } + + got, err := New().DecideOffers(t.Context(), input) + if err != nil { + t.Fatalf("DecideOffers: %v", err) + } + // a2 (80) fills first, leaving 10 for a1 — below a1's min-request size of 20, so a1 is skipped. + if len(got.Offers) != 1 || got.Offers[0].Maker != a2.Adapter || got.Offers[0].Principal.Int64() != 80 { + t.Fatalf("offers = %+v, want only adapter 2 / 80", got.Offers) + } +} + +func TestStrategyOwnsEligibility(t *testing.T) { + a1 := testAdapter(1, 100) // filtered by an existing live offer + a2 := testAdapter(2, 100) + a2.Collateral = common.Address{0xbb} // collateral mismatch + a3 := testAdapter(3, 100) + a3.MinYieldBps = big.NewInt(300) // min-yield above the auction's max rate (200) + auction := testAuction(10, 100) + input := types.OfferInput{ + Now: time.Unix(0, 0), + Adapters: []types.AdapterSnapshot{a1, a2, a3}, + Auctions: []types.AuctionSnapshot{auction}, + LiveOffers: []types.LiveOffer{{AdapterID: a1.ID, AuctionID: 10}}, + } + + got, err := New().DecideOffers(t.Context(), input) + if err != nil { + t.Fatalf("DecideOffers: %v", err) + } + if len(got.Offers) != 0 { + t.Fatalf("offers = %+v, want none: live, collateral, and min-yield filters are strategy-owned", got.Offers) + } +} diff --git a/internal/solvers/bridgefacilitator/strategies/registry.go b/internal/solvers/bridgefacilitator/strategies/registry.go new file mode 100644 index 00000000..14a5d363 --- /dev/null +++ b/internal/solvers/bridgefacilitator/strategies/registry.go @@ -0,0 +1,56 @@ +package strategies + +import ( + "sort" + "sync" + + "github.com/go-errors/errors" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies/types" +) + +type Deps struct{} + +type Factory func(raw yaml.Node, deps Deps) (types.Strategy, error) + +var ( + mu sync.RWMutex + registry = map[string]Factory{} +) + +func Register(name string, f Factory) { + mu.Lock() + defer mu.Unlock() + if name == "" { + panic("3F strategy: Register called with empty name") + } + if f == nil { + panic("3F strategy: Register called with nil factory for " + name) + } + if _, dup := registry[name]; dup { + panic("3F strategy: duplicate registration for " + name) + } + registry[name] = f +} + +func New(name string, raw yaml.Node, deps Deps) (types.Strategy, error) { + mu.RLock() + f, ok := registry[name] + mu.RUnlock() + if !ok { + return nil, errors.Errorf("unknown 3F strategy %q (registered: %v)", name, Registered()) + } + return f(raw, deps) +} + +func Registered() []string { + mu.RLock() + defer mu.RUnlock() + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/internal/solvers/bridgefacilitator/strategies/types/math.go b/internal/solvers/bridgefacilitator/strategies/types/math.go new file mode 100644 index 00000000..29ad3f9c --- /dev/null +++ b/internal/solvers/bridgefacilitator/strategies/types/math.go @@ -0,0 +1,22 @@ +package types + +import "math/big" + +// RateDenominatorBps converts a basis-point rate to a fraction (10_000 = 100%). +const RateDenominatorBps = 10_000.0 + +// ExpectedReturn derives the absolute expected return for principal at rateBps basis points. 3F +// maxRate is expressed in bps with tenths-of-a-basis-point precision, so the denominator is 10_000. +// The result truncates down, keeping the offer at or below the requested rate. +func ExpectedReturn(principal *big.Int, rateBps float64) *big.Int { + num := new(big.Float).Mul(new(big.Float).SetInt(principal), big.NewFloat(rateBps)) + num.Quo(num, big.NewFloat(RateDenominatorBps)) + out, _ := num.Int(nil) + return out +} + +// BpsToFloat converts an integer bps value to float64 for comparison against auction maxRate. +func BpsToFloat(n *big.Int) float64 { + f, _ := new(big.Float).SetInt(n).Float64() + return f +} diff --git a/internal/solvers/bridgefacilitator/strategies/types/math_test.go b/internal/solvers/bridgefacilitator/strategies/types/math_test.go new file mode 100644 index 00000000..e88743c0 --- /dev/null +++ b/internal/solvers/bridgefacilitator/strategies/types/math_test.go @@ -0,0 +1,16 @@ +package types + +import ( + "math/big" + "testing" +) + +func TestExpectedReturn(t *testing.T) { + // 100,000 USDC (6 decimals) at 200 bps (2%) => 2,000 USDC. + principal := new(big.Int).SetUint64(100_000_000_000) + got := ExpectedReturn(principal, 200) + want := new(big.Int).SetUint64(2_000_000_000) + if got.Cmp(want) != 0 { + t.Fatalf("expected %s, got %s", want, got) + } +} diff --git a/internal/solvers/bridgefacilitator/strategies/types/types.go b/internal/solvers/bridgefacilitator/strategies/types/types.go new file mode 100644 index 00000000..12f78ee4 --- /dev/null +++ b/internal/solvers/bridgefacilitator/strategies/types/types.go @@ -0,0 +1,74 @@ +// Package types defines the 3F-local strategy contract. +package types + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +type Strategy interface { + DecideOffers(ctx context.Context, input OfferInput) (OfferOutput, error) +} + +// OfferInput is the 3F strategy decision snapshot. It is intentionally solver-local. The solver only +// supplies raw facts — adapter liquidity/caps, open auctions, and the live offers it already holds; the +// strategy owns every decision (sizing, selection, dedup) built from them. +type OfferInput struct { + Now time.Time + Adapters []AdapterSnapshot + Auctions []AuctionSnapshot + LiveOffers []LiveOffer +} + +type AdapterSnapshot struct { + ID string + + Adapter common.Address + Vault common.Address + Collateral common.Address + + Fundable *big.Int + OpenCount int + MaxAssets *big.Int + MinAssets *big.Int + MinYieldBps *big.Int + MaxConcurrent int +} + +type AuctionSnapshot struct { + ID string + AuctionID int64 + OriginalIndex int + + Request common.Address + Status string + DepositAsset common.Address + + AmountRequested *big.Int + RemainingAmount *big.Int + MaxRateBps float64 +} + +// LiveOffer is one offer the solver already holds through an adapter on an auction. The strategy uses +// these to avoid re-offering through the same adapter while one is live. +type LiveOffer struct { + AdapterID string + AuctionID int64 +} + +type OfferOutput struct { + Offers []OfferExecution +} + +// OfferExecution is the trusted strategy's execution output. The solver only signs and submits it. +type OfferExecution struct { + AuctionID int64 + Request common.Address + Maker common.Address + Principal *big.Int + ExpectedReturn *big.Int + Reason string +} diff --git a/internal/solvers/bridgefacilitator/strategies/types/wire_json.go b/internal/solvers/bridgefacilitator/strategies/types/wire_json.go new file mode 100644 index 00000000..06b32340 --- /dev/null +++ b/internal/solvers/bridgefacilitator/strategies/types/wire_json.go @@ -0,0 +1,147 @@ +package types + +import ( + "bytes" + "encoding/json" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" +) + +// 3F webhook JSON wire contract: big integers are decimal strings, and strategy responses reject +// unknown fields so remote deciders fail closed on schema drift. +type offerInputJSON struct { + Now time.Time `json:"now"` + Adapters []adapterSnapshotJSON `json:"adapters"` + Auctions []auctionSnapshotJSON `json:"auctions"` + LiveOffers []liveOfferJSON `json:"liveOffers"` +} + +type adapterSnapshotJSON struct { + ID string `json:"id"` + + Adapter common.Address `json:"adapter"` + Vault common.Address `json:"vault"` + Collateral common.Address `json:"collateral"` + + Fundable string `json:"fundable"` + OpenCount int `json:"openCount"` + MaxAssets string `json:"maxAssets"` + MinAssets string `json:"minAssets"` + MinYieldBps string `json:"minYieldBps"` + MaxConcurrent int `json:"maxConcurrent"` +} + +type auctionSnapshotJSON struct { + ID string `json:"id"` + AuctionID int64 `json:"auctionId"` + OriginalIndex int `json:"originalIndex"` + + Request common.Address `json:"request"` + Status string `json:"status"` + DepositAsset common.Address `json:"depositAsset"` + + AmountRequested string `json:"amountRequested"` + RemainingAmount string `json:"remainingAmount"` + MaxRateBps float64 `json:"maxRateBps"` +} + +type liveOfferJSON struct { + AdapterID string `json:"adapterId"` + AuctionID int64 `json:"auctionId"` +} + +type offerOutputJSON struct { + Offers []offerExecutionJSON `json:"offers"` +} + +type offerExecutionJSON struct { + AuctionID int64 `json:"auctionId"` + Request common.Address `json:"request"` + Maker common.Address `json:"maker"` + Principal string `json:"principal"` + ExpectedReturn string `json:"expectedReturn"` + Reason string `json:"reason"` +} + +func (in OfferInput) MarshalJSON() ([]byte, error) { + adapters := make([]adapterSnapshotJSON, 0, len(in.Adapters)) + for _, a := range in.Adapters { + adapters = append(adapters, adapterSnapshotJSON{ + ID: a.ID, Adapter: a.Adapter, Vault: a.Vault, Collateral: a.Collateral, + Fundable: bigString(a.Fundable), + OpenCount: a.OpenCount, + MaxAssets: bigString(a.MaxAssets), + MinAssets: bigString(a.MinAssets), + MinYieldBps: bigString(a.MinYieldBps), + MaxConcurrent: a.MaxConcurrent, + }) + } + auctions := make([]auctionSnapshotJSON, 0, len(in.Auctions)) + for _, a := range in.Auctions { + auctions = append(auctions, auctionSnapshotJSON{ + ID: a.ID, AuctionID: a.AuctionID, OriginalIndex: a.OriginalIndex, + Request: a.Request, Status: a.Status, DepositAsset: a.DepositAsset, + AmountRequested: bigString(a.AmountRequested), + RemainingAmount: bigString(a.RemainingAmount), + MaxRateBps: a.MaxRateBps, + }) + } + liveOffers := make([]liveOfferJSON, 0, len(in.LiveOffers)) + for _, l := range in.LiveOffers { + liveOffers = append(liveOffers, liveOfferJSON(l)) + } + return json.Marshal(offerInputJSON{ + Now: in.Now, Adapters: adapters, Auctions: auctions, LiveOffers: liveOffers, + }) +} + +func (out *OfferOutput) UnmarshalJSON(b []byte) error { + var raw offerOutputJSON + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(&raw); err != nil { + return err + } + offers := make([]OfferExecution, 0, len(raw.Offers)) + for i, o := range raw.Offers { + principal, err := parseBigString(o.Principal, "offers.principal") + if err != nil { + return errors.Errorf("offer %d: %w", i, err) + } + expectedReturn, err := parseBigString(o.ExpectedReturn, "offers.expectedReturn") + if err != nil { + return errors.Errorf("offer %d: %w", i, err) + } + offers = append(offers, OfferExecution{ + AuctionID: o.AuctionID, + Request: o.Request, + Maker: o.Maker, + Principal: principal, + ExpectedReturn: expectedReturn, + Reason: o.Reason, + }) + } + *out = OfferOutput{Offers: offers} + return nil +} + +func bigString(n *big.Int) string { + if n == nil { + return "" + } + return n.String() +} + +func parseBigString(s, field string) (*big.Int, error) { + if s == "" { + return nil, nil + } + n, ok := new(big.Int).SetString(s, 10) + if !ok || n.Sign() < 0 { + return nil, errors.Errorf("%s: invalid decimal string %q", field, s) + } + return n, nil +} diff --git a/internal/solvers/bridgefacilitator/strategies/types/wire_json_test.go b/internal/solvers/bridgefacilitator/strategies/types/wire_json_test.go new file mode 100644 index 00000000..36b7936e --- /dev/null +++ b/internal/solvers/bridgefacilitator/strategies/types/wire_json_test.go @@ -0,0 +1,111 @@ +package types + +import ( + "encoding/json" + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +func mustBig(t *testing.T, s string) *big.Int { + t.Helper() + n, ok := new(big.Int).SetString(s, 10) + if !ok { + t.Fatalf("bad big.Int %q", s) + } + return n +} + +func TestOfferInputMarshalJSONWireShape(t *testing.T) { + input := OfferInput{ + Now: time.Unix(1, 0).UTC(), + Adapters: []AdapterSnapshot{{ + ID: "adapter-1", + Adapter: common.HexToAddress("0x0000000000000000000000000000000000000001"), + Vault: common.HexToAddress("0x0000000000000000000000000000000000000002"), + Collateral: common.HexToAddress("0x0000000000000000000000000000000000000003"), + Fundable: mustBig(t, "1000"), + OpenCount: 1, + MaxAssets: mustBig(t, "500"), + MinAssets: mustBig(t, "100"), + MinYieldBps: mustBig(t, "100"), + MaxConcurrent: 3, + }}, + Auctions: []AuctionSnapshot{{ + ID: "10", + AuctionID: 10, + OriginalIndex: 0, + Request: common.HexToAddress("0x0000000000000000000000000000000000000010"), + Status: "open", + DepositAsset: common.HexToAddress("0x0000000000000000000000000000000000000003"), + AmountRequested: mustBig(t, "900"), + RemainingAmount: mustBig(t, "700"), + MaxRateBps: 200, + }}, + LiveOffers: []LiveOffer{{AdapterID: "adapter-1", AuctionID: 10}}, + } + + body, err := json.Marshal(input) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if strings.Contains(string(body), "Fundable") || !strings.Contains(string(body), `"fundable":"1000"`) { + t.Fatalf("JSON does not use lower-camel decimal-string amounts: %s", body) + } + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatalf("Unmarshal raw: %v", err) + } + adapters := raw["adapters"].([]any) + adapter := adapters[0].(map[string]any) + if adapter["maxAssets"] != "500" || adapter["minAssets"] != "100" { + t.Fatalf("adapter amounts not decimal strings: %#v", adapter) + } + liveOffers := raw["liveOffers"].([]any) + liveOffer := liveOffers[0].(map[string]any) + if liveOffer["adapterId"] != "adapter-1" || liveOffer["auctionId"].(float64) != 10 { + t.Fatalf("liveOffer wire shape: %#v", liveOffer) + } +} + +func TestOfferOutputUnmarshalJSONWireShape(t *testing.T) { + var out OfferOutput + if err := json.Unmarshal([]byte(`{ + "offers": [{ + "auctionId": 10, + "request": "0x0000000000000000000000000000000000000010", + "maker": "0x0000000000000000000000000000000000000001", + "principal": "500", + "expectedReturn": "10", + "reason": "largest" + }] + }`), &out); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if len(out.Offers) != 1 || + out.Offers[0].AuctionID != 10 || + out.Offers[0].Principal.String() != "500" || + out.Offers[0].ExpectedReturn.String() != "10" || + out.Offers[0].Reason != "largest" { + t.Fatalf("unexpected output: %+v", out) + } +} + +func TestOfferOutputUnmarshalJSONRejectsUnknownFields(t *testing.T) { + var out OfferOutput + err := json.Unmarshal([]byte(`{"offers":[],"extra":1}`), &out) + if err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("Unmarshal error = %v, want unknown field rejection", err) + } +} + +func TestOfferOutputUnmarshalJSONRejectsInvalidDecimal(t *testing.T) { + var out OfferOutput + err := json.Unmarshal([]byte(`{"offers":[{"auctionId":10,"principal":"nan","expectedReturn":"1"}]}`), &out) + if err == nil || !strings.Contains(err.Error(), "principal") { + t.Fatalf("Unmarshal error = %v, want principal decimal rejection", err) + } +} diff --git a/internal/solvers/bridgefacilitator/strategies/webhook/strategy.go b/internal/solvers/bridgefacilitator/strategies/webhook/strategy.go new file mode 100644 index 00000000..353df8c3 --- /dev/null +++ b/internal/solvers/bridgefacilitator/strategies/webhook/strategy.go @@ -0,0 +1,51 @@ +package webhookstrategy + +import ( + "context" + + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies" + "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies/types" + "github.com/symbioticfi/vault-solver/internal/webhook" +) + +const Name = "webhook" + +type Strategy struct { + client *webhook.Client +} + +//nolint:gochecknoinits // solver-local strategy self-registration mirrors solver registration. +func init() { + strategies.Register(Name, NewFromConfig) +} + +func NewFromConfig(raw yaml.Node, _ strategies.Deps) (types.Strategy, error) { + cfg, err := webhook.ParseConfig(raw) + if err != nil { + return nil, err + } + client, err := webhook.NewClient(cfg) + if err != nil { + return nil, err + } + return New(client), nil +} + +func New(client *webhook.Client) *Strategy { + return &Strategy{client: client} +} + +func (s *Strategy) DecideOffers( + ctx context.Context, + input types.OfferInput, +) (types.OfferOutput, error) { + var out types.OfferOutput + if err := s.client.PostJSON(ctx, input, &out); err != nil { + return types.OfferOutput{}, err + } + return out, nil +} + +var _ types.Strategy = (*Strategy)(nil) diff --git a/internal/solvers/bridgefacilitator/strategy.go b/internal/solvers/bridgefacilitator/strategy.go new file mode 100644 index 00000000..34a255c8 --- /dev/null +++ b/internal/solvers/bridgefacilitator/strategy.go @@ -0,0 +1,132 @@ +package bridgefacilitator + +import ( + "math/big" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/api/threef" + "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies" + _ "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies/default" + "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies/types" + _ "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies/webhook" +) + +func newStrategy(spec StrategyConfig) (types.Strategy, error) { + name := spec.Name + if name == "" { + name = defaultStrategyName + } + return strategies.New(name, spec.Config, strategies.Deps{}) +} + +// buildStrategyInput converts the solver-owned API/on-chain snapshot into the compact strategy request. +func buildStrategyInput( + auctions []threef.AuctionDto, + offerings []*adapterOffering, + offers *offerTracker, + now time.Time, +) types.OfferInput { + adapters := make([]types.AdapterSnapshot, 0, len(offerings)) + for _, off := range offerings { + adapters = append(adapters, types.AdapterSnapshot{ + ID: adapterID(off.target.Adapter), + Adapter: off.target.Adapter, + Vault: off.target.Vault, + Collateral: off.target.Collateral, + Fundable: cloneBig(off.st.fundable), + OpenCount: off.st.openCount, + MaxAssets: cloneBig(off.st.maxAssets), + MinAssets: cloneBig(off.st.minAssets), + MinYieldBps: cloneBig(off.st.minYieldBps), + MaxConcurrent: maxRequests, + }) + } + + input := types.OfferInput{Now: now, Adapters: adapters} + for i := range auctions { + av := auctionView{auctions[i]} + auction, ok := buildAuctionSnapshot(av, i, offers, now) + if !ok { + continue + } + input.Auctions = append(input.Auctions, auction) + } + for _, k := range offers.liveEntries(now) { + input.LiveOffers = append(input.LiveOffers, types.LiveOffer{ + AdapterID: adapterID(k.adapter), + AuctionID: k.auction, + }) + } + return input +} + +func auctionViewsByID(auctions []threef.AuctionDto) map[int64]auctionView { + views := make(map[int64]auctionView, len(auctions)) + for i := range auctions { + av := auctionView{auctions[i]} + views[int64(av.dto.Id)] = av + } + return views +} + +func buildAuctionSnapshot( + av auctionView, + originalIndex int, + offers *offerTracker, + now time.Time, +) (types.AuctionSnapshot, bool) { + auctionID := int64(av.dto.Id) + if !av.isOpen() { + return types.AuctionSnapshot{}, false + } + request := av.requestAddr() + if request == (common.Address{}) { + return types.AuctionSnapshot{}, false + } + amountRequested := av.amountRequested() + if amountRequested == nil || amountRequested.Sign() <= 0 { + return types.AuctionSnapshot{}, false + } + rateBps, rateOk := av.maxRateBps() + if !rateOk { + return types.AuctionSnapshot{}, false + } + depositAsset := av.depositAsset() + if !common.IsHexAddress(depositAsset) { + return types.AuctionSnapshot{}, false + } + remaining := new(big.Int).Sub(amountRequested, offers.liveCoverage(auctionID, now)) + if remaining.Sign() < 0 { + remaining = new(big.Int) + } + return types.AuctionSnapshot{ + ID: auctionIDString(auctionID), + AuctionID: auctionID, + OriginalIndex: originalIndex, + Request: request, + Status: av.dto.Status, + DepositAsset: common.HexToAddress(depositAsset), + AmountRequested: cloneBig(amountRequested), + RemainingAmount: remaining, + MaxRateBps: rateBps, + }, true +} + +func adapterID(adapter common.Address) string { + return strings.ToLower(adapter.Hex()) +} + +func auctionIDString(auctionID int64) string { + return strconv.FormatInt(auctionID, 10) +} + +func cloneBig(n *big.Int) *big.Int { + if n == nil { + return nil + } + return new(big.Int).Set(n) +} diff --git a/internal/solvers/bridgefacilitator/strategy_test.go b/internal/solvers/bridgefacilitator/strategy_test.go new file mode 100644 index 00000000..7faa2714 --- /dev/null +++ b/internal/solvers/bridgefacilitator/strategy_test.go @@ -0,0 +1,162 @@ +package bridgefacilitator + +import ( + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/api/threef" + "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies" + "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies/types" + webhookstrategy "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies/webhook" + "github.com/symbioticfi/vault-solver/internal/webhook" +) + +func mustBig(t *testing.T, s string) *big.Int { + t.Helper() + n, ok := new(big.Int).SetString(s, 10) + if !ok { + t.Fatalf("bad big.Int %q", s) + } + return n +} + +func baseOfferInput(t *testing.T) types.OfferInput { + t.Helper() + adapter := common.HexToAddress("0x0000000000000000000000000000000000000001") + return types.OfferInput{ + Now: time.Unix(0, 0), + Adapters: []types.AdapterSnapshot{{ + ID: adapterID(adapter), + Adapter: adapter, + Vault: common.HexToAddress("0x0000000000000000000000000000000000000002"), + Collateral: common.HexToAddress("0x0000000000000000000000000000000000000003"), + Fundable: mustBig(t, "1000"), + MaxAssets: mustBig(t, "800"), + MinAssets: new(big.Int), + MinYieldBps: new(big.Int), + MaxConcurrent: maxRequests, + }}, + Auctions: []types.AuctionSnapshot{{ + ID: "10", + AuctionID: 10, + OriginalIndex: 0, + Request: common.HexToAddress("0x0000000000000000000000000000000000000010"), + Status: "open", + DepositAsset: common.HexToAddress("0x0000000000000000000000000000000000000003"), + AmountRequested: mustBig(t, "700"), + RemainingAmount: mustBig(t, "700"), + MaxRateBps: 200, + }}, + } +} + +func TestStrategyRegistryUsesBuiltIns(t *testing.T) { + got, err := newStrategy(StrategyConfig{Name: "default"}) + if err != nil { + t.Fatalf("newStrategy default: %v", err) + } + if got == nil { + t.Fatal("newStrategy default returned nil") + } + names := strategies.Registered() + if len(names) != 2 || names[0] != "default" || names[1] != "webhook" { + t.Fatalf("registered strategies = %v, want [default webhook]", names) + } +} + +func TestBuildStrategyInputKeepsFullyCoveredAuctions(t *testing.T) { + now := time.Unix(100, 0) + adapter := common.HexToAddress("0x0000000000000000000000000000000000000001") + collateral := common.HexToAddress("0x0000000000000000000000000000000000000003") + offers := newOfferTracker() + offers.record(adapter, 10, now.Add(time.Minute), big.NewInt(100)) + + input := buildStrategyInput( + []threef.AuctionDto{testAuctionDto(10, collateral, "100")}, + []*adapterOffering{{ + target: Target{ + Adapter: adapter, + Vault: common.HexToAddress("0x0000000000000000000000000000000000000002"), + Collateral: collateral, + }, + st: exposureState{ + fundable: big.NewInt(100), + maxAssets: big.NewInt(100), + minAssets: new(big.Int), + minYieldBps: new(big.Int), + }, + }}, + offers, + now, + ) + + if len(input.Auctions) != 1 { + t.Fatalf("auctions = %d, want fully covered auction passed to strategy", len(input.Auctions)) + } + if input.Auctions[0].RemainingAmount.Sign() != 0 { + t.Fatalf("remaining = %s, want 0", input.Auctions[0].RemainingAmount) + } + if len(input.LiveOffers) != 1 || + input.LiveOffers[0].AdapterID != adapterID(adapter) || input.LiveOffers[0].AuctionID != 10 { + t.Fatalf("liveOffers = %+v, want the adapter's live offer on auction 10", input.LiveOffers) + } +} + +func TestWebhookStrategyDecodesLowerCamelResponse(t *testing.T) { + input := baseOfferInput(t) + offer := input.Auctions[0] + maker := input.Adapters[0].Adapter + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read request: %v", err) + } + if !strings.Contains(string(body), `"fundable":"1000"`) || strings.Contains(string(body), `"Fundable"`) { + t.Fatalf("request body does not use decimal-string lower-camel JSON: %s", string(body)) + } + _, _ = w.Write([]byte(`{ + "offers": [{ + "auctionId": 10, + "request": "` + offer.Request.Hex() + `", + "maker": "` + maker.Hex() + `", + "principal": "700", + "expectedReturn": "14" + }] + }`)) + })) + defer srv.Close() + client, err := webhook.NewClient(webhook.Config{URL: srv.URL, Timeout: time.Second}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + out, err := webhookstrategy.New(client).DecideOffers(t.Context(), input) + if err != nil { + t.Fatalf("DecideOffers: %v", err) + } + if len(out.Offers) != 1 || out.Offers[0].Principal.String() != "700" || + out.Offers[0].ExpectedReturn.String() != "14" { + t.Fatalf("unexpected webhook output: %+v", out) + } +} + +func testAuctionDto(id int64, depositAsset common.Address, amountRequested string) threef.AuctionDto { + maxRate := float32(200) + request := common.HexToAddress("0x0000000000000000000000000000000000000010") + return threef.AuctionDto{ + Id: float32(id), + RequestId: request.Hex(), + AmountRequested: *threef.NewNullableString(&amountRequested), + MaxRate: *threef.NewNullableFloat32(&maxRate), + Status: "open", + DepositAsset: *threef.NewNullableAuctionDepositAssetDto( + threef.NewAuctionDepositAssetDto(depositAsset.Hex(), "USDC", 6), + ), + } +} diff --git a/internal/solvers/redstoneoev/breaker.go b/internal/solvers/redstoneoev/breaker.go new file mode 100644 index 00000000..01abbe50 --- /dev/null +++ b/internal/solvers/redstoneoev/breaker.go @@ -0,0 +1,62 @@ +package redstoneoev + +import ( + "sync" + "time" +) + +// breaker halts bidding when RedStone blacklists our key, or when too many liquidations fail in a +// rolling window (a revert storm bleeds gas + nonce and risks blacklisting — §6.2). Safe for +// concurrent use; `now` is injected so it's testable. +type breaker struct { + mu sync.Mutex + blacklisted bool + failures []time.Time + maxFailures int + window time.Duration +} + +func newBreaker(maxFailures int, window time.Duration) *breaker { + return &breaker{maxFailures: maxFailures, window: window} +} + +// blacklist permanently trips the breaker (until restart). Called on the `blacklisted` WS frame. +func (b *breaker) blacklist() { + b.mu.Lock() + b.blacklisted = true + b.mu.Unlock() +} + +// recordFailure logs a failed settlement and prunes the window. +func (b *breaker) recordFailure(now time.Time) { + b.mu.Lock() + defer b.mu.Unlock() + b.failures = append(b.failures, now) + b.prune(now) +} + +// tripped reports whether bidding must halt, with a reason. +func (b *breaker) tripped(now time.Time) (bool, string) { + b.mu.Lock() + defer b.mu.Unlock() + if b.blacklisted { + return true, "api key blacklisted" + } + b.prune(now) + if b.maxFailures > 0 && len(b.failures) >= b.maxFailures { + return true, "failed-liquidation rate-limit" + } + return false, "" +} + +// prune drops failures older than the window. Caller holds the lock. +func (b *breaker) prune(now time.Time) { + cutoff := now.Add(-b.window) + keep := b.failures[:0] + for _, t := range b.failures { + if t.After(cutoff) { + keep = append(keep, t) + } + } + b.failures = keep +} diff --git a/internal/solvers/redstoneoev/breaker_test.go b/internal/solvers/redstoneoev/breaker_test.go new file mode 100644 index 00000000..136dc9d7 --- /dev/null +++ b/internal/solvers/redstoneoev/breaker_test.go @@ -0,0 +1,50 @@ +package redstoneoev + +import ( + "testing" + "time" +) + +func TestBreakerBlacklistHalts(t *testing.T) { + b := newBreaker(3, time.Hour) + now := time.Unix(1_000_000, 0) + if tripped, _ := b.tripped(now); tripped { + t.Fatal("fresh breaker must not be tripped") + } + b.blacklist() + tripped, why := b.tripped(now) + if !tripped || why != "api key blacklisted" { + t.Fatalf("blacklist must trip: %v %q", tripped, why) + } +} + +func TestBreakerFailureRateLimit(t *testing.T) { + b := newBreaker(3, time.Hour) + base := time.Unix(2_000_000, 0) + b.recordFailure(base) + b.recordFailure(base.Add(time.Minute)) + if tripped, _ := b.tripped(base.Add(2 * time.Minute)); tripped { + t.Fatal("2 failures < 3 must not trip") + } + b.recordFailure(base.Add(3 * time.Minute)) + tripped, why := b.tripped(base.Add(4 * time.Minute)) + if !tripped || why != "failed-liquidation rate-limit" { + t.Fatalf("3 failures must trip: %v %q", tripped, why) + } +} + +func TestBreakerWindowPrunes(t *testing.T) { + b := newBreaker(3, time.Hour) + base := time.Unix(3_000_000, 0) + b.recordFailure(base) + b.recordFailure(base.Add(time.Minute)) + b.recordFailure(base.Add(2 * time.Minute)) + // All three are within the window -> tripped. + if tripped, _ := b.tripped(base.Add(3 * time.Minute)); !tripped { + t.Fatal("3 in-window failures must trip") + } + // Two hours later they're all pruned -> not tripped. + if tripped, _ := b.tripped(base.Add(2 * time.Hour)); tripped { + t.Fatal("failures older than the window must be pruned") + } +} diff --git a/internal/solvers/redstoneoev/bundle.go b/internal/solvers/redstoneoev/bundle.go new file mode 100644 index 00000000..388d5a99 --- /dev/null +++ b/internal/solvers/redstoneoev/bundle.go @@ -0,0 +1,424 @@ +package redstoneoev + +// bundle.go holds the leg-selection engine that turns scored legs into one priced solve. + +import ( + "cmp" + "maps" + "math/big" + "slices" + "strings" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +const netBundleBeamWidth = 64 + +// bundleLeg is one selected liquidation plus solver-only estimates used after selection. +// LiquidationLeg is embedded intentionally: it stays the callback payload while expectedLoanOut/collateral +// never cross the operationData boundary. +type bundleLeg struct { + LiquidationLeg + + expectedLoanOut *big.Int // solver-local loan-token output estimate; not sent to the callback + collateral common.Address // seized collateral; legs sharing it share the adapter's getMaxAssets pool +} + +// scoredLeg is a liquidatable, sized leg paired with replay source and the cached adapter budget. +type scoredLeg struct { + bundleLeg + + profit *big.Int // loan-token base units + maxAssets *big.Int // cached adapter getMaxAssets budget (loan units; nil ⇒ uncapped) + source evalItem + replay bool +} + +// chosenBundle is the set of legs selected for one solve. Single-token by design: the on-chain callback +// runs every leg against its one immutable LiquidLaneAdapter and a single loan token. +type chosenBundle struct { + legs []bundleLeg + grossLoan *big.Int // Σ leg profit in the loan token's units +} + +type pricedBundle struct { + gas gasPrediction + gasNative *big.Int + bidNative *big.Int + minBundleProfitLoan *big.Int + callbackLegs []LiquidationLeg +} + +// selectBundle is the gross-profit fallback for dry-run/no-rate paths. Live bidding uses selectNetBundle. +// +// Legs sharing collateral also share the adapter's getMaxAssets pool, so selection caps cumulative expected +// loan output per collateral against the cached adapter liquidity. +func (s *Solver) selectBundle(scored []scoredLeg) (chosenBundle, string) { + return s.selectBundleWithGas(scored, nil, 0, defaultPriceUpdateFeeds) +} + +func (s *Solver) selectBundleWithGas(scored []scoredLeg, gasState *gasPredictorState, gasLimit uint64, feedCount int) (chosenBundle, string) { + if len(scored) == 0 { + return chosenBundle{}, skipNoLegs + } + best, ok := s.searchBundle(scored, gasState, gasLimit, feedCount, func(b chosenBundle) *big.Int { + return new(big.Int).Set(b.grossLoan) + }) + if !ok { + return chosenBundle{}, skipNoLegs + } + return best.bundle, "" +} + +// selectNetBundle maximizes bounded after-cost net while preserving deterministic tie-breaks and the shared +// collateral budget. A lower-gross subset can beat a gross-best subset once gas and the bid are priced in. +func (s *Solver) selectNetBundle(scored []scoredLeg, rate *big.Int, gasState *gasPredictorState, gasPrice *big.Int, gasLimit uint64, feedCount int) (chosenBundle, string) { + if len(scored) == 0 { + return chosenBundle{}, skipNoLegs + } + if rate == nil || rate.Sign() <= 0 { + return s.selectBundleWithGas(scored, gasState, gasLimit, feedCount) + } + best, ok := s.searchBundle(scored, gasState, gasLimit, feedCount, func(b chosenBundle) *big.Int { + return s.bundleNetNativeForFeeds(b, rate, gasState, gasPrice, feedCount) + }) + if !ok { + return chosenBundle{}, skipGasUnprofitable + } + bidNative := s.bundleBidNative(best.bundle, rate) + minNative := s.minBundleProfitNative(bidNative) + bestNet := s.bundleNetNativeForFeeds(best.bundle, rate, gasState, gasPrice, feedCount) + if bestNet.Cmp(minNative) < 0 { + return best.bundle, skipGasUnprofitable + } + return best.bundle, "" +} + +type bundleSearchState struct { + bundle chosenBundle + consumed map[common.Address]*big.Int + markets map[common.Hash]bundleMarketState + used map[int]bool + score *big.Int +} + +type bundleMarketState struct { + info MarketInfo + positions map[common.Address]morpho.PositionState +} + +type replayedScoredLeg struct { + scored scoredLeg + marketID common.Hash + market bundleMarketState +} + +func (s *Solver) searchBundle(scored []scoredLeg, gasState *gasPredictorState, gasLimit uint64, feedCount int, scoreFn func(chosenBundle) *big.Int) (bundleSearchState, bool) { + maxDepth := bundleSearchDepth(gasLimit, feedCount) + if maxDepth == 0 { + return bundleSearchState{}, false + } + group := sortedScoredLegs(scored) + start := bundleSearchState{ + bundle: chosenBundle{grossLoan: new(big.Int)}, + consumed: make(map[common.Address]*big.Int), + markets: make(map[common.Hash]bundleMarketState), + used: make(map[int]bool), + score: new(big.Int), + } + beam := []bundleSearchState{start} + best := start + for depth := 0; depth < maxDepth && depth < len(group); depth++ { + nextBeam := make([]bundleSearchState, 0, min(len(group), netBundleBeamWidth)) + for _, state := range beam { + for i, sl := range group { + if state.used[i] { + continue + } + trial, ok := s.extendBundleState(state, sl, i) + if !ok { + continue + } + if !bundleFitsGasLimit(trial.bundle, gasState, gasLimit, feedCount) { + continue + } + trial.score = scoreFn(trial.bundle) + nextBeam = append(nextBeam, trial) + } + } + if len(nextBeam) == 0 { + break + } + slices.SortStableFunc(nextBeam, func(a, b bundleSearchState) int { + return b.score.Cmp(a.score) + }) + if len(nextBeam) > netBundleBeamWidth { + nextBeam = nextBeam[:netBundleBeamWidth] + } + if len(best.bundle.legs) == 0 || nextBeam[0].score.Cmp(best.score) > 0 { + best = nextBeam[0] + } + beam = nextBeam + } + return best, len(best.bundle.legs) > 0 +} + +func bundleSearchDepth(gasLimit uint64, feedCount int) int { + usable := usableBundleGasLimit(gasLimit) + fixed := saturatingAddUint64(fixedGasUnits(feedCount), gasFirstAcquireLeg) + if usable < fixed { + return 0 + } + return 1 + int((usable-fixed)/gasAdditionalAcquireLeg) +} + +func (s *Solver) extendBundleState(state bundleSearchState, sl scoredLeg, idx int) (bundleSearchState, bool) { + next, ok := s.replayScoredLeg(sl, state.markets) + if !ok || !fitsCollateralBudget(state.consumed, next.scored) { + return bundleSearchState{}, false + } + trial := bundleSearchState{ + bundle: cloneBundleWithLeg(state.bundle, next.scored), + consumed: cloneCollateralBudget(state.consumed), + markets: cloneBundleMarkets(state.markets), + used: cloneUsed(state.used), + } + trial.used[idx] = true + if next.marketID != (common.Hash{}) { + trial.markets[next.marketID] = next.market + } + commitCollateralBudget(trial.consumed, next.scored) + return trial, true +} + +func (s *Solver) replayScoredLeg(sl scoredLeg, markets map[common.Hash]bundleMarketState) (replayedScoredLeg, bool) { + if !sl.replay { + return replayedScoredLeg{scored: sl}, true + } + id := sl.source.cand.MarketID + if id == (common.Hash{}) { + return replayedScoredLeg{}, false + } + ms, ok := markets[id] + if !ok { + ms = bundleMarketState{info: cloneMarketInfo(sl.source.cand.Market), positions: make(map[common.Address]morpho.PositionState)} + } + pos, ok := ms.positions[sl.source.cand.Borrower] + if !ok { + pos = clonePositionState(sl.source.cand.Position) + } + cand := sl.source.cand + cand.Market = ms.info + cand.Position = pos + sized, ok := sizeLeg(cand, sl.source.price, sl.source.quote, ms.info.State.TotalBorrowAssets, s.cfg.Sizing) + if !ok { + return replayedScoredLeg{}, false + } + replay, ok := morpho.ApplySeizeLiquidation(ms.info.State, pos, sized.leg.MaxSeizeAssets, sl.source.price) + if !ok { + return replayedScoredLeg{}, false + } + nextMarket := cloneBundleMarketState(ms) + nextMarket.info.State = replay.Market + nextMarket.positions[cand.Borrower] = replay.Position + nextLeg := sl + nextLeg.LiquidationLeg = sized.leg + nextLeg.expectedLoanOut = sized.expectedLoanOut + nextLeg.profit = sized.profit + nextLeg.collateral = cand.Market.Params.CollateralToken + nextLeg.maxAssets = sl.source.quote.MaxAssets + return replayedScoredLeg{scored: nextLeg, marketID: id, market: nextMarket}, true +} + +func sortedScoredLegs(scored []scoredLeg) []scoredLeg { + group := slices.Clone(scored) + slices.SortFunc(group, func(a, b scoredLeg) int { + return cmp.Or( + b.profit.Cmp(a.profit), // higher gross loan profit first + a.MarketId.Cmp(b.MarketId), // then (marketId, borrower) — unique, deterministic + a.Borrower.Cmp(b.Borrower), + ) + }) + return group +} + +func fitsCollateralBudget(consumed map[common.Address]*big.Int, sl scoredLeg) bool { + if sl.maxAssets == nil || sl.maxAssets.Sign() <= 0 { + return true + } + next := new(big.Int).Add(orZero(consumed[sl.collateral]), orZero(sl.expectedLoanOut)) + return next.Cmp(sl.maxAssets) <= 0 +} + +func commitCollateralBudget(consumed map[common.Address]*big.Int, sl scoredLeg) { + if sl.maxAssets == nil || sl.maxAssets.Sign() <= 0 { + return + } + consumed[sl.collateral] = new(big.Int).Add(orZero(consumed[sl.collateral]), orZero(sl.expectedLoanOut)) +} + +func cloneCollateralBudget(in map[common.Address]*big.Int) map[common.Address]*big.Int { + out := make(map[common.Address]*big.Int, len(in)) + for collateral, amount := range in { + out[collateral] = orZero(amount) + } + return out +} + +func cloneBundleMarkets(in map[common.Hash]bundleMarketState) map[common.Hash]bundleMarketState { + out := make(map[common.Hash]bundleMarketState, len(in)) + for id, state := range in { + out[id] = cloneBundleMarketState(state) + } + return out +} + +func cloneBundleMarketState(in bundleMarketState) bundleMarketState { + out := bundleMarketState{info: cloneMarketInfo(in.info), positions: make(map[common.Address]morpho.PositionState, len(in.positions))} + for borrower, position := range in.positions { + out.positions[borrower] = clonePositionState(position) + } + return out +} + +func cloneUsed(in map[int]bool) map[int]bool { + out := make(map[int]bool, len(in)) + maps.Copy(out, in) + return out +} + +func cloneMarketInfo(in MarketInfo) MarketInfo { + in.State = cloneMarketState(in.State) + return in +} + +func cloneMarketState(in morpho.MarketState) morpho.MarketState { + return morpho.MarketState{ + TotalSupplyAssets: cloneBig(in.TotalSupplyAssets), + TotalSupplyShares: cloneBig(in.TotalSupplyShares), + TotalBorrowAssets: cloneBig(in.TotalBorrowAssets), + TotalBorrowShares: cloneBig(in.TotalBorrowShares), + LastUpdate: in.LastUpdate, + Fee: cloneBig(in.Fee), + Lltv: cloneBig(in.Lltv), + BorrowRatePerSec: cloneBig(in.BorrowRatePerSec), + } +} + +func clonePositionState(in morpho.PositionState) morpho.PositionState { + return morpho.PositionState{BorrowShares: cloneBig(in.BorrowShares), Collateral: cloneBig(in.Collateral)} +} + +func appendScoredLeg(b *chosenBundle, sl scoredLeg) { + b.legs = append(b.legs, cloneBundleLeg(sl.bundleLeg)) + b.grossLoan.Add(b.grossLoan, sl.profit) +} + +func cloneBundleWithLeg(b chosenBundle, sl scoredLeg) chosenBundle { + out := chosenBundle{ + legs: cloneBundleLegs(b.legs), + grossLoan: new(big.Int).Set(b.grossLoan), + } + appendScoredLeg(&out, sl) + return out +} + +func cloneBundleLeg(in bundleLeg) bundleLeg { + in.MaxSeizeAssets = cloneBig(in.MaxSeizeAssets) + in.MinProfit = cloneBig(in.MinProfit) + in.expectedLoanOut = cloneBig(in.expectedLoanOut) + return in +} + +func cloneBundleLegs(in []bundleLeg) []bundleLeg { + out := make([]bundleLeg, len(in)) + for i, leg := range in { + out[i] = cloneBundleLeg(leg) + } + return out +} + +func (b chosenBundle) callbackLegs() []LiquidationLeg { + out := make([]LiquidationLeg, len(b.legs)) + for i, leg := range b.legs { + out[i] = LiquidationLeg{ + MarketId: leg.MarketId, + Borrower: leg.Borrower, + MaxSeizeAssets: cloneBig(leg.MaxSeizeAssets), + MinProfit: cloneBig(leg.MinProfit), + } + } + return out +} + +func (b chosenBundle) borrowers() []string { + out := make([]string, len(b.legs)) + for i, leg := range b.legs { + out[i] = strings.ToLower(leg.Borrower.Hex()) + } + return out +} + +func (s *Solver) bundleNetNative(b chosenBundle, rate *big.Int, gasState *gasPredictorState, gasPrice *big.Int) *big.Int { + return s.bundleNetNativeForFeeds(b, rate, gasState, gasPrice, defaultPriceUpdateFeeds) +} + +func (s *Solver) bundleNetNativeForFeeds(b chosenBundle, rate *big.Int, gasState *gasPredictorState, gasPrice *big.Int, feedCount int) *big.Int { + grossNative := loanToNative(b.grossLoan, rate) + gasUnits := gasPredictionForBundleFeeds(b, gasState, feedCount).Units + gasNative := gasCostNative(gasUnits, gasPrice) + grossNative.Sub(grossNative, gasNative) + return grossNative.Sub(grossNative, s.bundleBidNative(b, rate)) +} + +func (s *Solver) bundleBidNative(b chosenBundle, rate *big.Int) *big.Int { + minimal := orZero(s.cfg.BidWei) + if s.cfg.TotalBundleProfitBps <= 0 { + return new(big.Int).Set(minimal) + } + share := ceilMulDiv(loanToNative(b.grossLoan, rate), big.NewInt(int64(s.cfg.TotalBundleProfitBps)), big.NewInt(10_000)) + if share.Cmp(minimal) < 0 { + return new(big.Int).Set(minimal) + } + return share +} + +func (s *Solver) minBundleProfitNative(bidNative *big.Int) *big.Int { + if s.cfg.MinBundleProfitBidBps <= 0 { + return new(big.Int) + } + return ceilMulDiv(orZero(bidNative), big.NewInt(int64(s.cfg.MinBundleProfitBidBps)), big.NewInt(10_000)) +} + +func (s *Solver) minBundleProfitLoan(b chosenBundle, rate *big.Int, gas gasPrediction, gasPrice *big.Int) *big.Int { + bidNative := s.bundleBidNative(b, rate) + requiredNative := gasCostNative(gas.Units, gasPrice) + requiredNative.Add(requiredNative, bidNative) + requiredNative.Add(requiredNative, s.minBundleProfitNative(bidNative)) + return nativeToLoan(requiredNative, rate) +} + +func (s *Solver) priceBundle(b chosenBundle, rate *big.Int, gasState *gasPredictorState, gasPrice *big.Int, feedCount int) pricedBundle { + gas := gasPredictionForBundleFeeds(b, gasState, feedCount) + return pricedBundle{ + gas: gas, + gasNative: gasCostNative(gas.Units, gasPrice), + bidNative: s.bundleBidNative(b, rate), + minBundleProfitLoan: s.minBundleProfitLoan(b, rate, gas, gasPrice), + callbackLegs: legsWithProfitFloors(b.callbackLegs(), gas, gasPrice, rate), + } +} + +func ceilMulDiv(x, y, denom *big.Int) *big.Int { + if x == nil || y == nil || denom == nil || x.Sign() <= 0 || y.Sign() <= 0 || denom.Sign() <= 0 { + return new(big.Int) + } + num := new(big.Int).Mul(x, y) + q, r := new(big.Int).QuoRem(num, denom, new(big.Int)) + if r.Sign() > 0 { + q.Add(q, big.NewInt(1)) + } + return q +} diff --git a/internal/solvers/redstoneoev/callbackevents.go b/internal/solvers/redstoneoev/callbackevents.go new file mode 100644 index 00000000..40d65ac5 --- /dev/null +++ b/internal/solvers/redstoneoev/callbackevents.go @@ -0,0 +1,118 @@ +package redstoneoev + +import ( + "encoding/hex" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/go-logr/logr" +) + +func logCallbackEvents(log logr.Logger, callback common.Address, expectedAuctionKey common.Hash, receipt *types.Receipt) { + for _, lg := range receipt.Logs { + if lg.Address != callback || len(lg.Topics) == 0 { + continue + } + if ev, err := callbackB.UnpackLegResultEvent(lg); err == nil { + auctionKey := common.BytesToHash(ev.AuctionKey[:]) + if !callbackEventMatchesAuction(log, expectedAuctionKey, auctionKey, "LegResult") { + continue + } + fields := legResultCode(ev.Code) + log.Info("callback leg result", + "auctionKey", auctionKey.Hex(), + "market", common.BytesToHash(ev.MarketId[:]).Hex(), + "borrower", ev.Borrower.Hex(), + "index", fields.index, "status", legStatusLabel(fields.status), "reason", legReasonLabel(fields.reason), + "selector", fields.selector, "seizedAssets", ev.SeizedAssets, "repaidAssets", ev.RepaidAssets, + "profitLoan", ev.ProfitLoan, "gasUsed", ev.GasUsed) + continue + } + if ev, err := callbackB.UnpackBundleResultEvent(lg); err == nil { + auctionKey := common.BytesToHash(ev.AuctionKey[:]) + if !callbackEventMatchesAuction(log, expectedAuctionKey, auctionKey, "BundleResult") { + continue + } + log.Info("callback bundle result", + "auctionKey", auctionKey.Hex(), + "totalProfitLoan", ev.TotalProfitLoan, "minProfitLoan", ev.MinProfitLoan, + "gasUsed", ev.GasUsed, "bidAuthorized", ev.BidAuthorized) + continue + } + if ev, err := callbackB.UnpackPayBidResultEvent(lg); err == nil { + auctionKey := common.BytesToHash(ev.AuctionKey[:]) + if !callbackEventMatchesAuction(log, expectedAuctionKey, auctionKey, "PayBidResult") { + continue + } + log.Info("callback paybid result", + "auctionKey", auctionKey.Hex(), + "bidAmount", ev.BidAmount, "paid", ev.Paid) + } + } +} + +func callbackEventMatchesAuction(log logr.Logger, expected, got common.Hash, event string) bool { + if expected == (common.Hash{}) || got == expected { + return true + } + log.Info("callback event auction key mismatch", "event", event, "expectedAuctionKey", expected.Hex(), "gotAuctionKey", got.Hex()) + return false +} + +type legResultFields struct { + index uint64 + status uint8 + reason uint8 + selector string +} + +func legResultCode(code *big.Int) legResultFields { + if code == nil { + return legResultFields{} + } + low := code.Uint64() + out := legResultFields{ + index: (low >> 16) & 0xffffffffffff, + status: uint8(low >> 8), + reason: uint8(low), + } + sel := new(big.Int).Rsh(new(big.Int).Set(code), 224) + if sel.Sign() == 0 { + return out + } + buf := make([]byte, 4) + sel.FillBytes(buf) + out.selector = "0x" + hex.EncodeToString(buf) + return out +} + +func legStatusLabel(status uint8) string { + switch status { + case 1: + return "success" + case 2: + return "skipped" + case 3: + return "reverted" + default: + return gasRouteUnknownLabel + } +} + +func legReasonLabel(reason uint8) string { + switch reason { + case 0: + return "none" + case 1: + return "swap_output_below_min" + case 2: + return "insufficient_loan_proceeds" + case 3: + return "profit_below_min" + case 4: + return "morpho_revert" + default: + return gasRouteUnknownLabel + } +} diff --git a/internal/solvers/redstoneoev/candidates.go b/internal/solvers/redstoneoev/candidates.go new file mode 100644 index 00000000..7fea1dad --- /dev/null +++ b/internal/solvers/redstoneoev/candidates.go @@ -0,0 +1,113 @@ +package redstoneoev + +import ( + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +type evalItem struct { + cand Candidate + price *big.Int + quote AdapterQuote + accrued *big.Int // totalBorrowAssets accrued to nowTs for cand's market +} + +type priceLookup func(id common.Hash, info MarketInfo) *big.Int + +func candidatesFromAuction(log logr.Logger, snap *snapshot, auction AuctionMessage, nowTs uint64) []evalItem { + frame := auctionPrices(log, auction) + return candidatesFromSnapshot(snap, nowTs, func(_ common.Hash, info MarketInfo) *big.Int { + return auctionPriceForMarket(frame, info) + }) +} + +func candidatesFromCachedPrices(snap *snapshot, nowTs uint64) []evalItem { + return candidatesFromSnapshot(snap, nowTs, func(id common.Hash, _ MarketInfo) *big.Int { + return snap.prices[id] + }) +} + +func auctionPriceForMarket(frame map[common.Address]*big.Int, info MarketInfo) *big.Int { + oracle := info.Params.Oracle + if oracle == (common.Address{}) { + return nil + } + return frame[oracle] +} + +func candidatesFromSnapshot(snap *snapshot, nowTs uint64, price priceLookup) []evalItem { + if snap == nil { + return nil + } + var out []evalItem + for id, info := range snap.markets { + pos := snap.positions[id] + if len(pos) == 0 { + continue // no tracked positions here — skip before the price/quote/accrual work + } + px := price(id, info) + if px == nil { + continue // no settlement price for this market's oracle + } + quote, ok := snap.quotes[id] + if !ok { + continue // adapter doesn't serve this market (or can't price it) -> can't size an exit + } + accruedState := morpho.AccruedMarketState(info.State, nowTs) + info.State = accruedState + for b, p := range pos { + out = append(out, evalItem{ + cand: Candidate{MarketID: id, Borrower: b, Market: info, Position: p}, + price: px, + quote: quote, + accrued: accruedState.TotalBorrowAssets, + }) + } + } + return out +} + +func auctionPrices(log logr.Logger, a AuctionMessage) map[common.Address]*big.Int { + out := make(map[common.Address]*big.Int, len(a.Payload.Prices)) + for k, v := range a.Payload.Prices { + if !common.IsHexAddress(k) { + log.V(1).Info("dropping auction price with invalid oracle address", "oracle", k) + continue + } + n, ok := new(big.Int).SetString(v, 10) + if !ok || n.Sign() <= 0 { + log.V(1).Info("dropping unparseable auction price", "oracle", k, "value", v) + continue + } + out[common.HexToAddress(k)] = n + } + return out +} + +// scoredLegs is I/O-free: it reads only the monitor snapshot and sizes one leg per liquidatable candidate. +func (s *Solver) scoredLegs(a AuctionMessage, now time.Time) []scoredLeg { + nowTs := clampTsAt(a.Timestamp, now) + cands := s.mon.candidates(a, nowTs) + out := make([]scoredLeg, 0, len(cands)) + for _, it := range cands { + if sized, ok := sizeLeg(it.cand, it.price, it.quote, it.accrued, s.cfg.Sizing); ok { + out = append(out, scoredLeg{ + bundleLeg: bundleLeg{ + LiquidationLeg: sized.leg, + expectedLoanOut: sized.expectedLoanOut, + collateral: it.cand.Market.Params.CollateralToken, + }, + profit: sized.profit, + maxAssets: it.quote.MaxAssets, + source: it, + replay: true, + }) + } + } + return out +} diff --git a/internal/solvers/redstoneoev/chainreader.go b/internal/solvers/redstoneoev/chainreader.go new file mode 100644 index 00000000..83cf6153 --- /dev/null +++ b/internal/solvers/redstoneoev/chainreader.go @@ -0,0 +1,633 @@ +package redstoneoev + +import ( + "context" + "maps" + "math/big" + "slices" + "sync" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/api/bindings/erc4626" + "github.com/symbioticfi/vault-solver/api/bindings/liquidlane/adapter" + "github.com/symbioticfi/vault-solver/api/bindings/oev/aggregator" + "github.com/symbioticfi/vault-solver/api/bindings/oev/callback" + "github.com/symbioticfi/vault-solver/api/bindings/oev/executor" + morphobinding "github.com/symbioticfi/vault-solver/api/bindings/oev/morpho" + "github.com/symbioticfi/vault-solver/api/bindings/oev/oracle" + "github.com/symbioticfi/vault-solver/api/bindings/vaultv2" + "github.com/symbioticfi/vault-solver/internal/chain" + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +// Contract binding instances (abigen --v2): typed Pack/Unpack helpers for the Multicall3 sub-calls below, +// driven the same way as the rfq reader, so an ABI change fails at compile time. The LiquidLane adapter is +// a neutral group driven by both rfq and redstone-oev; the ERC-4626 vault binding covers the ERC-20 reads +// (asset(), balanceOf()) — IERC4626 is an ERC-20. +var ( + morphoB = morphobinding.NewMorpho() + executorB = executor.NewRedStoneExecutor() + aggregatorB = aggregator.NewAggregatorV3() + callbackB = callback.NewSymbioticOevSolver() + oracleB = oracle.NewMorphoOracle() + + llAdapter = adapter.NewLiquidLaneAdapter() + erc4626b = erc4626.NewIERC4626() // asset() + balanceOf() (IERC4626 is an ERC-20) + vaultV2B = vaultv2.NewIVaultV2() +) + +// abiMarketParams is Morpho's MarketParams tuple (loanToken, collateralToken, oracle, irm, lltv). +type abiMarketParams struct { + LoanToken common.Address + CollateralToken common.Address + Oracle common.Address + Irm common.Address + Lltv *big.Int +} + +/* ───────── decoders ───────── */ + +// decodeMarketParams decodes Morpho idToMarketParams(id) into the params tuple. +func decodeMarketParams(data []byte) (abiMarketParams, error) { + out, err := morphoB.UnpackIdToMarketParams(data) + if err != nil { + return abiMarketParams{}, errors.Errorf("decode marketParams: %w", err) + } + if out.Lltv == nil { + return abiMarketParams{}, errors.New("decode marketParams: lltv nil") + } + return abiMarketParams{ + LoanToken: out.LoanToken, CollateralToken: out.CollateralToken, + Oracle: out.Oracle, Irm: out.Irm, Lltv: out.Lltv, + }, nil +} + +func decodeLatestRoundData(data []byte) (answer, updatedAt *big.Int, err error) { + out, e := aggregatorB.UnpackLatestRoundData(data) + if e != nil { + return nil, nil, errors.Errorf("decode latestRoundData: %w", e) + } + if out.Answer == nil || out.UpdatedAt == nil { + return nil, nil, errors.New("decode latestRoundData: nil field") + } + return out.Answer, out.UpdatedAt, nil +} + +func decodeDecimals(data []byte) (uint8, error) { + d, err := aggregatorB.UnpackDecimals(data) + if err != nil { + return 0, errors.Errorf("decode decimals: %w", err) + } + return d, nil +} + +/* ───────── market id re-derivation ───────── */ + +// marketParamsArgs is the ABI tuple of Morpho MarketParams, used to recompute a market id. It encodes +// the exact (address,address,address,address,uint256) tuple Morpho's Id library hashes — kept hand-built +// (vs the getter ABI, whose outputs are flattened, not a bare abi.encode of a tuple) so deriveMarketID +// stays byte-exact with the on-chain id (pinned in marketid_test.go). +var marketParamsArgs = abi.Arguments{{Type: mustTupleType([]abi.ArgumentMarshaling{ + {Name: "loanToken", Type: "address"}, + {Name: "collateralToken", Type: "address"}, + {Name: "oracle", Type: "address"}, + {Name: "irm", Type: "address"}, + {Name: "lltv", Type: "uint256"}, +})}} + +func mustTupleType(components []abi.ArgumentMarshaling) abi.Type { + t, err := abi.NewType("tuple", "", components) + if err != nil { + panic("redstoneoev: market params tuple type: " + err.Error()) + } + return t +} + +// deriveMarketID recomputes a Morpho market id = keccak256(abi.encode(MarketParams)), used to verify a +// resolved id against the params Morpho returned for it — a spoofed or non-existent id (Morpho returns +// zero params) re-derives to a different hash and is dropped (fail closed). +func deriveMarketID(p abiMarketParams) (common.Hash, error) { + enc, err := marketParamsArgs.Pack(p) + if err != nil { + return common.Hash{}, errors.Errorf("encode market params: %w", err) + } + return crypto.Keccak256Hash(enc), nil +} + +const maxFeedDecimals = 36 + +// reader performs the OEV on-chain reads, batching via Multicall3. Nothing here runs on the hot path. +type reader struct { + chain *chain.Client + log logr.Logger + decimals *chain.Decimals + // mu guards the two adapter caches below: both are read from the run-loop and discovery goroutines + // (refreshMarkets / discoverMarkets), so the map access is locked — the RPC resolve runs unlocked, only + // the cache read/write takes mu. + mu sync.Mutex + adapterLoan map[common.Address]common.Address // adapter.vault().asset(), resolved once (immutable) + redeemColl map[common.Address][]common.Address // adapter's redeemable collateral set, resolved once (stable) +} + +func feedDecimalsInBounds(loanDec, ethDec uint8) bool { + return loanDec <= maxFeedDecimals && ethDec <= maxFeedDecimals +} + +func feedFresh(updatedAt, nowSec, maxAge int64) bool { + age := nowSec - updatedAt + return age >= 0 && age <= maxAge +} + +func newReader(c *chain.Client, log logr.Logger) *reader { + return &reader{ + chain: c, log: log, + decimals: chain.NewDecimals(c), + adapterLoan: map[common.Address]common.Address{}, + redeemColl: map[common.Address][]common.Address{}, + } +} + +// adapterLoanToken returns the adapter's vault loan token (adapter.vault().asset()), caching the result +// (immutable). Up to two multicalls when uncached: vault(), then asset() on the vault. Returns the zero +// address (and ok=false) when either read fails — the market then resolves to no quote (fail closed). +func (r *reader) adapterLoanToken(ctx context.Context, adapter common.Address) (common.Address, bool, error) { + r.mu.Lock() + lt, ok := r.adapterLoan[adapter] + r.mu.Unlock() + if ok { + return lt, true, nil + } + vault, err := r.callAddress(ctx, adapter, llAdapter.PackVault(), llAdapter.UnpackVault) + if err != nil { + return common.Address{}, false, err + } + if vault == (common.Address{}) { + return common.Address{}, false, nil // vault() reverted / didn't decode → fail closed + } + asset, err := r.callAddress(ctx, vault, erc4626b.PackAsset(), erc4626b.UnpackAsset) + if err != nil { + return common.Address{}, false, err + } + if asset == (common.Address{}) { + return common.Address{}, false, nil // asset() reverted / didn't decode → fail closed + } + r.mu.Lock() + r.adapterLoan[adapter] = asset + r.mu.Unlock() + return asset, true, nil +} + +type adapterSnapshot struct { + loan common.Address + redeemable []common.Address + filler bool +} + +func (r *reader) readAdapterSnapshot(ctx context.Context, callback, adapter common.Address) (adapterSnapshot, error) { + loan, ok, err := r.adapterLoanToken(ctx, adapter) + if err != nil { + return adapterSnapshot{}, errors.Errorf("adapter loan token: %w", err) + } + if !ok || loan == (common.Address{}) { + return adapterSnapshot{}, errors.New("adapter loan token unresolved") + } + redeemable, err := r.readRedeemableCollaterals(ctx, adapter) + if err != nil { + return adapterSnapshot{}, errors.Errorf("adapter redeemable collateral: %w", err) + } + if len(redeemable) == 0 { + return adapterSnapshot{}, errors.New("adapter redeemable collateral unresolved") + } + filler, err := r.ReadFillerStatus(ctx, callback, adapter) + if err != nil { + return adapterSnapshot{}, errors.Errorf("adapter filler status: %w", err) + } + return adapterSnapshot{loan: loan, redeemable: redeemable, filler: filler}, nil +} + +// callAddress reads a single address-returning view method off `target` in one multicall (the call packed +// by `data`, the return decoded by `unpack` — the binding's typed PackXxx/UnpackXxx), returning the zero +// address (not an error) when the sub-call reverts or doesn't decode — only an RPC failure surfaces as an +// error. So a zero-address result means "fail closed" to the caller. +func (r *reader) callAddress(ctx context.Context, target common.Address, data []byte, unpack func([]byte) (common.Address, error)) (common.Address, error) { + res, err := r.chain.Multicall(ctx, []chain.Call{ + {Target: target, AllowFailure: true, Data: data}, + }) + if err != nil { + return common.Address{}, err + } + if len(res) != 1 || !res[0].Success { + return common.Address{}, nil // sub-call reverted → fail closed (zero address) + } + out, derr := unpack(res[0].ReturnData) + if derr != nil { + out = common.Address{} // didn't decode → fail closed (zero address) + } + return out, nil +} + +// ReadLoanEthRate composes live loanPerEth from loan/USD and ETH/USD feeds. Nil means no usable feed value. +func (r *reader) ReadLoanEthRate(ctx context.Context, adapter common.Address, feed *loanEthFeed, now time.Time) *big.Int { + if feed == nil { + return nil + } + token, ok, err := r.adapterLoanToken(ctx, adapter) + if err != nil || !ok { + return nil + } + res, err := r.chain.Multicall(ctx, []chain.Call{ + {Target: feed.LoanUsdFeed, AllowFailure: true, Data: aggregatorB.PackLatestRoundData()}, + {Target: feed.LoanUsdFeed, AllowFailure: true, Data: aggregatorB.PackDecimals()}, + {Target: feed.EthUsdFeed, AllowFailure: true, Data: aggregatorB.PackLatestRoundData()}, + {Target: feed.EthUsdFeed, AllowFailure: true, Data: aggregatorB.PackDecimals()}, + }) + if err != nil || !allSuccess(res, 4) { + return nil + } + loanAns, loanUp, e1 := decodeLatestRoundData(res[0].ReturnData) + loanDecFeed, e2 := decodeDecimals(res[1].ReturnData) + ethAns, ethUp, e3 := decodeLatestRoundData(res[2].ReturnData) + ethDecFeed, e4 := decodeDecimals(res[3].ReturnData) + if e1 != nil || e2 != nil || e3 != nil || e4 != nil { + return nil + } + if !feedDecimalsInBounds(loanDecFeed, ethDecFeed) { + r.log.Error(errors.New("feed decimals out of bounds"), + "loanPerEth feed rejected", "loanFeedDec", loanDecFeed, "ethFeedDec", ethDecFeed, "max", maxFeedDecimals) + return nil + } + nowSec, maxAge := now.Unix(), int64((feed.MaxAge+time.Second-1)/time.Second) + if !feedFresh(loanUp.Int64(), nowSec, maxAge) || !feedFresh(ethUp.Int64(), nowSec, maxAge) { + r.log.V(1).Info("loan/ETH rate feeds stale", + "loanFeed", feed.LoanUsdFeed.Hex(), "loanAgeSec", nowSec-loanUp.Int64(), + "ethFeed", feed.EthUsdFeed.Hex(), "ethAgeSec", nowSec-ethUp.Int64(), + "maxAgeSec", maxAge) + return nil + } + loanDec, e := r.decimals.Get(ctx, token) + if e != nil { + return nil + } + return composeLoanPerEth(ethAns, loanAns, int(ethDecFeed), int(loanDecFeed), loanDec) +} + +// readRedeemableCollaterals returns the adapter's redeemable collateral SET (the markets its loan token +// can liquidate into): getTokensToRedeemLength() then tokensToRedeem(0..n-1) batched in one multicall on +// the adapter. The set is stable, so the result is cached (mirrors the adapterLoan immutable cache). Fails +// CLOSED — returns nil (no discovery this round) when the length read reverts/doesn't decode or any +// tokensToRedeem entry fails — so a partial read never narrows market discovery to a wrong subset. +func (r *reader) readRedeemableCollaterals(ctx context.Context, adapter common.Address) ([]common.Address, error) { + r.mu.Lock() + c, ok := r.redeemColl[adapter] + r.mu.Unlock() + if ok { + return slices.Clone(c), nil + } + lenRes, err := r.chain.Multicall(ctx, []chain.Call{ + {Target: adapter, AllowFailure: true, Data: llAdapter.PackGetTokensToRedeemLength()}, + }) + if err != nil { + return nil, err + } + count, ok := decodeRedeemCount(lenRes) + if !ok { + return nil, nil // length read reverted / didn't decode → fail closed (no discovery) + } + if count == 0 { + r.mu.Lock() + r.redeemColl[adapter] = nil // an empty set is a valid (cached) answer + r.mu.Unlock() + return nil, nil + } + calls := make([]chain.Call, count) + for i := range count { + calls[i] = chain.Call{Target: adapter, AllowFailure: true, Data: llAdapter.PackTokensToRedeem(big.NewInt(int64(i)))} + } + res, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + toks, ok := decodeRedeemTokens(res, count) + if !ok { + return nil, nil // a partial/undecodable read → fail closed (don't cache a wrong subset) + } + r.mu.Lock() + r.redeemColl[adapter] = slices.Clone(toks) + r.mu.Unlock() + return toks, nil +} + +// decodeRedeemCount decodes getTokensToRedeemLength() from its single-call result into a non-negative +// int64-bounded count, returning ok=false on a reverted/undecodable/absurd length (caller fails closed). +// Pure → unit-testable. +func decodeRedeemCount(res []chain.CallResult) (int, bool) { + if len(res) != 1 || !res[0].Success { + return 0, false + } + n, err := llAdapter.UnpackGetTokensToRedeemLength(res[0].ReturnData) + if err != nil || n == nil || n.Sign() < 0 || !n.IsInt64() { + return 0, false + } + return int(n.Int64()), true +} + +// decodeRedeemTokens decodes the tokensToRedeem(i) multicall results into the collateral set, returning +// ok=false if any sub-call failed/didn't decode or yielded the zero address — the caller then fails closed. +// Pure (no I/O) so the strided decode is unit-testable against hand-packed CallResults. +func decodeRedeemTokens(res []chain.CallResult, count int) ([]common.Address, bool) { + if len(res) != count { + return nil, false + } + out := make([]common.Address, 0, count) + for i := range res { + if !res[i].Success { + return nil, false + } + tok, err := llAdapter.UnpackTokensToRedeem(res[i].ReturnData) + if err != nil || tok == (common.Address{}) { + return nil, false + } + out = append(out, tok) + } + return out, true +} + +// verifyAdapterPair filters resolved market params to those the adapter can actually liquidate: loan token +// == the adapter's loan AND collateral ∈ the adapter's redeemable set. params come from ResolveParams (each +// already keccak-verified against its id), so this is the pair half of the on-chain verification. Pure → +// unit-testable. +func verifyAdapterPair(params map[common.Hash]abiMarketParams, adapterLoan common.Address, redeemable []common.Address) []common.Hash { + redeem := make(map[common.Address]bool, len(redeemable)) + for _, t := range redeemable { + redeem[t] = true + } + out := make([]common.Hash, 0, len(params)) + for id, p := range params { + if p.LoanToken == adapterLoan && redeem[p.CollateralToken] { + out = append(out, id) + } + } + return out +} + +// MarketInfo is a market's params plus its API snapshot state. The serving adapter is NOT here: it is the +// solver's single configured adapter (cfg.Adapter), and its redemption quote travels separately in snapshot. +type MarketInfo struct { + Params abiMarketParams + State morpho.MarketState +} + +// ResolveParams reads idToMarketParams for each id in ONE multicall and returns the immutable market +// params, keyed by id. Each id is verified by re-deriving keccak256(abi.encode(params)) and dropping +// any mismatch — so a non-existent / spoofed id (Morpho returns zero params) fails closed. Params are +// immutable per id, so the monitor caches the result and only calls this for not-yet-seen ids. +func (r *reader) ResolveParams(ctx context.Context, morpho common.Address, ids []common.Hash) (map[common.Hash]abiMarketParams, error) { + if len(ids) == 0 { + return map[common.Hash]abiMarketParams{}, nil + } + calls := make([]chain.Call, len(ids)) + for i, id := range ids { + calls[i] = chain.Call{Target: morpho, AllowFailure: true, Data: morphoB.PackIdToMarketParams(id)} + } + res, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if len(res) != len(calls) { + return nil, errors.Errorf("resolveParams: got %d results, want %d", len(res), len(calls)) + } + out := make(map[common.Hash]abiMarketParams, len(ids)) + for i, id := range ids { + if !res[i].Success { + continue + } + mp, derr := decodeMarketParams(res[i].ReturnData) + if derr != nil { + continue + } + if derived, verr := deriveMarketID(mp); verr != nil || derived != id { + r.log.V(1).Info("market id mismatch; dropping", "id", id.Hex()) // fail closed (unknown/spoofed id) + continue + } + out[id] = mp + } + return out, nil +} + +// ReadAdapterQuotes reads only the single configured LiquidLane adapter's quote for served markets. API +// mode uses this while Morpho market state and positions come from the indexer snapshot. +func (r *reader) ReadAdapterQuotes(ctx context.Context, params map[common.Hash]abiMarketParams, adapter common.Address, serve map[common.Hash]bool) (map[common.Hash]*AdapterQuote, error) { + if len(params) == 0 { + return map[common.Hash]*AdapterQuote{}, nil + } + ids := slices.SortedFunc(maps.Keys(params), common.Hash.Cmp) + tokens := make([]common.Address, 0, len(ids)*2) + for _, id := range ids { + p := params[id] + tokens = append(tokens, p.LoanToken, p.CollateralToken) + } + decs, err := r.decimals.GetMany(ctx, tokens) + if err != nil { + return nil, err + } + + type slot struct { + id common.Hash + at int + } + var slots []slot + var calls []chain.Call + for _, id := range ids { + if !serve[id] { + continue + } + p := params[id] + slots = append(slots, slot{id: id, at: len(calls)}) + calls = append(calls, + chain.Call{Target: adapter, AllowFailure: true, Data: llAdapter.PackGetMaxRate(p.CollateralToken)}, + chain.Call{Target: adapter, AllowFailure: true, Data: llAdapter.PackGetMaxAssets(p.CollateralToken)}, + chain.Call{Target: adapter, AllowFailure: true, Data: llAdapter.PackPaused()}, + ) + } + if len(calls) == 0 { + return map[common.Hash]*AdapterQuote{}, nil + } + res, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if len(res) != len(calls) { + return nil, errors.Errorf("adapterQuotes: got %d results, want %d", len(res), len(calls)) + } + out := make(map[common.Hash]*AdapterQuote, len(slots)) + for _, s := range slots { + p := params[s.id] + out[s.id] = buildQuote(res[s.at], res[s.at+1], res[s.at+2], decs, p.LoanToken, p.CollateralToken) + } + return out, nil +} + +// buildQuote assembles the adapter redemption quote from the three adapter sub-call results, returning +// nil (no biddable exit) when paused, missing either token's decimals, or a non-positive rate/liquidity. +// An UNREADABLE pause state — paused() reverted or didn't decode — is treated as PAUSED (fail closed): we +// must not bid a leg whose adapter might be paused (the swap would revert), mirroring every other guard here. +func buildQuote(rateRes, maxAssetsRes, pausedRes chain.CallResult, decs map[common.Address]int, loanTok, collTok common.Address) *AdapterQuote { + loanDec, okLoan := decs[loanTok] + collDec, okColl := decs[collTok] + if !okLoan || !okColl { + return nil + } + if !pausedRes.Success { + return nil // unknown pause state ⇒ treat as paused (fail closed) + } + p, perr := llAdapter.UnpackPaused(pausedRes.ReturnData) + if perr != nil || p { + return nil // undecodable ⇒ fail closed; explicitly paused ⇒ no quote + } + if !rateRes.Success || !maxAssetsRes.Success { + return nil + } + rate, e1 := llAdapter.UnpackGetMaxRate(rateRes.ReturnData) + maxAssets, e2 := llAdapter.UnpackGetMaxAssets(maxAssetsRes.ReturnData) + if e1 != nil || e2 != nil || rate.Sign() <= 0 || maxAssets.Sign() <= 0 { + return nil + } + return &AdapterQuote{ + MaxRate: rate, MaxAssets: maxAssets, + LoanScale: chain.Exp10(loanDec), CollScale: chain.Exp10(collDec), // precompute for the hot path + } +} + +// ExecutorState is the signer's accounting on the RedStone Executor. +type ExecutorState struct { + Nonce *big.Int + Deposit *big.Int + Locked bool +} + +// ReadExecutorState reads nonces/deposits/locked for the signer in one multicall. +func (r *reader) ReadExecutorState(ctx context.Context, executor, signer common.Address) (ExecutorState, error) { + res, err := r.chain.Multicall(ctx, []chain.Call{ + {Target: executor, AllowFailure: true, Data: executorB.PackNonces(signer)}, + {Target: executor, AllowFailure: true, Data: executorB.PackDeposits(signer)}, + {Target: executor, AllowFailure: true, Data: executorB.PackLocked(signer)}, + }) + if err != nil { + return ExecutorState{}, err + } + if !allSuccess(res, 3) { + return ExecutorState{}, errors.New("executor state read reverted") + } + nonce, e1 := executorB.UnpackNonces(res[0].ReturnData) + deposit, e2 := executorB.UnpackDeposits(res[1].ReturnData) + locked, e3 := executorB.UnpackLocked(res[2].ReturnData) + if e1 != nil || e2 != nil || e3 != nil { + return ExecutorState{}, errors.New("executor state decode failed") + } + return ExecutorState{Nonce: nonce, Deposit: deposit, Locked: locked}, nil +} + +// ReadGasPredictorState caches the LiquidLane/vault balances needed to classify each selected leg's route. +func (r *reader) ReadGasPredictorState(ctx context.Context, adapter common.Address, collaterals []common.Address) (*gasPredictorState, error) { + colls := dedupeAddresses(collaterals) + if len(colls) == 0 { + return nil, nil + } + head, err := r.chain.Multicall(ctx, []chain.Call{ + {Target: adapter, AllowFailure: true, Data: llAdapter.PackOwner()}, + {Target: adapter, AllowFailure: true, Data: llAdapter.PackMarketMaker()}, + {Target: adapter, AllowFailure: true, Data: llAdapter.PackVault()}, + }) + if err != nil { + return nil, err + } + if !allSuccess(head, 3) { + return nil, errors.New("gas predictor head read reverted") + } + owner, e1 := llAdapter.UnpackOwner(head[0].ReturnData) + marketMaker, e2 := llAdapter.UnpackMarketMaker(head[1].ReturnData) + vault, e3 := llAdapter.UnpackVault(head[2].ReturnData) + if e1 != nil || e2 != nil || e3 != nil || vault == (common.Address{}) { + return nil, errors.New("gas predictor head decode failed") + } + + calls := []chain.Call{ + {Target: vault, AllowFailure: true, Data: vaultV2B.PackFreeAssets()}, + {Target: vault, AllowFailure: true, Data: vaultV2B.PackWithdrawable()}, + } + for _, coll := range colls { + calls = append(calls, chain.Call{Target: adapter, AllowFailure: true, Data: llAdapter.PackAcquireBalance(coll, owner)}) + if marketMaker != owner { + calls = append(calls, chain.Call{Target: adapter, AllowFailure: true, Data: llAdapter.PackAcquireBalance(coll, marketMaker)}) + } + } + res, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if !allSuccess(res, len(calls)) { + return nil, errors.New("gas predictor state read reverted") + } + free, e1 := vaultV2B.UnpackFreeAssets(res[0].ReturnData) + withdrawable, e2 := vaultV2B.UnpackWithdrawable(res[1].ReturnData) + if e1 != nil || e2 != nil || free == nil || withdrawable == nil { + return nil, errors.New("gas predictor vault decode failed") + } + st := &gasPredictorState{ + FreeAssets: free, + Withdrawable: withdrawable, + Acquire: make(map[common.Address]*big.Int, len(colls)), + } + idx := 2 + for _, coll := range colls { + ownerBal, derr := llAdapter.UnpackAcquireBalance(res[idx].ReturnData) + idx++ + if derr != nil || ownerBal == nil { + return nil, errors.New("gas predictor acquire decode failed") + } + total := new(big.Int).Set(ownerBal) + if marketMaker != owner { + mmBal, merr := llAdapter.UnpackAcquireBalance(res[idx].ReturnData) + idx++ + if merr != nil || mmBal == nil { + return nil, errors.New("gas predictor market-maker acquire decode failed") + } + total.Add(total, mmBal) + } + st.Acquire[coll] = total + } + return st, nil +} + +func dedupeAddresses(in []common.Address) []common.Address { + seen := make(map[common.Address]bool, len(in)) + out := make([]common.Address, 0, len(in)) + for _, a := range in { + if a == (common.Address{}) || seen[a] { + continue + } + seen[a] = true + out = append(out, a) + } + return out +} + +// allSuccess reports whether a fixed-shape multicall returned exactly n results and every one succeeded — +// the reverted-guard for single/triple-call view reads before decoding. +func allSuccess(res []chain.CallResult, n int) bool { + if len(res) != n { + return false + } + for i := range res { + if !res[i].Success { + return false + } + } + return true +} diff --git a/internal/solvers/redstoneoev/chainreader_test.go b/internal/solvers/redstoneoev/chainreader_test.go new file mode 100644 index 00000000..12cf5acc --- /dev/null +++ b/internal/solvers/redstoneoev/chainreader_test.go @@ -0,0 +1,263 @@ +package redstoneoev + +import ( + "context" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/api/bindings/liquidlane/adapter" + "github.com/symbioticfi/vault-solver/api/bindings/oev/aggregator" + "github.com/symbioticfi/vault-solver/internal/chain" +) + +// mustParseABI parses a binding's committed ABI JSON, panicking on a malformed (static) fragment — for the +// byte-crafting test helpers below. Protocol ABIs live in the owning solver package. +func mustParseABI(j string) abi.ABI { + parsed, err := abi.JSON(strings.NewReader(j)) + if err != nil { + panic("redstoneoev: parse abi: " + err.Error()) + } + return parsed +} + +// Parsed ABIs derived from the v2 bindings' committed MetaData, used only by the byte-crafting test helpers +// (isCall / packOut) to recognize a packed sub-call's selector and to ABI-encode a method's RETURN values — +// the production reader packs/decodes through the binding's typed PackXxx/UnpackXxx. Same source of record +// as the bindings, so selectors/output shapes can't drift from what the reader sends. +var ( + aggABI = mustParseABI(aggregator.AggregatorV3MetaData.ABI) + adapterABI = mustParseABI(adapter.LiquidLaneAdapterMetaData.ABI) +) + +// packOut ABI-encodes a method's RETURN values, so a test can craft the bytes a Multicall sub-call would +// hand back — lets the pure snapshot decoders be tested with no RPC. +func packOut(t *testing.T, a abi.ABI, method string, vals ...any) []byte { + t.Helper() + out, err := a.Methods[method].Outputs.Pack(vals...) + if err != nil { + t.Fatalf("pack %s outputs: %v", method, err) + } + return out +} + +// TestBuildQuotePausedFailClosed pins that unreadable paused() state fails closed to no quote. A successful +// paused() returning false still yields a quote. +func TestBuildQuotePausedFailClosed(t *testing.T) { + loan := common.HexToAddress("0x0000000000000000000000000000000000000010") + coll := common.HexToAddress("0x0000000000000000000000000000000000000011") + decs := map[common.Address]int{loan: 6, coll: 18} + rateRes := chain.CallResult{Success: true, ReturnData: packOut(t, adapterABI, "getMaxRate", mustBig("1800000000000000000000"))} + maxAssetsRes := chain.CallResult{Success: true, ReturnData: packOut(t, adapterABI, "getMaxAssets", big.NewInt(1_000_000_000_000))} + + // Control: paused() succeeds and is false → a quote is built. + pausedFalse := chain.CallResult{Success: true, ReturnData: packOut(t, adapterABI, "paused", false)} + if q := buildQuote(rateRes, maxAssetsRes, pausedFalse, decs, loan, coll); q == nil { + t.Fatal("paused()=false with good rate/liquidity should yield a quote") + } + // Reverted paused() sub-call → unknown pause state → no quote (fail closed). + if q := buildQuote(rateRes, maxAssetsRes, chain.CallResult{Success: false}, decs, loan, coll); q != nil { + t.Fatal("an unsuccessful paused() sub-call must fail closed (no quote)") + } + // Successful but undecodable paused() bytes → no quote (fail closed). + garbled := chain.CallResult{Success: true, ReturnData: []byte{0x01, 0x02}} + if q := buildQuote(rateRes, maxAssetsRes, garbled, decs, loan, coll); q != nil { + t.Fatal("an undecodable paused() return must fail closed (no quote)") + } +} + +// TestFillerAuth covers the canonical 3-way single-adapter authorization (callback==marketMaker || +// callback==owner || isFiller(marketMaker, callback)): direct ownership/market-making authorizes without a +// second call, a resolved marketMaker queues isFiller, and an unresolved marketMaker fails closed. +func TestFillerAuth(t *testing.T) { + callback := common.HexToAddress("0x00000000000000000000000000000000000000cb") + mm := common.HexToAddress("0x0000000000000000000000000000000000000071") // a normal market maker (not the callback) + + mkRes := func(mmAddr, ownerAddr common.Address, ok bool) []chain.CallResult { + if !ok { + return []chain.CallResult{{Success: false}, {Success: false}} + } + return []chain.CallResult{ + {Success: true, ReturnData: packOut(t, adapterABI, "marketMaker", mmAddr)}, + {Success: true, ReturnData: packOut(t, adapterABI, "owner", ownerAddr)}, + } + } + + // callback IS the marketMaker → direct, no isFiller. + if auth, _, need := resolveFillerAuth(callback, mkRes(callback, mm, true)); !auth || need { + t.Fatalf("callback as marketMaker must authorize directly (auth=%v need=%v)", auth, need) + } + // callback IS the owner → direct, no isFiller. + if auth, _, need := resolveFillerAuth(callback, mkRes(mm, callback, true)); !auth || need { + t.Fatalf("callback as owner must authorize directly (auth=%v need=%v)", auth, need) + } + // resolved marketMaker but not direct → needs isFiller(mm, callback). + auth, gotMM, need := resolveFillerAuth(callback, mkRes(mm, mm, true)) + if auth || !need || gotMM != mm { + t.Fatalf("delegated adapter must defer to isFiller (auth=%v need=%v mm=%s)", auth, need, gotMM) + } + // unresolved marketMaker + not owned → fail closed (no isFiller round). + deadAuth, _, deadNeed := resolveFillerAuth(callback, mkRes(common.Address{}, mm, false)) + if deadAuth || deadNeed { + t.Fatalf("an unresolved marketMaker must fail closed (auth=%v need=%v)", deadAuth, deadNeed) + } +} + +func TestFeedDecimalsInBounds(t *testing.T) { + cases := []struct { + name string + loanDec, ethDec uint8 + want bool + }{ + {"chainlink usd pairs", 8, 8, true}, + {"exactly at bound", maxFeedDecimals, maxFeedDecimals, true}, + {"loan over bound", maxFeedDecimals + 1, 8, false}, + {"eth over bound", 8, maxFeedDecimals + 1, false}, + {"uint8 max", 255, 255, false}, + } + for _, c := range cases { + if got := feedDecimalsInBounds(c.loanDec, c.ethDec); got != c.want { + t.Errorf("%s: got %v, want %v", c.name, got, c.want) + } + } +} + +func TestFeedFresh(t *testing.T) { + const now, maxAge = 1_000_000, 3600 + cases := []struct { + name string + updatedAt int64 + want bool + }{ + {"current", now, true}, + {"recent", now - 1800, true}, + {"exactly max age", now - maxAge, true}, + {"just stale", now - maxAge - 1, false}, + {"future", now + 1, false}, + } + for _, c := range cases { + if got := feedFresh(c.updatedAt, now, maxAge); got != c.want { + t.Errorf("%s: feedFresh = %v, want %v", c.name, got, c.want) + } + } +} + +func TestAggregatorFeedDecoders(t *testing.T) { + latest := packOut(t, aggABI, "latestRoundData", + big.NewInt(10), mustBig("250000000000"), big.NewInt(900), big.NewInt(1_000), big.NewInt(10)) + answer, updatedAt, err := decodeLatestRoundData(latest) + if err != nil { + t.Fatal(err) + } + if answer.String() != "250000000000" || updatedAt.Int64() != 1_000 { + t.Fatalf("latestRoundData decoded answer=%s updatedAt=%s", answer, updatedAt) + } + + dec, err := decodeDecimals(packOut(t, aggABI, "decimals", uint8(8))) + if err != nil { + t.Fatal(err) + } + if dec != 8 { + t.Fatalf("decimals=%d, want 8", dec) + } + + if _, _, err := decodeLatestRoundData([]byte{0x01, 0x02}); err == nil { + t.Fatal("garbled latestRoundData must fail") + } + if _, err := decodeDecimals([]byte{0x01, 0x02}); err == nil { + t.Fatal("garbled decimals must fail") + } +} + +// TestDecodeRedeemTokens pins the redeemable-collateral decode: every tokensToRedeem(i) sub-call must +// succeed and decode to a non-zero address, else the whole read fails closed (ok=false) so a partial set +// never narrows market discovery to a wrong subset. +func TestDecodeRedeemTokens(t *testing.T) { + tA := common.HexToAddress("0x45804880De22913dAFE09f4980848ECE6EcbAf78") + tB := common.HexToAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") + okRes := func(addr common.Address) chain.CallResult { + return chain.CallResult{Success: true, ReturnData: packOut(t, adapterABI, "tokensToRedeem", addr)} + } + + t.Run("all decode", func(t *testing.T) { + toks, ok := decodeRedeemTokens([]chain.CallResult{okRes(tA), okRes(tB)}, 2) + if !ok || len(toks) != 2 || toks[0] != tA || toks[1] != tB { + t.Fatalf("ok=%v toks=%+v", ok, toks) + } + }) + t.Run("a reverted entry fails closed", func(t *testing.T) { + if _, ok := decodeRedeemTokens([]chain.CallResult{okRes(tA), {Success: false}}, 2); ok { + t.Fatal("a reverted sub-call must fail the whole read") + } + }) + t.Run("a zero address fails closed", func(t *testing.T) { + if _, ok := decodeRedeemTokens([]chain.CallResult{okRes(common.Address{})}, 1); ok { + t.Fatal("a zero-address token must fail the read") + } + }) + t.Run("length mismatch fails closed", func(t *testing.T) { + if _, ok := decodeRedeemTokens([]chain.CallResult{okRes(tA)}, 2); ok { + t.Fatal("a short result vector must fail the read") + } + }) +} + +func TestReadRedeemableCollateralsCachedReturnsCopy(t *testing.T) { + adapter := common.HexToAddress("0x00000000000000000000000000000000000000aa") + coll := common.HexToAddress("0x00000000000000000000000000000000000000bb") + changed := common.HexToAddress("0x00000000000000000000000000000000000000cc") + r := &reader{redeemColl: map[common.Address][]common.Address{adapter: {coll}}} + + got, err := r.readRedeemableCollaterals(context.Background(), adapter) + if err != nil || len(got) != 1 || got[0] != coll { + t.Fatalf("cached redeemable collaterals = (%v, %v), want [%s]", got, err, coll.Hex()) + } + got[0] = changed + again, err := r.readRedeemableCollaterals(context.Background(), adapter) + if err != nil || len(again) != 1 || again[0] != coll { + t.Fatalf("cached collateral slice was mutated: got (%v, %v), want [%s]", again, err, coll.Hex()) + } +} + +// TestDecodeRedeemCount pins getTokensToRedeemLength decoding: a valid length decodes, a reverted/absurd +// length fails closed. +func TestDecodeRedeemCount(t *testing.T) { + lenRes := func(n int64) chain.CallResult { + return chain.CallResult{Success: true, ReturnData: packOut(t, adapterABI, "getTokensToRedeemLength", big.NewInt(n))} + } + if got, ok := decodeRedeemCount([]chain.CallResult{lenRes(3)}); !ok || got != 3 { + t.Fatalf("valid length: got=%d ok=%v", got, ok) + } + if _, ok := decodeRedeemCount([]chain.CallResult{{Success: false}}); ok { + t.Fatal("a reverted length read must fail closed") + } + if _, ok := decodeRedeemCount(nil); ok { + t.Fatal("an empty result vector must fail closed") + } +} + +// TestVerifyAdapterPair pins the pair half of the on-chain market verification: keep only markets whose +// loan == the adapter's loan AND whose collateral ∈ the adapter's redeemable set. +func TestVerifyAdapterPair(t *testing.T) { + loan := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + other := common.HexToAddress("0x1111111111111111111111111111111111111111") + collA := common.HexToAddress("0x45804880De22913dAFE09f4980848ECE6EcbAf78") + collB := common.HexToAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") + collX := common.HexToAddress("0x2222222222222222222222222222222222222222") + + good := common.HexToHash("0xaa") // loan match + collateral in set + wrongL := common.HexToHash("0xbb") // wrong loan + wrongC := common.HexToHash("0xcc") // collateral not redeemable + params := map[common.Hash]abiMarketParams{ + good: {LoanToken: loan, CollateralToken: collA}, + wrongL: {LoanToken: other, CollateralToken: collB}, + wrongC: {LoanToken: loan, CollateralToken: collX}, + } + kept := verifyAdapterPair(params, loan, []common.Address{collA, collB}) + if len(kept) != 1 || kept[0] != good { + t.Fatalf("want exactly the matching pair, got %+v", kept) + } +} diff --git a/internal/solvers/redstoneoev/config.go b/internal/solvers/redstoneoev/config.go new file mode 100644 index 00000000..4c9b2ebb --- /dev/null +++ b/internal/solvers/redstoneoev/config.go @@ -0,0 +1,286 @@ +package redstoneoev + +import ( + "math/big" + "net/url" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/parse" + "github.com/symbioticfi/vault-solver/internal/solver" +) + +// rawConfig mirrors the YAML shape; strings/ms are parsed into typed values in parseConfig. +type rawConfig struct { + WS rawWS `yaml:"ws"` + Executor string `yaml:"executor"` + Callback string `yaml:"callback"` + Adapter string `yaml:"adapter"` + MorphoAPIURL string `yaml:"morphoApiUrl"` + DiscoveryMaxHF *float64 `yaml:"discoveryMaxHealthFactor"` + MaxTrackedPositions *int `yaml:"maxTrackedPositions"` + LoanEthFeed *rawLoanEthFeed `yaml:"loanEthFeed"` + Bid rawBid `yaml:"bid"` + Sizing rawSizing `yaml:"sizing"` + Breaker rawBreaker `yaml:"breaker"` + Intervals rawIntervals `yaml:"intervals"` +} + +type rawWS struct { + URL string `yaml:"url"` + APIKeyEnv string `yaml:"apiKeyEnv"` +} + +type rawBid struct { + BidEth string `yaml:"bidEth"` + AuthTtlMs *int `yaml:"authTtlMs"` + MinBundleProfitBidBps *int `yaml:"minBundleProfitBidBps"` + TotalBundleProfitBps *int `yaml:"totalBundleProfitBps"` + MaxTxGasPriceWei string `yaml:"maxTxGasPriceWei"` +} + +type rawLoanEthFeed struct { + EthUsd string `yaml:"ethUsd"` + LoanUsd string `yaml:"loanUsd"` + MaxAgeMs *int `yaml:"maxAgeMs"` +} + +type loanEthFeed struct { + LoanUsdFeed common.Address + EthUsdFeed common.Address + MaxAge time.Duration +} + +type rawBreaker struct { + MaxFailures int `yaml:"maxFailures"` + WindowMs *int `yaml:"windowMs"` +} + +type rawSizing struct { + AllowFullLiquidation *bool `yaml:"allowFullLiquidation"` + SwapHaircutBps *int `yaml:"swapHaircutBps"` +} + +type rawIntervals struct { + // Pointers so an omitted field (→ default) is distinguishable from a set-but-invalid one: a present + // non-positive interval is a misconfiguration and is rejected, never silently defaulted. + OpsPollMs *int `yaml:"opsPollMs"` + MonitorPollMs *int `yaml:"monitorPollMs"` + MaxStateAgeMs *int `yaml:"maxStateAgeMs"` +} + +// Config is the validated, typed redstone-oev configuration. +type Config struct { + WSURL string + APIKeyEnv string + + Executor common.Address + Callback common.Address + Adapter common.Address + + // MorphoAPIURL is the Morpho GraphQL endpoint the solver polls for Morpho market state and at-risk + // positions. It is required by the production monitor factory. + MorphoAPIURL string + // DiscoveryMaxHealthFactor is the API at-risk band ceiling: positions with healthFactor ≤ this are + // snapshotted, then local Morpho math decides actual liquidatability at the auction price. + DiscoveryMaxHealthFactor float64 + // MaxTrackedPositions is the Morpho API `first` window and hard in-memory position cap. + MaxTrackedPositions int + + BidWei *big.Int + CallbackAuthTTL time.Duration + LoanEthFeed *loanEthFeed + MinBundleProfitBidBps int + TotalBundleProfitBps int + MaxTxGasPrice *big.Int + Sizing SizingParams + + BreakerMaxFailures int + BreakerWindow time.Duration + + OpsPoll time.Duration + MonitorPoll time.Duration + // MaxStateAge is the maximum age of any background cache (monitor snapshot, ops state) before + // bidding fails closed on stale_state. Must exceed every background poll interval. + MaxStateAge time.Duration +} + +const ( + defaultAllowFullLiquidation = true // target 100% collateral unless explicitly disabled + defaultCallbackAuthTTL = time.Minute // replay window for solver-signed callback auth + defaultSwapHaircut = 200 // 2% + defaultMaxTxGasPrice = 60_000_000_000 // 60 gwei + defaultFeedMaxAge = time.Hour // generous Chainlink-style heartbeat bound + defaultBreakerFails = 3 // halt after 3 failed liquidations in the window + defaultBreakerWindow = time.Hour + defaultOpsPoll = 30 * time.Second + defaultMonitorPoll = 10 * time.Second // cadence of the monitor snapshot poll + defaultMaxStateAge = 90 * time.Second // 3× the slowest default poll; bidding halts past this + defaultDiscoveryMaxHF = 1.30 // API at-risk band ceiling (spec §3.2: within 30% of liquidation) + defaultMaxTrackedPositions = 10_000 // API `first` window + in-memory at-risk cap +) + +// parseConfig decodes and validates the opaque redstone-oev solver config block. +func parseConfig(node yaml.Node) (*Config, error) { + var raw rawConfig + if err := solver.DecodeStrict(node, &raw); err != nil { // reject unknown keys → typos fail fast + return nil, err + } + if raw.WS.URL == "" { + return nil, errors.New("ws.url is required") + } + if raw.WS.APIKeyEnv == "" { + return nil, errors.New("ws.apiKeyEnv is required") + } + executor, err := parse.NonZeroAddress(raw.Executor, "executor") + if err != nil { + return nil, err + } + callback, err := parse.NonZeroAddress(raw.Callback, "callback") + if err != nil { + return nil, err + } + + breakerWindow, err := parse.MsDuration(raw.Breaker.WindowMs, defaultBreakerWindow, "breaker.windowMs") + if err != nil { + return nil, err + } + opsPoll, err := parse.MsDuration(raw.Intervals.OpsPollMs, defaultOpsPoll, "intervals.opsPollMs") + if err != nil { + return nil, err + } + monitorPoll, err := parse.MsDuration(raw.Intervals.MonitorPollMs, defaultMonitorPoll, "intervals.monitorPollMs") + if err != nil { + return nil, err + } + maxStateAge, err := parse.MsDuration(raw.Intervals.MaxStateAgeMs, defaultMaxStateAge, "intervals.maxStateAgeMs") + if err != nil { + return nil, err + } + // Every background loop must refresh strictly faster than the staleness cutoff, or steady-state + // bidding would flap between fresh and stale on ordinary poll cadence. + if opsPoll >= maxStateAge { + return nil, errors.Errorf("intervals.opsPollMs (%s) must be < intervals.maxStateAgeMs (%s)", opsPoll, maxStateAge) + } + if monitorPoll >= maxStateAge { + return nil, errors.Errorf("intervals.monitorPollMs (%s) must be < intervals.maxStateAgeMs (%s)", monitorPoll, maxStateAge) + } + + // SwapHaircutBps can't use OrDefault: an explicit 0 (no extra haircut) must be distinguishable from + // unset (→ defaultSwapHaircut), so the YAML field is a pointer and only nil falls back to the default. + swapHaircut := defaultSwapHaircut + if raw.Sizing.SwapHaircutBps != nil { + swapHaircut = *raw.Sizing.SwapHaircutBps + } + allowFullLiquidation := defaultAllowFullLiquidation + if raw.Sizing.AllowFullLiquidation != nil { + allowFullLiquidation = *raw.Sizing.AllowFullLiquidation + } + + cfg := &Config{ + WSURL: raw.WS.URL, + APIKeyEnv: raw.WS.APIKeyEnv, + Executor: executor, + Callback: callback, + Sizing: SizingParams{ + AllowFullLiquidation: allowFullLiquidation, + SwapHaircutBps: swapHaircut, + }, + BreakerMaxFailures: parse.OrDefault(raw.Breaker.MaxFailures, defaultBreakerFails), + BreakerWindow: breakerWindow, + OpsPoll: opsPoll, + MonitorPoll: monitorPoll, + MaxStateAge: maxStateAge, + } + if cfg.Adapter, err = parse.NonZeroAddress(raw.Adapter, "adapter"); err != nil { + return nil, err // required: sizing needs the adapter's redemption rate; the callback pins it as LiquidLaneAdapter + } + if cfg.BidWei, err = parse.EthToWei(parse.OrDefault(raw.Bid.BidEth, "0"), "bid.bidEth"); err != nil { + return nil, err + } + if cfg.BidWei.Sign() <= 0 { + return nil, errors.New("bid.bidEth must be > 0") + } + if cfg.CallbackAuthTTL, err = parse.MsDuration(raw.Bid.AuthTtlMs, defaultCallbackAuthTTL, "bid.authTtlMs"); err != nil { + return nil, err + } + if cfg.LoanEthFeed, err = parseLoanEthFeed(raw.LoanEthFeed); err != nil { + return nil, err + } + if cfg.LoanEthFeed == nil { + return nil, errors.New("loanEthFeed is required") + } + if cfg.MaxTxGasPrice, err = parse.Big(parse.OrDefault(raw.Bid.MaxTxGasPriceWei, big.NewInt(defaultMaxTxGasPrice).String()), "bid.maxTxGasPriceWei"); err != nil { + return nil, err + } + if cfg.MaxTxGasPrice.Sign() <= 0 { // signed into the EXECUTOR_V6 bid as the tx.gasprice ceiling; the contract requires it > 0 + return nil, errors.New("bid.maxTxGasPriceWei must be > 0") + } + if raw.Bid.MinBundleProfitBidBps != nil { + if *raw.Bid.MinBundleProfitBidBps < 0 { + return nil, errors.New("bid.minBundleProfitBidBps must be >= 0") + } + cfg.MinBundleProfitBidBps = *raw.Bid.MinBundleProfitBidBps + } + if raw.Bid.TotalBundleProfitBps != nil { + if *raw.Bid.TotalBundleProfitBps < 0 || *raw.Bid.TotalBundleProfitBps > 10_000 { + return nil, errors.New("bid.totalBundleProfitBps must be in [0, 10000]") + } + cfg.TotalBundleProfitBps = *raw.Bid.TotalBundleProfitBps + } + if cfg.Sizing.SwapHaircutBps < 0 || cfg.Sizing.SwapHaircutBps >= 10_000 { + return nil, errors.Errorf("sizing.swapHaircutBps must be in [0, 10000), got %d", cfg.Sizing.SwapHaircutBps) + } + if raw.MorphoAPIURL != "" { + u, perr := url.Parse(raw.MorphoAPIURL) + if perr != nil || !u.IsAbs() || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, errors.Errorf("morphoApiUrl must be an absolute http/https URL, got %q", raw.MorphoAPIURL) + } + cfg.MorphoAPIURL = raw.MorphoAPIURL + } + // At-risk band ceiling for API position snapshots (healthFactor_lte). Default 1.30 (spec §3.2). + cfg.DiscoveryMaxHealthFactor = defaultDiscoveryMaxHF + if raw.DiscoveryMaxHF != nil { + if *raw.DiscoveryMaxHF <= 0 { + return nil, errors.Errorf("discoveryMaxHealthFactor must be > 0, got %v", *raw.DiscoveryMaxHF) + } + cfg.DiscoveryMaxHealthFactor = *raw.DiscoveryMaxHF + } + // Bounds the in-memory tracked at-risk set AND doubles as the GraphQL `first` arg; >0 required (0/neg + // would track nothing). + cfg.MaxTrackedPositions = defaultMaxTrackedPositions + if raw.MaxTrackedPositions != nil { + if *raw.MaxTrackedPositions <= 0 { + return nil, errors.Errorf("maxTrackedPositions must be > 0, got %d", *raw.MaxTrackedPositions) + } + cfg.MaxTrackedPositions = *raw.MaxTrackedPositions + } + return cfg, nil +} + +// parseLoanEthFeed validates the single loan token's oracle feed config (nil -> no feed). Both feed +// addresses are required; maxAgeMs defaults to defaultFeedMaxAge when unset and must be positive when set. +func parseLoanEthFeed(in *rawLoanEthFeed) (*loanEthFeed, error) { + if in == nil { + return nil, nil + } + loanFeed, err := parse.NonZeroAddress(in.LoanUsd, "loanEthFeed.loanUsd") + if err != nil { + return nil, err + } + ethFeed, err := parse.NonZeroAddress(in.EthUsd, "loanEthFeed.ethUsd") + if err != nil { + return nil, err + } + maxAge := defaultFeedMaxAge + if in.MaxAgeMs != nil { + if *in.MaxAgeMs <= 0 { + return nil, errors.New("loanEthFeed.maxAgeMs must be > 0") + } + maxAge = time.Duration(*in.MaxAgeMs) * time.Millisecond + } + return &loanEthFeed{LoanUsdFeed: loanFeed, EthUsdFeed: ethFeed, MaxAge: maxAge}, nil +} diff --git a/internal/solvers/redstoneoev/config_test.go b/internal/solvers/redstoneoev/config_test.go new file mode 100644 index 00000000..803cb253 --- /dev/null +++ b/internal/solvers/redstoneoev/config_test.go @@ -0,0 +1,327 @@ +package redstoneoev + +import ( + "os" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "gopkg.in/yaml.v3" +) + +type exampleSolverEntry struct { + Name string `yaml:"name"` + Config yaml.Node `yaml:"config"` +} + +type exampleConfigFile struct { + Solvers []exampleSolverEntry `yaml:"solvers"` +} + +// TestExampleConfigParses loads the committed Sepolia profile and runs its solver block through +// parseConfig, so the example can't drift out of sync with the parser/validation. +func TestExampleConfigParses(t *testing.T) { + data, err := os.ReadFile("../../../config/redstone-oev.example.yaml") + if err != nil { + t.Fatalf("read example config: %v", err) + } + var top exampleConfigFile + if err := yaml.Unmarshal(data, &top); err != nil { + t.Fatal(err) + } + if len(top.Solvers) != 1 || top.Solvers[0].Name != Name { + t.Fatalf("example must define exactly the %q solver, got %+v", Name, top.Solvers) + } + cfg, err := parseConfig(top.Solvers[0].Config) + if err != nil { + t.Fatalf("example config failed to parse: %v", err) + } + // Full liquidation is the production default; disabling it is the explicit fallback if settlement + // routing ever has issues with full-collateral/bad-debt cases. + if !cfg.Sizing.AllowFullLiquidation { + t.Fatal("example settings drifted: allowFullLiquidation must stay enabled") + } + if cfg.LoanEthFeed == nil { + t.Fatal("example settings drifted: config must carry a loan↔ETH rate source") + } +} + +func decodeCfg(t *testing.T, y string) (*Config, error) { + t.Helper() + var node yaml.Node + if err := yaml.Unmarshal([]byte(y), &node); err != nil { + t.Fatal(err) + } + // The framework hands the solver the `config:` sub-node; here y is that node's content. + return parseConfig(node) +} + +// TestConfigProfiles is the deployment matrix: each representative operating configuration must parse and +// validate, and produce the Config the operator expects. This is the offline proof that "the various +// configurations are all operable as expected" — every mode/combination the solver supports, exercised +// through the real parser+validator (the on-chain behavior of each is the operator's live runbook). +func TestConfigProfiles(t *testing.T) { + cases := []struct { + name string + yaml string + check func(*testing.T, *Config) + }{ + { + // Production: the Morpho API is the market source + a flat bid, with a loan↔ETH rate source so + // the bundle-level after-cost profitability gate is active. + name: "prod: API snapshot / flat bid", + yaml: wsline + addrs + api + feedLine + okBid, + check: func(t *testing.T, c *Config) { + t.Helper() + if c.MorphoAPIURL == "" { + t.Fatal("prod profile must carry the Morpho API as its market source") + } + if c.BidWei.Sign() <= 0 { + t.Fatalf("prod profile must carry a positive flat bid, got %v", c.BidWei) + } + if c.LoanEthFeed == nil { + t.Fatal("prod profile must carry a rate source") + } + }, + }, + { + name: "morphoApiUrl monitor: API URL + poll override", + yaml: wsline + addrs + "morphoApiUrl: https://api.morpho.org/graphql\n" + + feedLine + "intervals: {monitorPollMs: 10000}\n" + okBid, + check: func(t *testing.T, c *Config) { + t.Helper() + if c.MorphoAPIURL != "https://api.morpho.org/graphql" || c.MonitorPoll != 10*time.Second { + t.Fatalf("monitor profile wrong: url=%q poll=%v", c.MorphoAPIURL, c.MonitorPoll) + } + if c.DiscoveryMaxHealthFactor != 1.30 { // default at-risk band ceiling + t.Fatalf("discoveryMaxHealthFactor default wrong: %v", c.DiscoveryMaxHealthFactor) + } + }, + }, + { + name: "sizing: full liquidation can be disabled", + yaml: wsline + addrs + api + feedLine + "bid: {bidEth: \"0.0005\"}\nsizing: {allowFullLiquidation: false}", + check: func(t *testing.T, c *Config) { + t.Helper() + if c.Sizing.AllowFullLiquidation { + t.Fatal("allowFullLiquidation=false was not parsed") + } + }, + }, + { + name: "single adapter pinned + oracle rate source", + yaml: wsline + addrs + api + feedLine + okBid, + check: func(t *testing.T, c *Config) { + t.Helper() + if c.Adapter != adapterAddr || c.LoanEthFeed == nil { + t.Fatalf("single-adapter profile wrong: adapter=%s feed=%v", c.Adapter, c.LoanEthFeed) + } + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg, err := decodeCfg(t, tc.yaml) + if err != nil { + t.Fatalf("profile failed to parse: %v", err) + } + tc.check(t, cfg) + }) + } +} + +const validCfg = ` +ws: + url: wss://dev-rwa-sepolia.oev.a.redstone.finance + apiKeyEnv: OEV_REDSTONE_API_KEY +executor: "0xfdFB1862a53a974b166d1f0D012f524Ebd2e0EbD" +callback: "0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1" +adapter: "0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b" +morphoApiUrl: https://api.morpho.org/graphql +loanEthFeed: + ethUsd: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + loanUsd: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + maxAgeMs: 3600000 +bid: + bidEth: "0.0005" + minBundleProfitBidBps: 1000 + totalBundleProfitBps: 500 + maxTxGasPriceWei: "60000000000" +sizing: + allowFullLiquidation: true + swapHaircutBps: 200 +intervals: + monitorPollMs: 15000 +` + +func TestParseConfigValid(t *testing.T) { + cfg, err := decodeCfg(t, validCfg) + if err != nil { + t.Fatal(err) + } + if cfg.BidWei.String() != "500000000000000" { // 0.0005 ETH + t.Fatalf("bidWei = %s", cfg.BidWei) + } + if !cfg.Sizing.AllowFullLiquidation || cfg.Sizing.SwapHaircutBps != 200 { + t.Fatalf("bad sizing: %+v", cfg.Sizing) + } + if cfg.MorphoAPIURL != "https://api.morpho.org/graphql" || cfg.MonitorPoll != 15*time.Second { + t.Fatalf("morphoApiUrl=%q monitorPoll=%v", cfg.MorphoAPIURL, cfg.MonitorPoll) + } + if cfg.MinBundleProfitBidBps != 1000 { + t.Fatalf("minBundleProfitBidBps=%d, want 1000", cfg.MinBundleProfitBidBps) + } + if cfg.TotalBundleProfitBps != 500 { + t.Fatalf("totalBundleProfitBps=%d, want 500", cfg.TotalBundleProfitBps) + } + if cfg.CallbackAuthTTL != defaultCallbackAuthTTL { + t.Fatalf("callback auth TTL = %v, want %v", cfg.CallbackAuthTTL, defaultCallbackAuthTTL) + } +} + +func TestParseConfigDefaults(t *testing.T) { + cfg, err := decodeCfg(t, ` +ws: {url: "wss://x", apiKeyEnv: K} +executor: "0xfdFB1862a53a974b166d1f0D012f524Ebd2e0EbD" +callback: "0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1" +adapter: "0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b" +morphoApiUrl: https://api.morpho.org/graphql +loanEthFeed: {ethUsd: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", loanUsd: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"} +bid: {bidEth: "0.0001"} +`) + if err != nil { + t.Fatal(err) + } + if cfg.Sizing.AllowFullLiquidation != defaultAllowFullLiquidation { + t.Fatalf("defaults not applied: allowFullLiquidation=%v", cfg.Sizing.AllowFullLiquidation) + } + if cfg.MonitorPoll != defaultMonitorPoll || cfg.MaxTxGasPrice.Int64() != defaultMaxTxGasPrice { + t.Fatalf("interval/gas defaults wrong") + } + if cfg.MaxStateAge != defaultMaxStateAge { + t.Fatalf("maxStateAge default wrong: %v, want %v", cfg.MaxStateAge, defaultMaxStateAge) + } + if cfg.MaxTrackedPositions != defaultMaxTrackedPositions { + t.Fatalf("maxTrackedPositions default wrong: %d, want %d", cfg.MaxTrackedPositions, defaultMaxTrackedPositions) + } + if cfg.CallbackAuthTTL != defaultCallbackAuthTTL { + t.Fatalf("callback auth TTL default wrong: %v, want %v", cfg.CallbackAuthTTL, defaultCallbackAuthTTL) + } +} + +func TestParseConfigBidAuthTTL(t *testing.T) { + cfg, err := decodeCfg(t, wsline+addrs+api+feedLine+`bid: {bidEth: "0.1", authTtlMs: 120000}`) + if err != nil { + t.Fatal(err) + } + if cfg.CallbackAuthTTL != 2*time.Minute { + t.Fatalf("callback auth TTL = %v, want 2m", cfg.CallbackAuthTTL) + } + if _, err := decodeCfg(t, wsline+addrs+api+feedLine+`bid: {bidEth: "0.1", authTtlMs: 0}`); err == nil { + t.Fatal("expected error for zero bid.authTtlMs") + } +} + +// TestParseConfigMaxTrackedPositions pins the cap knob: unset → default; an explicit positive value is +// honored; 0 and negative are rejected (it doubles as the GraphQL `first` arg). +func TestParseConfigMaxTrackedPositions(t *testing.T) { + t.Run("explicit positive honored", func(t *testing.T) { + cfg, err := decodeCfg(t, wsline+addrs+api+feedLine+okBid+"maxTrackedPositions: 50\n") + if err != nil { + t.Fatal(err) + } + if cfg.MaxTrackedPositions != 50 { + t.Fatalf("maxTrackedPositions = %d, want 50", cfg.MaxTrackedPositions) + } + }) + for _, bad := range []string{"0", "-1"} { + t.Run("rejects "+bad, func(t *testing.T) { + if _, err := decodeCfg(t, wsline+addrs+api+feedLine+okBid+"maxTrackedPositions: "+bad+"\n"); err == nil { + t.Fatalf("expected error for maxTrackedPositions: %s", bad) + } + }) + } +} + +// TestParseConfigSwapHaircutZeroRespected pins the *int handling: an explicit swapHaircutBps:0 (no +// extra haircut) must survive parsing, not be silently replaced by the 2% default. +func TestParseConfigSwapHaircutZeroRespected(t *testing.T) { + cfg, err := decodeCfg(t, wsline+addrs+api+feedLine+"bid: {bidEth: \"0.1\"}\nsizing: {swapHaircutBps: 0}") + if err != nil { + t.Fatal(err) + } + if cfg.Sizing.SwapHaircutBps != 0 { + t.Fatalf("explicit swapHaircutBps:0 should be respected, got %d", cfg.Sizing.SwapHaircutBps) + } + // And unset still defaults to 2%. + cfg2, err := decodeCfg(t, wsline+addrs+api+feedLine+"bid: {bidEth: \"0.1\"}") + if err != nil { + t.Fatal(err) + } + if cfg2.Sizing.SwapHaircutBps != defaultSwapHaircut { + t.Fatalf("unset swapHaircutBps should default to %d, got %d", defaultSwapHaircut, cfg2.Sizing.SwapHaircutBps) + } +} + +func TestParseConfigErrors(t *testing.T) { + cases := map[string]string{ + "missing ws url": `ws: {apiKeyEnv: K}` + "\n" + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}", + "missing apiKeyEnv": `ws: {url: x}` + "\n" + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}", + "removed positionSource": wsline + addrs + api + feedLine + "positionSource: redstone\nbid: {bidEth: \"0.1\"}", // unknown key: knob removed + "removed markets key": wsline + addrs + api + feedLine + `markets: ["` + mkt + `"]` + "\nbid: {bidEth: \"0.1\"}", // markets no longer a config field → unknown key + "zero bid": wsline + addrs + api + feedLine + "bid: {bidEth: \"0\"}", + "bad executor addr": wsline + `executor: "0xnope"` + "\ncallback: \"0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1\"\n" + api + feedLine + "bid: {bidEth: \"0.1\"}", + "missing loanEthFeed": wsline + addrs + api + "bid: {bidEth: \"0.1\"}", + "removed maxSeizeFractionBps": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nsizing: {maxSeizeFractionBps: 9000}", + "removed maxLegsPerBid": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", maxLegsPerBid: 8}", + "removed minLegProfitLoan": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nsizing: {minLegProfitLoan: \"1\"}", + "negative swapHaircutBps": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nsizing: {swapHaircutBps: -1}", + "bad morphoApiUrl": wsline + addrs + "morphoApiUrl: \"not-a-url\"\n" + feedLine + "bid: {bidEth: \"0.1\"}", + "non-positive maxHF": wsline + addrs + api + feedLine + "discoveryMaxHealthFactor: 0\nbid: {bidEth: \"0.1\"}", + "removed gasBase": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", gasBase: 100000}", + "removed gasPerLeg": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", gasPerLeg: 800000}", + "removed loanPerEth": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", loanPerEth: \"2500000000\"}", + "bad loan feed age": wsline + addrs + api + "loanEthFeed: {ethUsd: \"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\", loanUsd: \"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\", maxAgeMs: 0}\nbid: {bidEth: \"0.1\"}", + "removed minBundleProfitLoan": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", minBundleProfitLoan: \"1\"}", + "negative minBundleProfitBidBps": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", minBundleProfitBidBps: -1}", + "bad totalBundleProfitBps": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", totalBundleProfitBps: 10001}", + "zero maxTxGasPrice": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", maxTxGasPriceWei: \"0\"}", + "removed gas multiplier": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", gasPriceMultiplierBps: 20000}", + "removed priority fee": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\", priorityFeeWei: \"1\"}", + "removed market poll": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nintervals: {marketPollMs: 5000}", + "removed position poll": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nintervals: {positionPollMs: 2000}", + "negative interval": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nintervals: {monitorPollMs: -1}", + "removed discovery poll": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nintervals: {discoveryPollMs: 10000}", + "removed snapshot age": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nmaxSnapshotAgeMs: 60000", + "zero interval": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nintervals: {opsPollMs: 0}", + "non-positive breaker": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nbreaker: {maxFailures: 3, windowMs: 0}", + "opsPoll >= maxStateAge": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nintervals: {opsPollMs: 60000, maxStateAgeMs: 60000}", + "monitorPoll >= maxStateAge": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nintervals: {monitorPollMs: 90001, maxStateAgeMs: 90000}", + "zero maxStateAge": wsline + addrs + api + feedLine + "bid: {bidEth: \"0.1\"}\nintervals: {maxStateAgeMs: 0}", + "zero executor addr": wsline + "executor: \"0x0000000000000000000000000000000000000000\"\ncallback: \"0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1\"\nadapter: \"0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b\"\n" + api + feedLine + "bid: {bidEth: \"0.1\"}", + "zero callback addr": wsline + "executor: \"0xfdFB1862a53a974b166d1f0D012f524Ebd2e0EbD\"\ncallback: \"0x0000000000000000000000000000000000000000\"\nadapter: \"0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b\"\n" + api + feedLine + "bid: {bidEth: \"0.1\"}", + "zero adapter addr": wsline + "executor: \"0xfdFB1862a53a974b166d1f0D012f524Ebd2e0EbD\"\ncallback: \"0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1\"\nadapter: \"0x0000000000000000000000000000000000000000\"\n" + api + feedLine + "bid: {bidEth: \"0.1\"}", + } + for name, y := range cases { + t.Run(name, func(t *testing.T) { + if _, err := decodeCfg(t, y); err == nil { + t.Fatalf("expected error for %q", name) + } + }) + } +} + +const ( + mkt = "0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5" + wsline = "ws: {url: x, apiKeyEnv: K}\n" + addrs = "executor: \"0xfdFB1862a53a974b166d1f0D012f524Ebd2e0EbD\"\n" + + "callback: \"0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1\"\n" + + "adapter: \"0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b\"\n" + // api is the production market source (the Morpho API) appended to a valid config; markets/positions are + // discovered at runtime, so a parseable config needs no market list. + api = "morphoApiUrl: https://api.morpho.org/graphql\n" + feedLine = "loanEthFeed: {ethUsd: \"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\", loanUsd: \"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\", maxAgeMs: 3600000}\n" + okBid = "bid: {bidEth: \"0.0005\"}\n" +) + +var adapterAddr = common.HexToAddress("0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b") diff --git a/internal/solvers/redstoneoev/eip191.go b/internal/solvers/redstoneoev/eip191.go new file mode 100644 index 00000000..e16f1283 --- /dev/null +++ b/internal/solvers/redstoneoev/eip191.go @@ -0,0 +1,69 @@ +package redstoneoev + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/signer" +) + +// executorV6Domain is the RedStone Atom signature version string, the first field of the signed +// payload (see the verified Executor source, docs/OEV-PLAN.md §6.2). +const executorV6Domain = "EXECUTOR_V6" + +// executorV6Args is the ABI tuple the Executor recovers the solver from: +// +// keccak256(abi.encode("EXECUTOR_V6", chainId, operationCallback, keccak256(operationData), +// bidAmount, nonce, maxTxGasPrice)) +// +// standard (non-packed) ABI encoding, matching ethers AbiCoder.defaultAbiCoder().encode. +var executorV6Args = abi.Arguments{ + {Type: mustType("string")}, + {Type: mustType("uint256")}, // chainId + {Type: mustType("address")}, // operationCallback + {Type: mustType("bytes32")}, // keccak256(operationData) + {Type: mustType("uint256")}, // bidAmount (wei) + {Type: mustType("uint256")}, // nonce (strictly ascending) + {Type: mustType("uint256")}, // maxTxGasPrice +} + +// ExecutorV6Digest is the inner digest the Executor hashes before EIP-191 wrapping: +// keccak256(abi.encode("EXECUTOR_V6", chainId, callback, opDataHash, bid, nonce, maxTxGasPrice)). +func ExecutorV6Digest(chainID *big.Int, callback common.Address, opDataHash common.Hash, bid, nonce, maxTxGasPrice *big.Int) (common.Hash, error) { + enc, err := executorV6Args.Pack(executorV6Domain, chainID, callback, opDataHash, bid, nonce, maxTxGasPrice) + if err != nil { + return common.Hash{}, errors.Errorf("encode EXECUTOR_V6: %w", err) + } + return crypto.Keccak256Hash(enc), nil +} + +// SignBid produces the 65-byte EIP-191 (personal_sign) signature over the EXECUTOR_V6 digest that the +// auctioneer forwards and the Executor verifies via ECDSA.recover(toEthSignedMessageHash(digest)). +// The signer EOA must be the wallet holding the Executor deposit (§6.2). +func SignBid(sgnr signer.Signer, chainID *big.Int, callback common.Address, operationData []byte, bid, nonce, maxTxGasPrice *big.Int) ([]byte, error) { + digest, err := ExecutorV6Digest(chainID, callback, crypto.Keccak256Hash(operationData), bid, nonce, maxTxGasPrice) + if err != nil { + return nil, err + } + return sgnr.SignHash(ethSignedMessageHash(digest)) +} + +// ethSignedMessageHash applies the EIP-191 personal_sign prefix to a 32-byte digest: +// keccak256("\x19Ethereum Signed Message:\n32" || digest) — Solady/OZ MessageHashUtils.toEthSignedMessageHash. +// accounts.TextHash computes exactly this prefix (len(digest)==32) for a 32-byte input. +func ethSignedMessageHash(digest common.Hash) common.Hash { + return common.BytesToHash(accounts.TextHash(digest.Bytes())) +} + +func mustType(t string) abi.Type { + typ, err := abi.NewType(t, "", nil) + if err != nil { + panic("redstoneoev: abi type " + t + ": " + err.Error()) + } + return typ +} diff --git a/internal/solvers/redstoneoev/eip191_test.go b/internal/solvers/redstoneoev/eip191_test.go new file mode 100644 index 00000000..c9162569 --- /dev/null +++ b/internal/solvers/redstoneoev/eip191_test.go @@ -0,0 +1,72 @@ +package redstoneoev + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// TestExecutorV6DigestGoldenVector pins the digest computation to the verified live vector +// (docs/OEV-PLAN.md §6.7): the same inputs that produced a winning, signature-valid bid on Sepolia. +func TestExecutorV6DigestGoldenVector(t *testing.T) { + chainID := big.NewInt(11155111) + callback := common.HexToAddress("0x812492C36b003837C30cB0B63960b86eC9B27309") + opDataHash := common.HexToHash("0x0a85a1be3cf06539edd05476a60cca5482e8ef0c4fa0bb6c1cf3f79fd0945509") + bid := big.NewInt(100000000000000) // 0.0001 ETH + nonce := big.NewInt(1) + maxGas := big.NewInt(50000000000) // 50 gwei + + got, err := ExecutorV6Digest(chainID, callback, opDataHash, bid, nonce, maxGas) + if err != nil { + t.Fatal(err) + } + want := common.HexToHash("0x78f6eb68948cfeb1e16a81b050c111bf099628ff9dc51debb55f0b4fff2c7e5a") + if got != want { + t.Fatalf("digest = %s, want %s", got.Hex(), want.Hex()) + } +} + +// TestSignBidRecoversToSigner round-trips signing + recovery with a throwaway key: the EIP-191 +// wrapping + signature must recover to the signer, exactly as the Executor's +// ECDSA.recover(toEthSignedMessageHash(digest)) does on-chain. +func TestSignBidRecoversToSigner(t *testing.T) { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatal(err) + } + s := &testSigner{key: key, addr: crypto.PubkeyToAddress(key.PublicKey)} + + chainID := big.NewInt(11155111) + callback := common.HexToAddress("0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1") + opData := []byte{0x12, 0x34} + bid := big.NewInt(300000000000000) + nonce := big.NewInt(3) + maxGas := big.NewInt(60000000000) + + sig, err := SignBid(s, chainID, callback, opData, bid, nonce, maxGas) + if err != nil { + t.Fatal(err) + } + if len(sig) != 65 { + t.Fatalf("sig length = %d, want 65", len(sig)) + } + + digest, _ := ExecutorV6Digest(chainID, callback, crypto.Keccak256Hash(opData), bid, nonce, maxGas) + ethHash := ethSignedMessageHash(digest) + + // Recover: normalize v from {27,28} back to {0,1} for crypto.SigToPub. + rs := make([]byte, 65) + copy(rs, sig) + if rs[64] >= 27 { + rs[64] -= 27 + } + pub, err := crypto.SigToPub(ethHash.Bytes(), rs) + if err != nil { + t.Fatal(err) + } + if got := crypto.PubkeyToAddress(*pub); got != s.addr { + t.Fatalf("recovered %s, want %s", got.Hex(), s.addr.Hex()) + } +} diff --git a/internal/solvers/redstoneoev/epoch.go b/internal/solvers/redstoneoev/epoch.go new file mode 100644 index 00000000..0ce3b7a7 --- /dev/null +++ b/internal/solvers/redstoneoev/epoch.go @@ -0,0 +1,14 @@ +package redstoneoev + +import ( + "time" +) + +type readEpoch struct { + Block uint64 + At time.Time +} + +func newReadEpoch(block uint64, at time.Time) readEpoch { + return readEpoch{Block: block, At: at} +} diff --git a/internal/solvers/redstoneoev/fillerauth.go b/internal/solvers/redstoneoev/fillerauth.go new file mode 100644 index 00000000..e3493249 --- /dev/null +++ b/internal/solvers/redstoneoev/fillerauth.go @@ -0,0 +1,61 @@ +package redstoneoev + +import ( + "context" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/chain" +) + +// ReadFillerStatus checks the adapter's caller predicate: +// callback == marketMaker || callback == owner || isFiller(marketMaker, callback). +func (r *reader) ReadFillerStatus(ctx context.Context, callback, adapter common.Address) (bool, error) { + res, err := r.chain.Multicall(ctx, []chain.Call{ + {Target: adapter, AllowFailure: true, Data: llAdapter.PackMarketMaker()}, + {Target: adapter, AllowFailure: true, Data: llAdapter.PackOwner()}, + }) + if err != nil { + return false, err + } + authorized, mm, needFiller := resolveFillerAuth(callback, res) + if !needFiller { + return authorized, nil + } + fRes, err := r.chain.Multicall(ctx, []chain.Call{ + {Target: adapter, AllowFailure: true, Data: llAdapter.PackIsFiller(mm, callback)}, + }) + if err != nil { + return false, err + } + if len(fRes) == 1 && fRes[0].Success { + if ok, e := llAdapter.UnpackIsFiller(fRes[0].ReturnData); e == nil { + return ok, nil + } + } + return false, nil // isFiller unreadable → fail closed +} + +func resolveFillerAuth(callback common.Address, res []chain.CallResult) (authorized bool, marketMaker common.Address, needFiller bool) { + var mm common.Address + hasMM, direct := false, false + if len(res) > 0 && res[0].Success { + if v, e := llAdapter.UnpackMarketMaker(res[0].ReturnData); e == nil { + mm, hasMM = v, v != (common.Address{}) + direct = mm == callback + } + } + if len(res) > 1 && res[1].Success { + if owner, e := llAdapter.UnpackOwner(res[1].ReturnData); e == nil && owner == callback { + direct = true + } + } + switch { + case direct: + return true, mm, false // marketMaker/owner == callback + case hasMM: + return false, mm, true // need isFiller(marketMaker, callback) + default: + return false, mm, false // no marketMaker + not owned → fail closed + } +} diff --git a/internal/solvers/redstoneoev/gaspredictor.go b/internal/solvers/redstoneoev/gaspredictor.go new file mode 100644 index 00000000..1237a4a0 --- /dev/null +++ b/internal/solvers/redstoneoev/gaspredictor.go @@ -0,0 +1,234 @@ +package redstoneoev + +import ( + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/common" +) + +type gasRoute uint8 + +const ( + gasRouteUnknown gasRoute = iota + gasRouteAcquire + gasRouteAllocate + gasRouteDeallocate +) + +const ( + gasRouteUnknownLabel = "unknown" + + // Calibrated on the Sepolia OEV no-preview callback fork. fixedGasUnits adds RedStone overhead; first + // route units include the cold Morpho/callback/adapter path, later route units are marginal. + gasBaseUnits uint64 = 100_000 + gasFirstAcquireLeg uint64 = 300_000 + gasAdditionalAcquireLeg uint64 = 140_000 + gasFirstAllocateLeg uint64 = 530_000 + gasAdditionalAllocateLeg uint64 = 350_000 + gasFirstDeallocateLeg uint64 = 650_000 + gasAdditionalDeallocateLeg uint64 = 450_000 + gasFirstUnknownLeg uint64 = 850_000 + gasAdditionalUnknownLeg uint64 = 650_000 + + // RedStone debits (gasUsed + 35k) * tx.gasprice from the Executor deposit after settlement. Their + // price update path adds roughly 40k per updated feed before our callback runs. Both are fixed bundle + // costs for economics and gas-limit sizing; per-leg route units are converted to loan floors by buildBid. + gasExecutorDebitSurcharge uint64 = 35_000 + gasPriceUpdatePerFeed uint64 = 40_000 + + redstoneExecutorMaxGasUnits uint64 = 2_000_000 + bundleGasLimitSafetyBps uint64 = 8_500 + defaultPriceUpdateFeeds = 1 +) + +type gasPredictorState struct { + FreeAssets *big.Int + Withdrawable *big.Int + Acquire map[common.Address]*big.Int +} + +type gasPrediction struct { + Units uint64 + Routes []gasRoute +} + +func cloneBig(v *big.Int) *big.Int { + if v == nil { + return nil + } + return new(big.Int).Set(v) +} + +func gasPredictionForBundle(b chosenBundle, st *gasPredictorState) gasPrediction { + return gasPredictionForBundleFeeds(b, st, defaultPriceUpdateFeeds) +} + +func gasPredictionForBundleFeeds(b chosenBundle, st *gasPredictorState, feedCount int) gasPrediction { + legUnits, routes := gasLegPredictionForBundle(b, st) + return gasPrediction{Units: saturatingAddUint64(fixedGasUnits(feedCount), legUnits), Routes: routes} +} + +func gasLegPredictionForBundle(b chosenBundle, st *gasPredictorState) (uint64, []gasRoute) { + if len(b.legs) == 0 { + return 0, nil + } + routes := make([]gasRoute, 0, len(b.legs)) + if st == nil || st.FreeAssets == nil || st.Withdrawable == nil { + var total uint64 + for i := range b.legs { + routes = append(routes, gasRouteUnknown) + total = saturatingAddUint64(total, gasUnitsForRouteAt(gasRouteUnknown, i == 0)) + } + return total, routes + } + acquire := make(map[common.Address]*big.Int, len(st.Acquire)) + for k, v := range st.Acquire { + acquire[k] = cloneBig(v) + } + free := cloneBig(st.FreeAssets) + withdrawable := cloneBig(st.Withdrawable) + var total uint64 + for i, leg := range b.legs { + route := predictGasRoute(leg.expectedLoanOut, leg.collateral, acquire, free, withdrawable) + routes = append(routes, route) + total = saturatingAddUint64(total, gasUnitsForRouteAt(route, i == 0)) + } + return total, routes +} + +func predictGasRoute(expectedLoanOut *big.Int, collateral common.Address, acquire map[common.Address]*big.Int, free, withdrawable *big.Int) gasRoute { + if expectedLoanOut == nil || expectedLoanOut.Sign() <= 0 || free == nil || withdrawable == nil { + return gasRouteUnknown + } + remaining := new(big.Int).Set(expectedLoanOut) + if a := acquire[collateral]; a != nil && a.Sign() > 0 { + used := minBig(remaining, a) + remaining.Sub(remaining, used) + a.Sub(a, used) + } + if remaining.Sign() == 0 { + return gasRouteAcquire + } + if free.Cmp(remaining) >= 0 { + free.Sub(free, remaining) + if withdrawable.Cmp(remaining) >= 0 { + withdrawable.Sub(withdrawable, remaining) + } else { + withdrawable.SetInt64(0) + } + return gasRouteAllocate + } + if withdrawable.Cmp(remaining) >= 0 { + withdrawable.Sub(withdrawable, remaining) + free.SetInt64(0) + return gasRouteDeallocate + } + return gasRouteUnknown +} + +func gasUnitsForRoute(route gasRoute) uint64 { + return gasUnitsForRouteAt(route, false) +} + +func gasUnitsForRouteAt(route gasRoute, first bool) uint64 { + switch route { + case gasRouteAcquire: + if first { + return gasFirstAcquireLeg + } + return gasAdditionalAcquireLeg + case gasRouteAllocate: + if first { + return gasFirstAllocateLeg + } + return gasAdditionalAllocateLeg + case gasRouteDeallocate: + if first { + return gasFirstDeallocateLeg + } + return gasAdditionalDeallocateLeg + case gasRouteUnknown: + if first { + return gasFirstUnknownLeg + } + return gasAdditionalUnknownLeg + default: + if first { + return gasFirstUnknownLeg + } + return gasAdditionalUnknownLeg + } +} + +func fixedGasUnits(feedCount int) uint64 { + feeds := uint64(defaultPriceUpdateFeeds) + if feedCount > 0 { + feeds = uint64(feedCount) + } + feedUnits := saturatingMulUint64(gasPriceUpdatePerFeed, feeds) + return saturatingAddUint64(saturatingAddUint64(gasBaseUnits, gasExecutorDebitSurcharge), feedUnits) +} + +func usableBundleGasLimit(headerGasLimit uint64) uint64 { + if headerGasLimit == 0 { + headerGasLimit = redstoneExecutorMaxGasUnits + } + limit := min(headerGasLimit, redstoneExecutorMaxGasUnits) + return saturatingMulUint64(limit, bundleGasLimitSafetyBps) / 10_000 +} + +func bundleFitsGasLimit(b chosenBundle, st *gasPredictorState, headerGasLimit uint64, feedCount int) bool { + return gasPredictionForBundleFeeds(b, st, feedCount).Units <= usableBundleGasLimit(headerGasLimit) +} + +func gasCostNative(units uint64, gasPrice *big.Int) *big.Int { + return new(big.Int).Mul(new(big.Int).SetUint64(units), orZero(gasPrice)) +} + +func (r gasRoute) String() string { + switch r { + case gasRouteAcquire: + return "acquire" + case gasRouteAllocate: + return "allocate" + case gasRouteDeallocate: + return "deallocate" + case gasRouteUnknown: + return gasRouteUnknownLabel + default: + return gasRouteUnknownLabel + } +} + +func gasRoutesString(routes []gasRoute) string { + if len(routes) == 0 { + return "" + } + out := make([]string, len(routes)) + for i, r := range routes { + out[i] = r.String() + } + return strings.Join(out, ",") +} + +func minBig(a, b *big.Int) *big.Int { + if a.Cmp(b) <= 0 { + return new(big.Int).Set(a) + } + return new(big.Int).Set(b) +} + +func saturatingMulUint64(a, b uint64) uint64 { + if a != 0 && b > ^uint64(0)/a { + return ^uint64(0) + } + return a * b +} + +func saturatingAddUint64(a, b uint64) uint64 { + if b > ^uint64(0)-a { + return ^uint64(0) + } + return a + b +} diff --git a/internal/solvers/redstoneoev/gaspredictor_test.go b/internal/solvers/redstoneoev/gaspredictor_test.go new file mode 100644 index 00000000..4dea757d --- /dev/null +++ b/internal/solvers/redstoneoev/gaspredictor_test.go @@ -0,0 +1,207 @@ +package redstoneoev + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestGasUnitsForBundleRoutes(t *testing.T) { + coll := common.HexToAddress("0x00000000000000000000000000000000000000ca") + bundle := bundleWithExpectedLoanOuts(coll, 100) + + cases := []struct { + name string + st *gasPredictorState + want uint64 + }{ + { + name: "unknown snapshot uses conservative code fallback", + st: nil, + want: fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstUnknownLeg, + }, + { + name: "acquire-only", + st: &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{coll: big.NewInt(100)}, + }, + want: fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstAcquireLeg, + }, + { + name: "allocate from free assets", + st: &gasPredictorState{ + FreeAssets: big.NewInt(100), + Withdrawable: big.NewInt(100), + Acquire: map[common.Address]*big.Int{}, + }, + want: fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstAllocateLeg, + }, + { + name: "deallocate before allocate", + st: &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(100), + Acquire: map[common.Address]*big.Int{}, + }, + want: fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstDeallocateLeg, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := gasPredictionForBundle(bundle, c.st).Units; got != c.want { + t.Fatalf("gasPredictionForBundle units = %d, want %d", got, c.want) + } + }) + } +} + +func TestGasUnitsForBundleConsumesSharedBudgets(t *testing.T) { + coll := common.HexToAddress("0x00000000000000000000000000000000000000ca") + bundle := bundleWithExpectedLoanOuts(coll, 70, 70, 70) + st := &gasPredictorState{ + FreeAssets: big.NewInt(80), + Withdrawable: big.NewInt(200), + Acquire: map[common.Address]*big.Int{coll: big.NewInt(100)}, + } + want := fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstAcquireLeg + gasAdditionalAllocateLeg + gasAdditionalDeallocateLeg + pred := gasPredictionForBundle(bundle, st) + if got := pred.Units; got != want { + t.Fatalf("gasPredictionForBundle units = %d, want %d", got, want) + } + if got := gasRoutesString(pred.Routes); got != "acquire,allocate,deallocate" { + t.Fatalf("routes = %q", got) + } + // The estimator must not mutate the cached predictor snapshot; buildBid reads it lock-free across bids. + if st.Acquire[coll].String() != "100" || st.FreeAssets.String() != "80" || st.Withdrawable.String() != "200" { + t.Fatalf("predictor mutated input state: %+v", st) + } +} + +func TestGasPredictionFixedFeedCostAndLimit(t *testing.T) { + bundle := bundleWithExpectedLoanOuts(common.Address{}, 1, 1) + pred := gasPredictionForBundleFeeds(bundle, nil, 3) + want := gasBaseUnits + gasExecutorDebitSurcharge + 3*gasPriceUpdatePerFeed + gasFirstUnknownLeg + gasAdditionalUnknownLeg + if pred.Units != want { + t.Fatalf("gas with feed updates = %d, want %d", pred.Units, want) + } + if got, limitWant := usableBundleGasLimit(30_000_000), uint64(1_700_000); got != limitWant { + t.Fatalf("usableBundleGasLimit = %d, want %d", got, limitWant) + } + if got, limitWant := usableBundleGasLimit(1_000_000), uint64(850_000); got != limitWant { + t.Fatalf("small-chain usableBundleGasLimit = %d, want %d", got, limitWant) + } +} + +func TestLiveRedStoneLimitRejectsThreeAllocateLegs(t *testing.T) { + coll := common.HexToAddress("0x00000000000000000000000000000000000000ca") + two := bundleWithExpectedLoanOuts(coll, 1, 1) + three := bundleWithExpectedLoanOuts(coll, 1, 1, 1) + four := bundleWithExpectedLoanOuts(coll, 1, 1, 1, 1) + st := &gasPredictorState{ + FreeAssets: big.NewInt(10), + Withdrawable: big.NewInt(10), + Acquire: map[common.Address]*big.Int{}, + } + + if !bundleFitsGasLimit(two, st, 2_000_000, 3) { + t.Fatal("two allocate legs should fit the observed RedStone settlement gas limit") + } + if !bundleFitsGasLimit(three, st, 2_000_000, 3) { + t.Fatal("three allocate legs should fit the observed RedStone settlement gas limit") + } + if bundleFitsGasLimit(four, st, 2_000_000, 3) { + t.Fatal("four allocate legs must not fit the observed RedStone settlement gas limit") + } +} + +func TestGasPredictionTracksForkCalibratedSettlements(t *testing.T) { + coll := common.HexToAddress("0x00000000000000000000000000000000000000ca") + bundle := func(legs int) chosenBundle { + outs := make([]int64, legs) + for i := range outs { + outs[i] = 1 + } + return bundleWithExpectedLoanOuts(coll, outs...) + } + cases := []struct { + name string + legs int + state *gasPredictorState + debitGas uint64 + }{ + { + name: "one acquire leg", + legs: 1, + state: &gasPredictorState{ + FreeAssets: big.NewInt(10), + Withdrawable: big.NewInt(10), + Acquire: map[common.Address]*big.Int{coll: big.NewInt(10)}, + }, + debitGas: 469_911, + }, + { + name: "two acquire legs", + legs: 2, + state: &gasPredictorState{ + FreeAssets: big.NewInt(10), + Withdrawable: big.NewInt(10), + Acquire: map[common.Address]*big.Int{coll: big.NewInt(10)}, + }, + debitGas: 588_048, + }, + { + name: "one allocate leg", + legs: 1, + state: &gasPredictorState{ + FreeAssets: big.NewInt(10), + Withdrawable: big.NewInt(10), + Acquire: map[common.Address]*big.Int{}, + }, + debitGas: 703_664, + }, + { + name: "two allocate legs", + legs: 2, + state: &gasPredictorState{ + FreeAssets: big.NewInt(10), + Withdrawable: big.NewInt(10), + Acquire: map[common.Address]*big.Int{}, + }, + debitGas: 969_948, + }, + { + name: "mixed acquire then allocate", + legs: 2, + state: &gasPredictorState{ + FreeAssets: big.NewInt(10), + Withdrawable: big.NewInt(10), + Acquire: map[common.Address]*big.Int{coll: big.NewInt(1)}, + }, + debitGas: 817_877, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + predicted := gasPredictionForBundleFeeds(bundle(c.legs), c.state, defaultPriceUpdateFeeds).Units + if predicted < c.debitGas { + t.Fatalf("predicted gas %d below debit gas %d", predicted, c.debitGas) + } + if predicted > c.debitGas*115/100 { + t.Fatalf("predicted gas %d too far above debit gas %d", predicted, c.debitGas) + } + }) + } +} + +func bundleWithExpectedLoanOuts(coll common.Address, outs ...int64) chosenBundle { + b := chosenBundle{ + legs: make([]bundleLeg, len(outs)), + } + for i, out := range outs { + b.legs[i] = bundleLeg{expectedLoanOut: big.NewInt(out), collateral: coll} + } + return b +} diff --git a/internal/solvers/redstoneoev/live_fork_payload_test.go b/internal/solvers/redstoneoev/live_fork_payload_test.go new file mode 100644 index 00000000..99f4e2ba --- /dev/null +++ b/internal/solvers/redstoneoev/live_fork_payload_test.go @@ -0,0 +1,129 @@ +//go:build live + +package redstoneoev + +import ( + "context" + "encoding/json" + "math/big" + "os" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/chain" + appconfig "github.com/symbioticfi/vault-solver/internal/config" + "github.com/symbioticfi/vault-solver/internal/signer" + "github.com/symbioticfi/vault-solver/internal/solver" +) + +type forkPayload struct { + Callback string `json:"callback"` + Executor string `json:"executor"` + Signer string `json:"signer"` + AuctionID string `json:"auctionId"` + BidWei string `json:"bidWei"` + Nonce string `json:"nonce"` + MaxTxGasPrice string `json:"maxTxGasPrice"` + OperationData string `json:"operationData"` + LiquidationSig string `json:"liquidationSig"` + LiquidateCalldata string `json:"liquidateCalldata"` + PayBidCalldata string `json:"payBidCalldata"` + Borrowers []string `json:"borrowers"` +} + +// TestLiveSepoliaDumpForkPayload writes /tmp/oev-fork-payload.json for an anvil-fork settlement replay. +// +// set -a; . ./.env.local; set +a +// OEV_TEST_MONITOR=true OEV_ONCHAIN_PRICE_FOR_TEST=true \ +// OEV_TEST_MARKETS=... OEV_TEST_POSITIONS=... \ +// go test -tags live ./internal/solvers/redstoneoev -run TestLiveSepoliaDumpForkPayload -v +func TestLiveSepoliaDumpForkPayload(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cfgPath := getenvDefault("OEV_CONFIG", "../../../config/redstone-oev.example.yaml") + cfg, err := appconfig.Load(cfgPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + if len(cfg.Solvers) != 1 || cfg.Solvers[0].Name != Name { + t.Fatalf("expected single %s solver in %s", Name, cfgPath) + } + chainClient, err := chain.Dial(ctx, []string{cfg.Chain.RPCURL}, "", cfg.Chain.MulticallAddress, logr.Discard()) + if err != nil { + t.Fatalf("dial chain: %v", err) + } + defer chainClient.Close() + sgnr, err := signer.FromConfig(cfg.Signer) + if err != nil { + t.Fatalf("load signer: %v", err) + } + built, err := factory(cfg.Solvers[0].Config, solver.Deps{Chain: chainClient, Signer: sgnr, Log: logr.Discard()}) + if err != nil { + t.Fatalf("build solver: %v", err) + } + s, ok := built.(*Solver) + if !ok { + t.Fatalf("unexpected solver type %T", built) + } + s.refreshState(ctx) + s.mon.refresh(ctx) + snap := s.mon.snapshot() + if snap == nil || len(snap.prices) == 0 { + t.Fatalf("empty monitor snapshot") + } + + prices := make(map[string]string, len(snap.prices)) + for id, price := range snap.prices { + oracle := snap.markets[id].Params.Oracle + prices[oracle.Hex()] = price.String() + } + auction := AuctionMessage{ + Op: "auction", + ID: "fork-debug-" + time.Now().UTC().Format("20060102T150405Z"), + Timestamp: int64(snap.blockTime) * 1000, + Payload: AuctionPayload{Prices: prices}, + } + decision := s.buildBid(auction, func() time.Time { return time.Unix(int64(snap.blockTime), 0) }) + if decision.skip != "" { + t.Fatalf("buildBid skipped: %s gross=%s", decision.skip, decision.gross) + } + + opData, err := hexutil.Decode(decision.solve.Data.OperationData) + if err != nil { + t.Fatalf("decode operationData: %v", err) + } + bid := new(big.Int).Set(decision.bidNative) + out := forkPayload{ + Callback: s.cfg.Callback.Hex(), + Executor: s.cfg.Executor.Hex(), + Signer: sgnr.Address().Hex(), + AuctionID: auction.ID, + BidWei: bid.String(), + Nonce: decision.solve.Data.Nonce, + MaxTxGasPrice: decision.solve.Data.MaxTxGasPrice, + OperationData: decision.solve.Data.OperationData, + LiquidationSig: decision.solve.Data.LiquidationSig, + LiquidateCalldata: hexutil.Encode(callbackB.PackLiquidate(bid, sgnr.Address(), opData)), + PayBidCalldata: hexutil.Encode(callbackB.PackPayBid(bid)), + Borrowers: decision.solve.Data.Borrowers, + } + raw, err := json.MarshalIndent(out, "", " ") + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + if err := os.WriteFile("/tmp/oev-fork-payload.json", raw, 0o600); err != nil { + t.Fatalf("write payload: %v", err) + } + t.Logf("wrote /tmp/oev-fork-payload.json: nonce=%s legs=%d maxTxGasPrice=%s", out.Nonce, len(out.Borrowers), out.MaxTxGasPrice) +} + +func getenvDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/solvers/redstoneoev/live_test.go b/internal/solvers/redstoneoev/live_test.go new file mode 100644 index 00000000..72aff18e --- /dev/null +++ b/internal/solvers/redstoneoev/live_test.go @@ -0,0 +1,113 @@ +//go:build live + +// Live read-only checks for the production Morpho API monitor path. They are OPT-IN (`-tags live`) and +// never run in the normal gate. +package redstoneoev + +import ( + "context" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" +) + +// TestLiveAPIMonitorSnapshotAndCandidates exercises the same production API path the OEV monitor uses: +// adapter-derived token pair -> Morpho markets with state -> monitor snapshot validation -> positions -> +// hot-path candidates. It uses a known mainnet USDC/PAXG pair as the adapter-derived stand-in; no RPC or +// real adapter is needed because this test targets the API-backed Morpho side. +// +// go test -tags live -run TestLiveAPIMonitorSnapshotAndCandidates -v ./internal/solvers/redstoneoev/ +func TestLiveAPIMonitorSnapshotAndCandidates(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + loan := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") // USDC + coll := common.HexToAddress("0x45804880De22913dAFE09f4980848ECE6EcbAf78") // PAXG + wantMarket := common.HexToHash("0x8eaf7b29f02ba8d8c1d7aeb587403dcb16e2e943e4e2f5f94b0963c2386406c9") + + mon := &apiMonitor{ + api: newMorphoClient("https://api.morpho.org/graphql"), + maxPositions: 100, + maxHF: 1.30, + log: logr.Discard(), + } + + apiMarkets, err := mon.api.DiscoverMarketData(ctx, 1, []common.Address{loan}, []common.Address{coll}) + if err != nil { + t.Fatalf("DiscoverMarketData live API failed: %v", err) + } + apiSnap := mon.apiMarketSnapshot(apiMarkets, loan, []common.Address{coll}, true) + if len(apiSnap.markets) == 0 { + t.Fatal("apiMonitor snapshot has no usable USDC/PAXG markets") + } + if _, ok := apiSnap.markets[wantMarket]; !ok { + t.Fatalf("apiMonitor snapshot missing known market %s (got %d markets)", wantMarket.Hex(), len(apiSnap.markets)) + } + if apiSnap.block == 0 || apiSnap.blockTime == 0 { + t.Fatalf("apiMonitor snapshot missing epoch: block=%d blockTime=%d", apiSnap.block, apiSnap.blockTime) + } + for id, info := range apiSnap.markets { + if info.Params.LoanToken != loan || info.Params.CollateralToken != coll || info.Params.Oracle == (common.Address{}) { + t.Fatalf("bad market params for %s: %+v", id.Hex(), info.Params) + } + if got, err := deriveMarketID(info.Params); err != nil || got != id { + t.Fatalf("market id verification failed for %s: derived=%s err=%v", id.Hex(), got.Hex(), err) + } + if _, ok := apiSnap.prices[id]; !ok { + t.Fatalf("market %s missing API state price", id.Hex()) + } + } + + ids := make([]common.Hash, 0, len(apiSnap.markets)) + quotes := make(map[common.Hash]AdapterQuote, len(apiSnap.markets)) + for id := range apiSnap.markets { + ids = append(ids, id) + quotes[id] = newQuote("1780000000000000000000", nil) + } + apiPositions, err := mon.api.PositionsByMarket(ctx, ids, mon.maxPositions, &mon.maxHF) + if err != nil { + t.Fatalf("PositionsByMarket live API failed: %v", err) + } + positions := apiPositionsSnapshot(apiPositions, apiSnap.markets) + if len(positions) == 0 { + t.Skip("live API returned no USDC/PAXG positions inside healthFactor <= 1.30 right now") + } + + mon.snap.Store(&snapshot{ + markets: apiSnap.markets, prices: apiSnap.prices, quotes: quotes, positions: positions, + block: apiSnap.block, blockTime: apiSnap.blockTime, + }) + + var targetMarket common.Hash + var targetBorrower common.Address + for id, byBorrower := range positions { + for borrower := range byBorrower { + targetMarket, targetBorrower = id, borrower + break + } + if targetBorrower != (common.Address{}) { + break + } + } + oracle := apiSnap.markets[targetMarket].Params.Oracle + price := apiSnap.prices[targetMarket] + auction := AuctionMessage{Payload: AuctionPayload{Prices: map[string]string{oracle.Hex(): price.String()}}} + cands := mon.candidates(auction, apiSnap.blockTime) + if len(cands) == 0 { + t.Fatal("apiMonitor.candidates returned no candidates for a snapshot position with matching oracle price") + } + found := false + for _, c := range cands { + if c.cand.MarketID == targetMarket && c.cand.Borrower == targetBorrower && c.price.Cmp(price) == 0 { + found = true + break + } + } + if !found { + t.Fatalf("apiMonitor.candidates did not include target %s/%s", targetMarket.Hex(), targetBorrower.Hex()) + } + t.Logf("apiMonitor live snapshot: markets=%d positions=%d block=%d candidate=%s/%s", + len(apiSnap.markets), len(apiPositions), apiSnap.block, targetMarket.Hex(), targetBorrower.Hex()) +} diff --git a/internal/solvers/redstoneoev/metrics.go b/internal/solvers/redstoneoev/metrics.go new file mode 100644 index 00000000..6263a847 --- /dev/null +++ b/internal/solvers/redstoneoev/metrics.go @@ -0,0 +1,113 @@ +package redstoneoev + +import ( + "time" + + "github.com/go-errors/errors" + "github.com/prometheus/client_golang/prometheus" +) + +// metrics are the OEV solver's collectors, registered on the shared Prometheus registry (served at +// the framework's /metrics). All methods are nil-safe so the solver runs unmetered when no registry +// is provided. +type metrics struct { + auctions prometheus.Counter + bids prometheus.Counter + wins prometheus.Counter + failedLiq prometheus.Counter + skips *prometheus.CounterVec + hotPath prometheus.Histogram + gasRatio prometheus.Histogram + deposit prometheus.Gauge + callbackNative prometheus.Gauge + depositLow prometheus.Gauge // 1 when the deposit is below the on-chain MIN_DEPOSIT floor +} + +func newMetrics(reg prometheus.Registerer) (*metrics, error) { + m := &metrics{ + auctions: prometheus.NewCounter(prometheus.CounterOpts{Name: "oev_auctions_total", Help: "OEV auction frames seen."}), + bids: prometheus.NewCounter(prometheus.CounterOpts{Name: "oev_bids_total", Help: "Bids sent (or would-bid in dry-run)."}), + wins: prometheus.NewCounter(prometheus.CounterOpts{Name: "oev_wins_total", Help: "Auctions won (auction-result names our callback)."}), + failedLiq: prometheus.NewCounter(prometheus.CounterOpts{Name: "oev_failed_liquidations_total", Help: "Reverted settlements for our callback (from the WS liquidation-result frame)."}), + skips: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "oev_skips_total", Help: "Auctions not bid on, by reason.", + }, []string{"reason"}), + hotPath: prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "oev_hotpath_seconds", Help: "handleAuction wall-clock (the ~400ms budget).", + Buckets: []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.4, 1}, + }), + gasRatio: prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "oev_settlement_gas_actual_predicted_ratio", Help: "Actual receipt gasUsed divided by predicted settlement gas units.", + Buckets: []float64{0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 5}, + }), + deposit: prometheus.NewGauge(prometheus.GaugeOpts{Name: "oev_deposit_wei", Help: "Signer's Executor deposit (wei)."}), + callbackNative: prometheus.NewGauge(prometheus.GaugeOpts{Name: "oev_callback_native_wei", Help: "Callback contract native balance (wei)."}), + depositLow: prometheus.NewGauge(prometheus.GaugeOpts{Name: "oev_deposit_below_floor", Help: "1 when the Executor deposit is below the on-chain MIN_DEPOSIT floor."}), + } + for _, c := range []prometheus.Collector{m.auctions, m.bids, m.wins, m.failedLiq, m.skips, m.hotPath, m.gasRatio, m.deposit, m.callbackNative, m.depositLow} { + if err := reg.Register(c); err != nil { + return nil, errors.Errorf("redstoneoev: register metric: %w", err) + } + } + return m, nil +} + +func (m *metrics) auction() { + if m != nil { + m.auctions.Inc() + } +} + +func (m *metrics) bid() { + if m != nil { + m.bids.Inc() + } +} + +func (m *metrics) won() { + if m != nil { + m.wins.Inc() + } +} + +func (m *metrics) failed() { + if m != nil { + m.failedLiq.Inc() + } +} + +func (m *metrics) skip(reason string) { + if m != nil { + m.skips.WithLabelValues(reason).Inc() + } +} + +func (m *metrics) latency(d time.Duration) { + if m != nil { + m.hotPath.Observe(d.Seconds()) + } +} + +func (m *metrics) settlementGas(predicted, actual uint64) { + if m != nil && predicted > 0 { + m.gasRatio.Observe(float64(actual) / float64(predicted)) + } +} + +func (m *metrics) balances(depositWei, callbackWei float64) { + if m != nil { + m.deposit.Set(depositWei) + m.callbackNative.Set(callbackWei) + } +} + +// depositBelowFloor sets the alarm gauge; "below" now means deposit < MIN_DEPOSIT. +func (m *metrics) depositBelowFloor(below bool) { + if m != nil { + v := 0.0 + if below { + v = 1 + } + m.depositLow.Set(v) + } +} diff --git a/internal/solvers/redstoneoev/monitor.go b/internal/solvers/redstoneoev/monitor.go new file mode 100644 index 00000000..ece9003d --- /dev/null +++ b/internal/solvers/redstoneoev/monitor.go @@ -0,0 +1,380 @@ +package redstoneoev + +import ( + "context" + "math/big" + "sync/atomic" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +const snapshotMaxAuctionLag = 3 * 12 * time.Second + +// snapshot is immutable once stored and read lock-free by the WS goroutine. +type snapshot struct { + markets map[common.Hash]MarketInfo + prices map[common.Hash]*big.Int + quotes map[common.Hash]AdapterQuote + positions map[common.Hash]map[common.Address]morpho.PositionState + + block uint64 + blockTime uint64 + updatedAt time.Time // wall clock of the last successful refresh store; zero until one succeeds +} + +type monitorSource interface { + name() string + run(context.Context) + refresh(context.Context) + snapshot() *snapshot + candidates(auction AuctionMessage, nowTs uint64) []evalItem +} + +// apiMonitor owns the API-backed Morpho snapshot. The run loop is the only snapshot writer. +type apiMonitor struct { + reader *reader + log logr.Logger + + maxPositions int + adapter common.Address + + maxHF float64 + callback common.Address + chainID int64 + + api *morphoClient + monitorPoll time.Duration + + snap atomic.Pointer[snapshot] +} + +func newAPIMonitor(r *reader, log logr.Logger, cfg *Config, chainID int64) *apiMonitor { + m := &apiMonitor{ + reader: r, + log: log.WithName("monitor"), + maxPositions: cfg.MaxTrackedPositions, + adapter: cfg.Adapter, + maxHF: cfg.DiscoveryMaxHealthFactor, + callback: cfg.Callback, + chainID: chainID, + monitorPoll: cfg.MonitorPoll, + api: newMorphoClient(cfg.MorphoAPIURL), + } + m.snap.Store(&snapshot{ + markets: map[common.Hash]MarketInfo{}, + prices: map[common.Hash]*big.Int{}, + quotes: map[common.Hash]AdapterQuote{}, + positions: map[common.Hash]map[common.Address]morpho.PositionState{}, + }) + return m +} + +func (m *apiMonitor) snapshot() *snapshot { + return m.snap.Load() +} + +func (m *apiMonitor) name() string { return "api" } + +// candidates evaluates our tracked at-risk set at the auction price. RedStone's pushed positions are ignored. +func (m *apiMonitor) candidates(auction AuctionMessage, nowTs uint64) []evalItem { + return candidatesFromAuction(m.log, m.snapshot(), auction, nowTs) +} + +func quoteCollateralsFromSnapshot(snap *snapshot) []common.Address { + if snap == nil { + return nil + } + seen := make(map[common.Address]bool, len(snap.quotes)) + out := make([]common.Address, 0, len(snap.quotes)) + for id := range snap.quotes { + info, ok := snap.markets[id] + if !ok { + continue + } + coll := info.Params.CollateralToken + if coll == (common.Address{}) || seen[coll] { + continue + } + seen[coll] = true + out = append(out, coll) + } + return out +} + +func compactQuotes(in map[common.Hash]*AdapterQuote) map[common.Hash]AdapterQuote { + out := make(map[common.Hash]AdapterQuote, len(in)) + for id, q := range in { + if q != nil { + out[id] = *q + } + } + return out +} + +// run drives API snapshot refreshes until ctx is cancelled. +func (m *apiMonitor) run(ctx context.Context) { + tick := time.NewTicker(m.monitorPoll) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + m.refresh(ctx) + } + } +} + +func (m *apiMonitor) refresh(ctx context.Context) { + adapter, err := m.reader.readAdapterSnapshot(ctx, m.callback, m.adapter) + if err != nil { + m.log.Error(err, "API refresh skipped: adapter state unreadable") + return + } + + apiMarkets, err := m.api.DiscoverMarketData(ctx, m.chainID, []common.Address{adapter.loan}, adapter.redeemable) + if err != nil { + m.log.Error(err, "morpho API market refresh failed; keeping cache") + return + } + apiSnap := m.apiMarketSnapshot(apiMarkets, adapter.loan, adapter.redeemable, adapter.filler) + if len(apiSnap.markets) == 0 { + m.log.V(1).Info("morpho API market refresh returned no usable adapter markets") + return + } + + apiQuotes, err := m.reader.ReadAdapterQuotes(ctx, apiSnap.params, m.adapter, apiSnap.serve) + if err != nil { + m.log.Error(err, "adapter quote refresh failed; keeping cache") + return + } + quotes := compactQuotes(apiQuotes) + + ids := make([]common.Hash, 0, len(apiSnap.markets)) + for id := range apiSnap.markets { + ids = append(ids, id) + } + apiPositions, err := m.api.PositionsByMarket(ctx, ids, m.maxPositions, &m.maxHF) + if err != nil { + m.log.Error(err, "morpho API position refresh failed; keeping cache") + return + } + positions := apiPositionsSnapshot(apiPositions, apiSnap.markets) + + m.snap.Store(&snapshot{ + markets: apiSnap.markets, prices: apiSnap.prices, quotes: quotes, positions: positions, + block: apiSnap.block, blockTime: apiSnap.blockTime, updatedAt: time.Now(), + }) +} + +type apiMarketSnapshot struct { + markets map[common.Hash]MarketInfo + prices map[common.Hash]*big.Int + params map[common.Hash]abiMarketParams + serve map[common.Hash]bool + block uint64 + blockTime uint64 +} + +func (m *apiMonitor) apiMarketSnapshot(apiMarkets []morphoMarket, loan common.Address, redeemable []common.Address, filler bool) apiMarketSnapshot { + redeem := make(map[common.Address]bool, len(redeemable)) + for _, a := range redeemable { + redeem[a] = true + } + out := apiMarketSnapshot{ + markets: make(map[common.Hash]MarketInfo, len(apiMarkets)), + prices: make(map[common.Hash]*big.Int, len(apiMarkets)), + params: make(map[common.Hash]abiMarketParams, len(apiMarkets)), + serve: make(map[common.Hash]bool, len(apiMarkets)), + } + views := make([]apiMarketView, 0, len(apiMarkets)) + for _, apiMarket := range apiMarkets { + view, ok := marketInfoFromAPI(apiMarket) + if !ok || view.info.Params.LoanToken != loan || !redeem[view.info.Params.CollateralToken] { + continue + } + derived, err := deriveMarketID(view.info.Params) + if err != nil || derived != view.id { + m.log.V(1).Info("morpho API market id mismatch; dropping", "market", view.id.Hex()) + continue + } + views = append(views, view) + if view.block > out.block { + out.block = view.block + out.blockTime = view.blockTime + } + } + for _, view := range views { + if view.block != out.block { + m.log.V(1).Info("morpho API market block mismatch; dropping", + "market", view.id.Hex(), "wantBlock", out.block, "gotBlock", view.block) + continue + } + out.markets[view.id] = view.info + out.params[view.id] = view.info.Params + out.serve[view.id] = filler + if view.price != nil { + out.prices[view.id] = view.price + } + } + return out +} + +type apiMarketView struct { + id common.Hash + info MarketInfo + price *big.Int + block uint64 + blockTime uint64 +} + +func marketInfoFromAPI(m morphoMarket) (apiMarketView, bool) { + if m.MarketID == (common.Hash{}) || m.CollateralAsset == nil || m.State == nil { + return apiMarketView{}, false + } + lltv, ok := parseAPIBig(m.LLTV) + if !ok { + return apiMarketView{}, false + } + supplyAssets, ok := parseAPIBig(m.State.SupplyAssets) + if !ok { + return apiMarketView{}, false + } + supplyShares, ok := parseAPIBig(m.State.SupplyShares) + if !ok { + return apiMarketView{}, false + } + borrowAssets, ok := parseAPIBig(m.State.BorrowAssets) + if !ok { + return apiMarketView{}, false + } + borrowShares, ok := parseAPIBig(m.State.BorrowShares) + if !ok { + return apiMarketView{}, false + } + lastUpdate, ok := parseAPIUint64(m.State.Timestamp) + if !ok { + return apiMarketView{}, false + } + block, ok := parseAPIUint64(m.State.BlockNumber) + if !ok || block == 0 { + return apiMarketView{}, false + } + var price *big.Int + if m.State.Price != "" { + if price, ok = parseAPIBig(m.State.Price); !ok { + return apiMarketView{}, false + } + } + params := abiMarketParams{ + LoanToken: m.LoanAsset.Address, + CollateralToken: m.CollateralAsset.Address, + Oracle: m.Oracle, + Irm: m.IRM, + Lltv: lltv, + } + if params.LoanToken == (common.Address{}) || params.CollateralToken == (common.Address{}) || + params.Oracle == (common.Address{}) { + return apiMarketView{}, false + } + return apiMarketView{id: m.MarketID, price: price, block: block, blockTime: lastUpdate, info: MarketInfo{ + Params: params, + State: morpho.MarketState{ + TotalSupplyAssets: supplyAssets, + TotalSupplyShares: supplyShares, + TotalBorrowAssets: borrowAssets, + TotalBorrowShares: borrowShares, + LastUpdate: lastUpdate, + Fee: big.NewInt(0), + Lltv: lltv, + BorrowRatePerSec: big.NewInt(0), + }, + }}, true +} + +func apiPositionsSnapshot(apiPositions []morphoPosition, markets map[common.Hash]MarketInfo) map[common.Hash]map[common.Address]morpho.PositionState { + out := make(map[common.Hash]map[common.Address]morpho.PositionState) + for _, p := range apiPositions { + if _, ok := markets[p.MarketID]; !ok { + continue + } + pos, ok := positionStateFromAPI(p) + if !ok { + continue + } + if out[p.MarketID] == nil { + out[p.MarketID] = make(map[common.Address]morpho.PositionState) + } + out[p.MarketID][p.Borrower] = pos + } + return out +} + +func positionStateFromAPI(p morphoPosition) (morpho.PositionState, bool) { + if p.MarketID == (common.Hash{}) || p.Borrower == (common.Address{}) { + return morpho.PositionState{}, false + } + borrowShares, ok := parseAPIBig(p.BorrowShares) + if !ok { + return morpho.PositionState{}, false + } + collateral, ok := parseAPIBig(p.Collateral) + if !ok { + return morpho.PositionState{}, false + } + return morpho.PositionState{BorrowShares: borrowShares, Collateral: collateral}, true +} + +func parseAPIBig(s string) (*big.Int, bool) { + n, ok := new(big.Int).SetString(s, 10) + if !ok || n.Sign() < 0 { + return nil, false + } + return n, true +} + +func parseAPIUint64(s string) (uint64, bool) { + n, ok := parseAPIBig(s) + if !ok || !n.IsUint64() { + return 0, false + } + return n.Uint64(), true +} + +func snapshotHasPositions(snap *snapshot) bool { + if snap == nil { + return false + } + for _, byBorrower := range snap.positions { + if len(byBorrower) > 0 { + return true + } + } + return false +} + +func (s *Solver) fresh(a AuctionMessage) (bool, string) { + skip := snapshotFreshForAuction(s.mon.snapshot(), a) + return skip == "", skip +} + +func snapshotFreshForAuction(snap *snapshot, auction AuctionMessage) string { + if !snapshotHasPositions(snap) { + return "" + } + if snap.block == 0 || snap.blockTime == 0 { + return skipStaleEpoch + } + auctionTs := auction.Timestamp / 1000 + if auctionTs <= 0 { + return "" + } + if uint64(auctionTs) > snap.blockTime+uint64(snapshotMaxAuctionLag/time.Second) { + return skipStaleEpoch + } + return "" +} diff --git a/internal/solvers/redstoneoev/monitor_test.go b/internal/solvers/redstoneoev/monitor_test.go new file mode 100644 index 00000000..2f8db530 --- /dev/null +++ b/internal/solvers/redstoneoev/monitor_test.go @@ -0,0 +1,217 @@ +package redstoneoev + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +func TestCandidatePriceSource(t *testing.T) { + id := common.HexToHash("0x01") + oracle := common.HexToAddress("0x00000000000000000000000000000000000000aa") + onchain := mustBig("1000000000000000000000000000000000000") + framePx := new(big.Int).Mul(onchain, big.NewInt(2)) + + snap := &snapshot{ + markets: map[common.Hash]MarketInfo{ + id: {Params: abiMarketParams{Oracle: oracle}, State: goldenMarket()}, + }, + prices: map[common.Hash]*big.Int{id: onchain}, + quotes: map[common.Hash]AdapterQuote{ + id: newQuote("1780000000000000000000", mustBig("100000000000")), + }, + positions: map[common.Hash]map[common.Address]morpho.PositionState{ + id: {common.Address{1}: goldenBorrower()}, + }, + } + auction := AuctionMessage{Payload: AuctionPayload{Prices: map[string]string{oracle.Hex(): framePx.String()}}} + + apiCands := candidatesFromAuction(logr.Discard(), snap, auction, snap.markets[id].State.LastUpdate) + if len(apiCands) != 1 || apiCands[0].price.Cmp(framePx) != 0 { + t.Fatalf("auction path price = %+v, want %v", apiCands, framePx) + } + + testCands := candidatesFromCachedPrices(snap, snap.markets[id].State.LastUpdate) + if len(testCands) != 1 || testCands[0].price.Cmp(onchain) != 0 { + t.Fatalf("cached-price path price = %+v, want %v", testCands, onchain) + } +} + +func TestCandidateRequiresAuctionPriceForMarketOracle(t *testing.T) { + id := common.HexToHash("0x01") + oracle := common.HexToAddress("0x00000000000000000000000000000000000000aa") + otherOracle := common.HexToAddress("0x00000000000000000000000000000000000000bb") + snap := &snapshot{ + markets: map[common.Hash]MarketInfo{ + id: {Params: abiMarketParams{Oracle: oracle}, State: goldenMarket()}, + }, + quotes: map[common.Hash]AdapterQuote{ + id: newQuote("1780000000000000000000", mustBig("100000000000")), + }, + positions: map[common.Hash]map[common.Address]morpho.PositionState{ + id: {common.Address{1}: goldenBorrower()}, + }, + } + auction := AuctionMessage{Payload: AuctionPayload{Prices: map[string]string{ + otherOracle.Hex(): "1000000000000000000000000000", + "not-an-address": "1000000000000000000000000000", + oracle.Hex(): "0", + }}} + + got := candidatesFromAuction(logr.Discard(), snap, auction, snap.markets[id].State.LastUpdate) + if len(got) != 0 { + t.Fatalf("market without positive auction price for its oracle must not produce candidates: %+v", got) + } +} + +func TestSnapshotFreshForAuction(t *testing.T) { + auctionAt := int64(1_000_000) + auction := AuctionMessage{Timestamp: auctionAt} + positioned := func(s snapshot) *snapshot { + market := common.Hash{1} + borrower := common.Address{2} + s.positions = map[common.Hash]map[common.Address]morpho.PositionState{ + market: { + borrower: {BorrowShares: big.NewInt(1), Collateral: big.NewInt(1)}, + }, + } + return &s + } + tests := []struct { + name string + snap *snapshot + want string + }{ + {"nil snapshot has no positions", nil, ""}, + {"empty snapshot needs no epoch", &snapshot{}, ""}, + {"positions need block", positioned(snapshot{}), skipStaleEpoch}, + {"positions need block time", positioned(snapshot{block: 1}), skipStaleEpoch}, + {"positions within auction lag are usable", positioned(snapshot{block: 1, blockTime: uint64(auctionAt / 1000)}), ""}, + {"positions older than auction lag are stale", positioned(snapshot{block: 1, blockTime: uint64(auctionAt/1000) - uint64(snapshotMaxAuctionLag/time.Second) - 1}), skipStaleEpoch}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := snapshotFreshForAuction(tc.snap, auction); got != tc.want { + t.Fatalf("snapshotFreshForAuction = %q, want %q", got, tc.want) + } + }) + } +} + +func TestMarketInfoFromAPI(t *testing.T) { + loan := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + coll := common.HexToAddress("0x45804880De22913dAFE09f4980848ECE6EcbAf78") + oracle := common.HexToAddress("0x1234567890123456789012345678901234567890") + irm := common.HexToAddress("0x2222222222222222222222222222222222222222") + lltv := mustBig("860000000000000000") + id, err := deriveMarketID(abiMarketParams{LoanToken: loan, CollateralToken: coll, Oracle: oracle, Irm: irm, Lltv: lltv}) + if err != nil { + t.Fatalf("deriveMarketID: %v", err) + } + + view, ok := marketInfoFromAPI(morphoMarket{ + MarketID: id, + Oracle: oracle, + IRM: irm, + LLTV: lltv.String(), + LoanAsset: morphoAsset{ + Address: loan, + }, + CollateralAsset: &morphoAsset{ + Address: coll, + }, + State: &morphoMarketState{ + BlockNumber: "123", + BorrowAssets: "1000", + BorrowShares: "900", + SupplyAssets: "5000", + SupplyShares: "4500", + Timestamp: "456", + Price: "1000000000000000000000000000000000000", + }, + }) + if !ok { + t.Fatal("marketInfoFromAPI returned !ok") + } + if view.id != id || view.block != 123 || view.blockTime != 456 || view.price.String() != "1000000000000000000000000000000000000" { + t.Fatalf("bad id/block/blockTime/price: id=%s block=%d blockTime=%d price=%v", + view.id, view.block, view.blockTime, view.price) + } + info := view.info + if info.Params.LoanToken != loan || info.Params.CollateralToken != coll || info.Params.Oracle != oracle || info.Params.Irm != irm { + t.Fatalf("bad params: %+v", info.Params) + } + if info.State.TotalBorrowAssets.String() != "1000" || info.State.TotalBorrowShares.String() != "900" || + info.State.TotalSupplyAssets.String() != "5000" || info.State.TotalSupplyShares.String() != "4500" || + info.State.LastUpdate != 456 || info.State.BorrowRatePerSec.Sign() != 0 || info.State.Fee.Sign() != 0 { + t.Fatalf("bad state: %+v", info.State) + } +} + +func TestAPIMarketSnapshotKeepsLatestBlockOnly(t *testing.T) { + loan := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + coll := common.HexToAddress("0x45804880De22913dAFE09f4980848ECE6EcbAf78") + lltv := mustBig("860000000000000000") + mk := func(oracle common.Address, block, ts string) morphoMarket { + params := abiMarketParams{LoanToken: loan, CollateralToken: coll, Oracle: oracle, Lltv: lltv} + id, err := deriveMarketID(params) + if err != nil { + t.Fatalf("deriveMarketID: %v", err) + } + return morphoMarket{ + MarketID: id, + Oracle: oracle, + LLTV: lltv.String(), + LoanAsset: morphoAsset{Address: loan}, + CollateralAsset: &morphoAsset{Address: coll}, + State: &morphoMarketState{ + BlockNumber: block, Timestamp: ts, + BorrowAssets: "1000", BorrowShares: "900", SupplyAssets: "5000", SupplyShares: "4500", + }, + } + } + old := mk(common.HexToAddress("0x1111111111111111111111111111111111111111"), "10", "120") + latest := mk(common.HexToAddress("0x2222222222222222222222222222222222222222"), "11", "132") + + snap := (&apiMonitor{log: logr.Discard()}).apiMarketSnapshot([]morphoMarket{old, latest}, loan, []common.Address{coll}, true) + if snap.block != 11 || snap.blockTime != 132 { + t.Fatalf("snapshot epoch = (%d,%d), want (11,132)", snap.block, snap.blockTime) + } + if _, ok := snap.markets[latest.MarketID]; !ok || len(snap.markets) != 1 { + t.Fatalf("latest-only markets = %+v, want exactly %s", snap.markets, latest.MarketID.Hex()) + } +} + +func TestAPIMarketAndPositionFailClosed(t *testing.T) { + if _, ok := marketInfoFromAPI(morphoMarket{ + MarketID: common.Hash{1}, + CollateralAsset: &morphoAsset{Address: common.Address{2}}, + State: &morphoMarketState{BlockNumber: "bad"}, + }); ok { + t.Fatal("bad market numbers must be rejected") + } + + if _, ok := positionStateFromAPI(morphoPosition{ + MarketID: common.Hash{1}, + Borrower: common.Address{2}, + BorrowShares: "not-a-number", + Collateral: "10", + }); ok { + t.Fatal("bad position numbers must be rejected") + } + + pos, ok := positionStateFromAPI(morphoPosition{ + MarketID: common.Hash{1}, + Borrower: common.Address{2}, + BorrowShares: "11", + Collateral: "22", + }) + if !ok || pos.BorrowShares.Cmp(big.NewInt(11)) != 0 || pos.Collateral.Cmp(big.NewInt(22)) != 0 { + t.Fatalf("bad parsed position: %+v ok=%v", pos, ok) + } +} diff --git a/internal/solvers/redstoneoev/morphoapi.go b/internal/solvers/redstoneoev/morphoapi.go new file mode 100644 index 00000000..a6b62826 --- /dev/null +++ b/internal/solvers/redstoneoev/morphoapi.go @@ -0,0 +1,334 @@ +package redstoneoev + +// morphoapi.go adapts generated Morpho GraphQL responses into OEV-local snapshot types. + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "slices" + "strings" + "time" + + "github.com/Khan/genqlient/graphql" + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/api/morphographql" + "github.com/symbioticfi/vault-solver/api/morphographql/scalars" +) + +// maxDiscoverMarkets bounds the candidate markets one discovery poll proposes (the `first` arg + a defensive +// truncation): a token pair has few real markets, so this only stops a misbehaving/compromised endpoint +// from flooding the local snapshot — far above any honest (loan, collateral) market count. +const maxDiscoverMarkets = 512 + +// maxMorphoRespBytes bounds the response body we read from the external endpoint (defence in depth atop the +// client Timeout): a misbehaving/compromised endpoint can't drive unbounded allocation in the discovery +// goroutine. The at-risk band for our market set is small; 8 MiB is far above any honest response. +const maxMorphoRespBytes = 8 << 20 + +// Live Morpho API request caps observed on 2026-06-26: `marketPositions(first:1001)` and +// `marketUniqueKey_in` with 101 ids both fail input validation. Keep each HTTP response small and page in +// the wrapper so solver config can express a larger logical cap. +const ( + maxPositionsPage = 1000 + maxPositionMarketIDs = 100 +) + +// morphoClient is the OEV-local adapter over the generated Morpho GraphQL binding. +type morphoClient struct { + gql graphql.Client +} + +type morphoMarket struct { + MarketID common.Hash + Oracle common.Address + IRM common.Address + LLTV string + LoanAsset morphoAsset + CollateralAsset *morphoAsset + State *morphoMarketState +} + +type morphoAsset struct { + Address common.Address +} + +type morphoMarketState struct { + BlockNumber string + BorrowAssets string + BorrowShares string + SupplyAssets string + SupplyShares string + Timestamp string + Price string +} + +type morphoPosition struct { + MarketID common.Hash + Borrower common.Address + HealthFactor *float64 + BorrowShares string + Collateral string +} + +func newMorphoClient(url string) *morphoClient { + hc := &http.Client{Timeout: 8 * time.Second} + return &morphoClient{ + gql: boundedGraphQLClient{url: url, hc: hc}, + } +} + +// DiscoverMarketData returns Morpho markets plus their latest indexed state for adapter-derived token +// pairs. Callers must still fail closed on malformed items and verify that the derived market id matches the +// returned id before using the data for execution. +func (a *morphoClient) DiscoverMarketData(ctx context.Context, chainID int64, loan, collateral []common.Address) ([]morphoMarket, error) { + if len(loan) == 0 || len(collateral) == 0 { + return nil, nil + } + data, err := morphographql.MorphoDiscoverMarkets(ctx, a.gql, lowerAddrs(loan), lowerAddrs(collateral), []int{int(chainID)}, maxDiscoverMarkets) + if err != nil { + return nil, err + } + out := make([]morphoMarket, 0, min(len(data.Markets.Items), maxDiscoverMarkets)) + for i := range data.Markets.Items { + if len(out) >= maxDiscoverMarkets { + break + } + m := morphoMarketFromDiscover(data.Markets.Items[i]) + if m.MarketID == (common.Hash{}) { + continue + } + out = append(out, m) + } + return out, nil +} + +func (a *morphoClient) PositionsByMarket(ctx context.Context, marketIDs []common.Hash, first int, maxHF *float64) ([]morphoPosition, error) { + if len(marketIDs) == 0 || first <= 0 { + return nil, nil + } + ids := make([]string, 0, len(marketIDs)) + for _, id := range marketIDs { + ids = append(ids, strings.ToLower(id.Hex())) + } + out := make([]morphoPosition, 0, min(first, maxPositionsPage)) + for start := 0; start < len(ids); start += maxPositionMarketIDs { + end := min(start+maxPositionMarketIDs, len(ids)) + page, err := a.positionsChunk(ctx, ids[start:end], first, maxHF) + if err != nil { + return nil, err + } + out = append(out, page...) + } + sortPositionsByRisk(out) + if len(out) > first { + out = out[:first] + } + return out, nil +} + +func (a *morphoClient) positionsChunk(ctx context.Context, ids []string, limit int, maxHF *float64) ([]morphoPosition, error) { + out := make([]morphoPosition, 0, min(limit, maxPositionsPage)) + for skip := 0; len(out) < limit; { + first := min(maxPositionsPage, limit-len(out)) + data, err := morphographql.MorphoPositionsByMarket(ctx, a.gql, ids, first, skip, maxHF) + if err != nil { + return nil, err + } + items := data.MarketPositions.Items + if len(items) == 0 { + break + } + for _, it := range items { + var state wirePositionState + if it.State != nil { + state = it.State + } + pos := morphoPositionFromWire(it.User.Address, it.Market.MarketId, it.HealthFactor, state) + if pos.MarketID == (common.Hash{}) || pos.Borrower == (common.Address{}) { + continue + } + out = append(out, pos) + } + if len(items) < first { + break + } + skip += len(items) + } + return out, nil +} + +func sortPositionsByRisk(pos []morphoPosition) { + slices.SortStableFunc(pos, func(a, b morphoPosition) int { + if a.HealthFactor != nil && b.HealthFactor != nil && *a.HealthFactor != *b.HealthFactor { + if *a.HealthFactor < *b.HealthFactor { + return -1 + } + return 1 + } + if a.HealthFactor == nil && b.HealthFactor != nil { + return 1 + } + if a.HealthFactor != nil && b.HealthFactor == nil { + return -1 + } + if c := a.MarketID.Cmp(b.MarketID); c != 0 { + return c + } + return a.Borrower.Cmp(b.Borrower) + }) +} + +// lowerAddrs maps addresses to their lowercase 0x hex form (the Morpho API matches addresses lowercased). +func lowerAddrs(addrs []common.Address) []string { + out := make([]string, len(addrs)) + for i, a := range addrs { + out[i] = strings.ToLower(a.Hex()) + } + return out +} + +type wireAsset interface { + GetAddress() string +} + +type wireMarketState interface { + GetBlockNumber() scalars.BigIntString + GetBorrowAssets() scalars.BigIntString + GetBorrowShares() scalars.BigIntString + GetSupplyAssets() scalars.BigIntString + GetSupplyShares() scalars.BigIntString + GetTimestamp() scalars.BigIntString + GetPrice() *scalars.BigIntString +} + +func morphoMarketFromDiscover(it morphographql.MorphoDiscoverMarketsMarketsPaginatedMarketsItemsMarket) morphoMarket { + var coll wireAsset + if it.CollateralAsset != nil { + coll = it.CollateralAsset + } + var state wireMarketState + if it.State != nil { + state = it.State + } + return morphoMarketFromWire(it.MarketId, it.OracleAddress, it.IrmAddress, it.Lltv.String(), &it.LoanAsset, coll, state) +} + +func morphoMarketFromWire(id, oracle, irm, lltv string, loan wireAsset, collateral wireAsset, state wireMarketState) morphoMarket { + m := morphoMarket{ + MarketID: common.HexToHash(id), + Oracle: common.HexToAddress(oracle), + IRM: common.HexToAddress(irm), + LLTV: lltv, + LoanAsset: morphoAssetFromWire(loan), + } + if collateral != nil { + c := morphoAssetFromWire(collateral) + m.CollateralAsset = &c + } + if state != nil { + m.State = morphoMarketStateFromWire(state) + } + return m +} + +func morphoAssetFromWire(a wireAsset) morphoAsset { + if a == nil { + return morphoAsset{} + } + return morphoAsset{Address: common.HexToAddress(a.GetAddress())} +} + +func morphoMarketStateFromWire(s wireMarketState) *morphoMarketState { + st := &morphoMarketState{ + BlockNumber: s.GetBlockNumber().String(), + BorrowAssets: s.GetBorrowAssets().String(), + BorrowShares: s.GetBorrowShares().String(), + SupplyAssets: s.GetSupplyAssets().String(), + SupplyShares: s.GetSupplyShares().String(), + Timestamp: s.GetTimestamp().String(), + } + if p := s.GetPrice(); p != nil { + st.Price = p.String() + } + return st +} + +type wirePositionState interface { + GetBorrowShares() scalars.BigIntString + GetCollateral() scalars.BigIntString +} + +func morphoPositionFromWire(user, market string, health *float64, state wirePositionState) morphoPosition { + pos := morphoPosition{ + MarketID: common.HexToHash(market), + Borrower: common.HexToAddress(user), + HealthFactor: health, + } + if state == nil { + return pos + } + pos.BorrowShares = state.GetBorrowShares().String() + pos.Collateral = state.GetCollateral().String() + return pos +} + +type boundedGraphQLClient struct { + url string + hc *http.Client +} + +// MakeRequest is genqlient's transport hook. It keeps the old fail-safe HTTP behavior while the query and +// response types come from generated code. +func (c boundedGraphQLClient) MakeRequest(ctx context.Context, req *graphql.Request, resp *graphql.Response) error { + body, err := json.Marshal(req) + if err != nil { + return errors.Errorf("morpho graphql: marshal request: %w", err) + } + reqCtx, cancel := context.WithTimeout(ctx, c.hc.Timeout) + defer cancel() + httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.url, bytes.NewReader(body)) + if err != nil { + return errors.Errorf("morpho graphql: build request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + httpResp, err := c.hc.Do(httpReq) + if err != nil { + return errors.Errorf("morpho graphql: request: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() + if httpResp.StatusCode != http.StatusOK { + return errors.Errorf("morpho graphql: status %d", httpResp.StatusCode) + } + + raw, err := io.ReadAll(io.LimitReader(httpResp.Body, maxMorphoRespBytes+1)) + if err != nil { + return errors.Errorf("morpho graphql: read response: %w", err) + } + if len(raw) > maxMorphoRespBytes { + return errors.New("morpho graphql: response too large") + } + if err := json.Unmarshal(raw, resp); err != nil { + return errors.Errorf("morpho graphql: decode response: %w", err) + } + if len(resp.Errors) > 0 { + return errors.Errorf("morpho graphql: graphql error: %s", resp.Errors[0].Message) + } + + var envelope struct { + Data *json.RawMessage `json:"data"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return errors.Errorf("morpho graphql: decode response envelope: %w", err) + } + if envelope.Data == nil || bytes.Equal(bytes.TrimSpace(*envelope.Data), []byte("null")) { + return errors.New("morpho graphql: response missing data") + } + return nil +} diff --git a/internal/solvers/redstoneoev/morphoapi_test.go b/internal/solvers/redstoneoev/morphoapi_test.go new file mode 100644 index 00000000..aa421f93 --- /dev/null +++ b/internal/solvers/redstoneoev/morphoapi_test.go @@ -0,0 +1,289 @@ +package redstoneoev + +import ( + "context" + "encoding/json" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +// newTestMorphoClient points a morphoClient at the given httptest server. +func newTestMorphoClient(url string) *morphoClient { return newMorphoClient(url) } + +// mktA is the market id requested in these tests; mktB is one never requested. The fixtures below mirror +// the LIVE Morpho schema (api.morpho.org/graphql): the output field is `marketId` (not uniqueKey). +var ( + apiMktA = common.HexToHash("0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5") + apiMktB = common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111") +) + +type positionsGraphQLVars struct { + IDs []string `json:"ids"` + First int `json:"first"` + Skip int `json:"skip"` +} + +type positionsGraphQLRequest struct { + Variables positionsGraphQLVars `json:"variables"` +} + +// newJSONServer returns an httptest server replying with a fixed status + JSON body. +func newJSONServer(t *testing.T, status int, body string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + })) +} + +func TestMorphoClientDiscoverMarketData(t *testing.T) { + loan := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + coll := common.HexToAddress("0x45804880De22913dAFE09f4980848ECE6EcbAf78") + + t.Run("normal response returns candidate ids", func(t *testing.T) { + body := `{"data":{"markets":{"items":[ + {"marketId":"` + apiMktA.Hex() + `"}, + {"marketId":"` + apiMktB.Hex() + `"} + ]}}}` + srv := newJSONServer(t, http.StatusOK, body) + defer srv.Close() + + got, err := newTestMorphoClient(srv.URL).DiscoverMarketData(context.Background(), 1, []common.Address{loan}, []common.Address{coll}) + if err != nil { + t.Fatalf("DiscoverMarketData: %v", err) + } + if len(got) != 2 || got[0].MarketID != apiMktA || got[1].MarketID != apiMktB { + t.Fatalf("bad markets: %+v", got) + } + }) + + t.Run("graphql errors => error", func(t *testing.T) { + srv := newJSONServer(t, http.StatusOK, `{"errors":[{"message":"bad chain"}]}`) + defer srv.Close() + if _, err := newTestMorphoClient(srv.URL).DiscoverMarketData(context.Background(), 1, []common.Address{loan}, []common.Address{coll}); err == nil { + t.Fatal("expected an error on non-empty graphql errors") + } + }) + + t.Run("http 500 => error", func(t *testing.T) { + srv := newJSONServer(t, http.StatusInternalServerError, `{}`) + defer srv.Close() + if _, err := newTestMorphoClient(srv.URL).DiscoverMarketData(context.Background(), 1, []common.Address{loan}, []common.Address{coll}); err == nil { + t.Fatal("expected an error on HTTP 500") + } + }) + + t.Run("empty/zero marketId skipped, valid kept", func(t *testing.T) { + body := `{"data":{"markets":{"items":[ + {"marketId":""}, + {"marketId":"0x0000000000000000000000000000000000000000000000000000000000000000"}, + {"marketId":"` + apiMktA.Hex() + `"} + ]}}}` + srv := newJSONServer(t, http.StatusOK, body) + defer srv.Close() + + got, err := newTestMorphoClient(srv.URL).DiscoverMarketData(context.Background(), 1, []common.Address{loan}, []common.Address{coll}) + if err != nil { + t.Fatalf("DiscoverMarketData: %v", err) + } + if len(got) != 1 || got[0].MarketID != apiMktA { + t.Fatalf("want exactly the one valid market, got %+v", got) + } + }) + + t.Run("request sends lowercased addresses and chainId_in", func(t *testing.T) { + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":{"markets":{"items":[]}}}`) + })) + defer srv.Close() + + if _, err := newTestMorphoClient(srv.URL).DiscoverMarketData(context.Background(), 11155111, []common.Address{loan}, []common.Address{coll}); err != nil { + t.Fatalf("DiscoverMarketData: %v", err) + } + if !strings.Contains(gotBody, strings.ToLower(loan.Hex())) || !strings.Contains(gotBody, strings.ToLower(coll.Hex())) { + t.Fatalf("request body missing lowercased loan/collateral: %s", gotBody) + } + queryBody := strings.NewReplacer(" ", "", "\n", "", "\t", "").Replace(gotBody) + if !strings.Contains(queryBody, "chainId_in") || !strings.Contains(gotBody, `"chains":[11155111]`) { + t.Fatalf("request body missing chainId_in scope: %s", gotBody) + } + }) + + t.Run("empty pair => no call", func(t *testing.T) { + // A request would dial 127.0.0.1:0 and fail; an empty loan or collateral set must short-circuit. + api := newMorphoClient("http://127.0.0.1:0") + if got, err := api.DiscoverMarketData(context.Background(), 1, nil, []common.Address{coll}); err != nil || got != nil { + t.Fatalf("empty loan: got=%+v err=%v", got, err) + } + if got, err := api.DiscoverMarketData(context.Background(), 1, []common.Address{loan}, nil); err != nil || got != nil { + t.Fatalf("empty collateral: got=%+v err=%v", got, err) + } + }) +} + +func TestMorphoClientPositions(t *testing.T) { + borrower := common.HexToAddress("0x629d764eC8563AFA701709B52c1a215e865632dE") + item := `{ + "user":{"address":"` + borrower.Hex() + `"}, + "market":{"marketId":"` + apiMktA.Hex() + `"}, + "state":{ + "borrowShares":"34", + "collateral":"56" + }, + "healthFactor":1.2 + }` + + t.Run("bulk by market", func(t *testing.T) { + srv := newJSONServer(t, http.StatusOK, `{"data":{"marketPositions":{"items":[`+item+`]}}}`) + defer srv.Close() + maxHF := 1.3 + got, err := newTestMorphoClient(srv.URL).PositionsByMarket(context.Background(), []common.Hash{apiMktA}, 10, &maxHF) + if err != nil { + t.Fatalf("PositionsByMarket: %v", err) + } + if len(got) != 1 || got[0].MarketID != apiMktA || got[0].Borrower != borrower { + t.Fatalf("bad position parse: %+v", got) + } + if got[0].HealthFactor == nil || *got[0].HealthFactor != 1.2 || + got[0].BorrowShares != "34" || got[0].Collateral != "56" { + t.Fatalf("bad state parse: %+v", got[0]) + } + }) + + t.Run("missing state does not panic", func(t *testing.T) { + body := `{"data":{"marketPositions":{"items":[{ + "user":{"address":"` + borrower.Hex() + `"}, + "market":{"marketId":"` + apiMktA.Hex() + `"}, + "healthFactor":1.2 + }]}}}` + srv := newJSONServer(t, http.StatusOK, body) + defer srv.Close() + + got, err := newTestMorphoClient(srv.URL).PositionsByMarket(context.Background(), []common.Hash{apiMktA}, 10, nil) + if err != nil { + t.Fatalf("PositionsByMarket: %v", err) + } + if len(got) != 1 || got[0].BorrowShares != "" || got[0].Collateral != "" { + t.Fatalf("missing state should keep only identity/risk, got %+v", got) + } + if _, ok := positionStateFromAPI(got[0]); ok { + t.Fatal("missing state must fail closed before entering the monitor snapshot") + } + }) + + t.Run("bulk chunks live API request caps", func(t *testing.T) { + var calls []positionsGraphQLVars + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req positionsGraphQLRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + calls = append(calls, req.Variables) + if len(req.Variables.IDs) > maxPositionMarketIDs || req.Variables.First > maxPositionsPage { + _, _ = io.WriteString(w, `{"errors":[{"message":"Input validation failed"}]}`) + return + } + _, _ = io.WriteString(w, `{"data":{"marketPositions":{"items":[]}}}`) + })) + defer srv.Close() + + ids := make([]common.Hash, maxPositionMarketIDs+1) + for i := range ids { + ids[i] = common.BigToHash(big.NewInt(int64(i + 1))) + } + if _, err := newTestMorphoClient(srv.URL).PositionsByMarket(context.Background(), ids, 10_000, nil); err != nil { + t.Fatalf("PositionsByMarket: %v", err) + } + if len(calls) != 2 { + t.Fatalf("calls = %d, want 2 chunks", len(calls)) + } + if len(calls[0].IDs) != maxPositionMarketIDs || len(calls[1].IDs) != 1 { + t.Fatalf("bad id chunks: %d/%d", len(calls[0].IDs), len(calls[1].IDs)) + } + for _, c := range calls { + if c.First != maxPositionsPage || c.Skip != 0 { + t.Fatalf("bad page args: %+v", c) + } + } + }) + + t.Run("bulk paginates beyond one API page", func(t *testing.T) { + var skips []int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req positionsGraphQLRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + skips = append(skips, req.Variables.Skip) + count := req.Variables.First + if req.Variables.Skip >= maxPositionsPage { + count = 1 + } + items := make([]string, count) + for i := range items { + addr := common.BigToAddress(big.NewInt(int64(req.Variables.Skip + i + 1))).Hex() + items[i] = `{"user":{"address":"` + addr + `"},"market":{"marketId":"` + apiMktA.Hex() + `"},"state":{"borrowShares":"1","collateral":"1"},"healthFactor":1.2}` + } + _, _ = io.WriteString(w, `{"data":{"marketPositions":{"items":[`+strings.Join(items, ",")+`]}}}`) + })) + defer srv.Close() + + got, err := newTestMorphoClient(srv.URL).PositionsByMarket(context.Background(), []common.Hash{apiMktA}, maxPositionsPage+1, nil) + if err != nil { + t.Fatalf("PositionsByMarket: %v", err) + } + if len(got) != maxPositionsPage+1 { + t.Fatalf("positions = %d, want %d", len(got), maxPositionsPage+1) + } + if len(skips) != 2 || skips[0] != 0 || skips[1] != maxPositionsPage { + t.Fatalf("skips = %+v, want [0 %d]", skips, maxPositionsPage) + } + }) + + t.Run("bulk truncates by global risk after market chunks", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req positionsGraphQLRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + hf := "1.2" + chunkBorrower := common.HexToAddress("0x0000000000000000000000000000000000000001") + if len(req.Variables.IDs) == 1 { + hf = "1.01" + chunkBorrower = common.HexToAddress("0x0000000000000000000000000000000000000002") + } + chunkItem := `{"user":{"address":"` + chunkBorrower.Hex() + `"},"market":{"marketId":"` + apiMktA.Hex() + `"},"state":{"borrowShares":"1","collateral":"1"},"healthFactor":` + hf + `}` + _, _ = io.WriteString(w, `{"data":{"marketPositions":{"items":[`+chunkItem+`]}}}`) + })) + defer srv.Close() + + ids := make([]common.Hash, maxPositionMarketIDs+1) + for i := range ids { + ids[i] = common.BigToHash(big.NewInt(int64(i + 1))) + } + got, err := newTestMorphoClient(srv.URL).PositionsByMarket(context.Background(), ids, 1, nil) + if err != nil { + t.Fatalf("PositionsByMarket: %v", err) + } + if len(got) != 1 || got[0].Borrower != common.HexToAddress("0x0000000000000000000000000000000000000002") { + t.Fatalf("global top risk was not selected after chunk merge: %+v", got) + } + }) +} diff --git a/internal/solvers/redstoneoev/noncestore.go b/internal/solvers/redstoneoev/noncestore.go new file mode 100644 index 00000000..1f98a25c --- /dev/null +++ b/internal/solvers/redstoneoev/noncestore.go @@ -0,0 +1,34 @@ +package redstoneoev + +import "sync" + +// nonceStore issues strictly-ascending EXECUTOR_V6 nonces. The Executor requires nonce > +// nonces[signer] and only advances that on a settled (or failed) execution, so we track the last +// issued nonce in memory and reconcile it with the on-chain value (which can jump ahead after a +// settlement we didn't initiate the next bid for). See docs/OEV-PLAN.md §6.2. +type nonceStore struct { + mu sync.Mutex + issued uint64 // highest nonce handed out so far +} + +// reconcile raises the in-memory high-water mark to the on-chain nonce (called at boot and from the +// ops loop). Never lowers it. +func (n *nonceStore) reconcile(onchain uint64) { + n.mu.Lock() + defer n.mu.Unlock() + if onchain > n.issued { + n.issued = onchain + } +} + +// next returns the next nonce to sign: strictly greater than both the on-chain nonce and any nonce +// already issued this session. +func (n *nonceStore) next(onchain uint64) uint64 { + n.mu.Lock() + defer n.mu.Unlock() + if onchain > n.issued { + n.issued = onchain + } + n.issued++ + return n.issued +} diff --git a/internal/solvers/redstoneoev/operationdata.go b/internal/solvers/redstoneoev/operationdata.go new file mode 100644 index 00000000..5173396c --- /dev/null +++ b/internal/solvers/redstoneoev/operationdata.go @@ -0,0 +1,174 @@ +package redstoneoev + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/go-errors/errors" +) + +// LiquidationLeg is one solver-selected callback leg. The callback reads the current LiquidLane getMaxAssets +// cap on-chain when it prices the swap. +type LiquidationLeg struct { + MarketId common.Hash + Borrower common.Address + MaxSeizeAssets *big.Int + MinProfit *big.Int +} + +type operationAuth struct { + AuctionKey common.Hash + BidAmount *big.Int + MinBundleProfit *big.Int + Deadline *big.Int +} + +type callbackLeg struct { + MarketId common.Hash + Borrower common.Address + MaxSeizeAssets *big.Int + MinProfit *big.Int +} + +type operationData struct { + Auth operationAuth + Legs []callbackLeg + AuthSig []byte +} + +var ( + operationDataArgs = abi.Arguments{{Type: mustOperationDataType()}} + callbackLegArrayArgs = abi.Arguments{{Type: mustCallbackLegArrayType()}} + authDigestArgs = abi.Arguments{ + {Type: mustType("bytes32")}, + {Type: mustType("uint256")}, + {Type: mustType("address")}, + {Type: mustType("address")}, + {Type: mustType("bytes32")}, + {Type: mustType("uint256")}, + {Type: mustType("uint256")}, + {Type: mustType("uint256")}, + {Type: mustType("bytes32")}, + } + authDomain = crypto.Keccak256Hash([]byte("SYMBIOTIC_OEV_AUTH_V1")) +) + +// EncodeOperationData ABI-encodes the callback payload committed by the RedStone EXECUTOR_V6 signature. +func EncodeOperationData(auth operationAuth, legs []LiquidationLeg, authSig []byte) ([]byte, error) { + if len(legs) == 0 { + return nil, errors.New("operationData: no legs") + } + if auth.BidAmount == nil || auth.MinBundleProfit == nil || auth.MinBundleProfit.Sign() <= 0 || + auth.Deadline == nil || auth.Deadline.Sign() <= 0 { + return nil, errors.New("operationData: invalid auth") + } + if err := validateOperationLegs(legs); err != nil { + return nil, err + } + op := operationData{Auth: auth, Legs: encodeLegs(legs), AuthSig: authSig} + enc, err := operationDataArgs.Pack(op) + if err != nil { + return nil, errors.Errorf("encode operationData: %w", err) + } + return enc, nil +} + +func CallbackAuthDigest(chainID *big.Int, callback, executor common.Address, auth operationAuth, legs []LiquidationLeg) (common.Hash, error) { + if err := validateOperationLegs(legs); err != nil { + return common.Hash{}, err + } + legsHash, err := encodedLegsHash(legs) + if err != nil { + return common.Hash{}, err + } + enc, err := authDigestArgs.Pack( + authDomain, chainID, callback, executor, auth.AuctionKey, auth.BidAmount, auth.MinBundleProfit, + auth.Deadline, legsHash, + ) + if err != nil { + return common.Hash{}, errors.Errorf("encode callback auth digest: %w", err) + } + return crypto.Keccak256Hash(enc), nil +} + +func validateOperationLegs(legs []LiquidationLeg) error { + for i, leg := range legs { + if leg.MaxSeizeAssets == nil || leg.MaxSeizeAssets.Sign() <= 0 { + return errors.Errorf("operationData: invalid leg %d maxSeizeAssets", i) + } + if leg.MinProfit == nil || leg.MinProfit.Sign() <= 0 { + return errors.Errorf("operationData: invalid leg %d minProfit", i) + } + } + return nil +} + +func encodedLegsHash(legs []LiquidationLeg) (common.Hash, error) { + enc, err := callbackLegArrayArgs.Pack(encodeLegs(legs)) + if err != nil { + return common.Hash{}, errors.Errorf("encode callback auth legs: %w", err) + } + return crypto.Keccak256Hash(enc), nil +} + +func encodeLegs(legs []LiquidationLeg) []callbackLeg { + out := make([]callbackLeg, len(legs)) + for i, leg := range legs { + out[i] = callbackLeg(leg) + } + return out +} + +func auctionKeyHash(a AuctionMessage) common.Hash { + return crypto.Keccak256Hash([]byte(a.dedupKey())) +} + +func legsWithProfitFloors(legs []LiquidationLeg, gas gasPrediction, gasPrice, rate *big.Int) []LiquidationLeg { + out := make([]LiquidationLeg, len(legs)) + copy(out, legs) + for i := range out { + route := gasRouteUnknown + if i < len(gas.Routes) { + route = gas.Routes[i] + } + units := gasUnitsForRoute(route) + out[i].MinProfit = nativeToLoan(gasCostNative(units, gasPrice), rate) + } + return out +} + +func mustOperationDataType() abi.Type { + t, err := abi.NewType("tuple", "", []abi.ArgumentMarshaling{ + {Name: "auth", Type: "tuple", Components: []abi.ArgumentMarshaling{ + {Name: "auctionKey", Type: "bytes32"}, + {Name: "bidAmount", Type: "uint256"}, + {Name: "minBundleProfit", Type: "uint256"}, + {Name: "deadline", Type: "uint256"}, + }}, + {Name: "legs", Type: "tuple[]", Components: callbackLegComponents()}, + {Name: "authSig", Type: "bytes"}, + }) + if err != nil { + panic("redstoneoev: build OperationData type: " + err.Error()) + } + return t +} + +func mustCallbackLegArrayType() abi.Type { + t, err := abi.NewType("tuple[]", "", callbackLegComponents()) + if err != nil { + panic("redstoneoev: build LiquidationLeg[] type: " + err.Error()) + } + return t +} + +func callbackLegComponents() []abi.ArgumentMarshaling { + return []abi.ArgumentMarshaling{ + {Name: "marketId", Type: "bytes32"}, + {Name: "borrower", Type: "address"}, + {Name: "maxSeizeAssets", Type: "uint256"}, + {Name: "minProfit", Type: "uint256"}, + } +} diff --git a/internal/solvers/redstoneoev/operationdata_decode_test.go b/internal/solvers/redstoneoev/operationdata_decode_test.go new file mode 100644 index 00000000..0936fdfc --- /dev/null +++ b/internal/solvers/redstoneoev/operationdata_decode_test.go @@ -0,0 +1,156 @@ +package redstoneoev + +import ( + "math/big" + "reflect" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" +) + +func decodeOperationData(data []byte) (operationData, error) { + vals, err := operationDataArgs.Unpack(data) + if err != nil { + return operationData{}, errors.Errorf("decode operationData: %w", err) + } + if len(vals) != 1 { + return operationData{}, errors.Errorf("decode operationData: got %d values, want 1", len(vals)) + } + if out, ok := vals[0].(operationData); ok { + return out, nil + } + return decodeOperationDataValue(reflect.ValueOf(vals[0])) +} + +func decodeOperationDataValue(v reflect.Value) (operationData, error) { + if v.Kind() == reflect.Pointer { + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return operationData{}, errors.Errorf("decode operationData: got %s, want struct", v.Kind()) + } + auth, err := decodeOperationAuthValue(v.FieldByName("Auth")) + if err != nil { + return operationData{}, err + } + legs, err := decodeOperationLegsValue(v.FieldByName("Legs")) + if err != nil { + return operationData{}, err + } + sigV := v.FieldByName("AuthSig") + sig, ok := sigV.Interface().([]byte) + if !ok { + return operationData{}, errors.Errorf("decode operationData: authSig has type %s", sigV.Type()) + } + return operationData{Auth: auth, Legs: legs, AuthSig: append([]byte(nil), sig...)}, nil +} + +func decodeOperationAuthValue(v reflect.Value) (operationAuth, error) { + if v.Kind() == reflect.Pointer { + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return operationAuth{}, errors.Errorf("decode operationData auth: got %s, want struct", v.Kind()) + } + key, ok := hashValue(v.FieldByName("AuctionKey")) + if !ok { + return operationAuth{}, errors.New("decode operationData auth: bad auctionKey") + } + bid, ok := bigValue(v.FieldByName("BidAmount")) + if !ok { + return operationAuth{}, errors.New("decode operationData auth: bad bidAmount") + } + minBundleProfit, ok := bigValue(v.FieldByName("MinBundleProfit")) + if !ok { + return operationAuth{}, errors.New("decode operationData auth: bad minBundleProfit") + } + deadline, ok := bigValue(v.FieldByName("Deadline")) + if !ok { + return operationAuth{}, errors.New("decode operationData auth: bad deadline") + } + return operationAuth{AuctionKey: key, BidAmount: bid, MinBundleProfit: minBundleProfit, Deadline: deadline}, nil +} + +func decodeOperationLegsValue(v reflect.Value) ([]callbackLeg, error) { + if v.Kind() != reflect.Slice { + return nil, errors.Errorf("decode operationData legs: got %s, want slice", v.Kind()) + } + out := make([]callbackLeg, v.Len()) + for i := 0; i < v.Len(); i++ { + legV := v.Index(i) + if legV.Kind() == reflect.Pointer { + legV = legV.Elem() + } + if legV.Kind() != reflect.Struct { + return nil, errors.Errorf("decode operationData leg %d: got %s, want struct", i, legV.Kind()) + } + id, ok := hashValue(legV.FieldByName("MarketId")) + if !ok { + return nil, errors.Errorf("decode operationData leg %d: bad marketId", i) + } + borrower, ok := addressValue(legV.FieldByName("Borrower")) + if !ok { + return nil, errors.Errorf("decode operationData leg %d: bad borrower", i) + } + maxSeize, ok := bigValue(legV.FieldByName("MaxSeizeAssets")) + if !ok { + return nil, errors.Errorf("decode operationData leg %d: bad maxSeizeAssets", i) + } + minProfit, ok := bigValue(legV.FieldByName("MinProfit")) + if !ok { + return nil, errors.Errorf("decode operationData leg %d: bad minProfit", i) + } + out[i] = callbackLeg{ + MarketId: id, + Borrower: borrower, + MaxSeizeAssets: maxSeize, + MinProfit: minProfit, + } + } + return out, nil +} + +func hashValue(v reflect.Value) (common.Hash, bool) { + if !v.IsValid() { + return common.Hash{}, false + } + if h, ok := v.Interface().(common.Hash); ok { + return h, true + } + if v.Kind() != reflect.Array || v.Len() != common.HashLength { + return common.Hash{}, false + } + var h common.Hash + for i := 0; i < common.HashLength; i++ { + h[i] = byte(v.Index(i).Uint()) + } + return h, true +} + +func addressValue(v reflect.Value) (common.Address, bool) { + if !v.IsValid() { + return common.Address{}, false + } + if a, ok := v.Interface().(common.Address); ok { + return a, true + } + if v.Kind() != reflect.Array || v.Len() != common.AddressLength { + return common.Address{}, false + } + var a common.Address + for i := 0; i < common.AddressLength; i++ { + a[i] = byte(v.Index(i).Uint()) + } + return a, true +} + +func bigValue(v reflect.Value) (*big.Int, bool) { + if !v.IsValid() { + return nil, false + } + b, ok := v.Interface().(*big.Int) + if !ok || b == nil { + return nil, false + } + return new(big.Int).Set(b), true +} diff --git a/internal/solvers/redstoneoev/operationdata_test.go b/internal/solvers/redstoneoev/operationdata_test.go new file mode 100644 index 00000000..22840af2 --- /dev/null +++ b/internal/solvers/redstoneoev/operationdata_test.go @@ -0,0 +1,184 @@ +package redstoneoev + +import ( + "bytes" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" +) + +func TestEncodeOperationDataRoundTrip(t *testing.T) { + auth := operationAuth{ + AuctionKey: common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111"), + BidAmount: mustBig("500000000000000"), + MinBundleProfit: mustBig("2200000"), + Deadline: mustBig("1781243700"), + } + legs := []LiquidationLeg{{ + MarketId: common.HexToHash("0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5"), + Borrower: common.HexToAddress("0x629d764eC8563AFA701709B52c1a215e865632dE"), + MaxSeizeAssets: mustBig("500000000000000000"), + MinProfit: mustBig("625000"), + }} + authSig := bytes.Repeat([]byte{0x42}, 65) + + got, err := EncodeOperationData(auth, legs, authSig) + if err != nil { + t.Fatal(err) + } + want := "0x" + + "0000000000000000000000000000000000000000000000000000000000000020" + + "1111111111111111111111111111111111111111111111111111111111111111" + + "0000000000000000000000000000000000000000000000000001c6bf52634000" + + "00000000000000000000000000000000000000000000000000000000002191c0" + + "000000000000000000000000000000000000000000000000000000006a2b9f34" + + "00000000000000000000000000000000000000000000000000000000000000c0" + + "0000000000000000000000000000000000000000000000000000000000000160" + + "0000000000000000000000000000000000000000000000000000000000000001" + + "6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5" + + "000000000000000000000000629d764ec8563afa701709b52c1a215e865632de" + + "00000000000000000000000000000000000000000000000006f05b59d3b20000" + + "0000000000000000000000000000000000000000000000000000000000098968" + + "0000000000000000000000000000000000000000000000000000000000000041" + + "4242424242424242424242424242424242424242424242424242424242424242" + + "4242424242424242424242424242424242424242424242424242424242424242" + + "4200000000000000000000000000000000000000000000000000000000000000" + if hexutil.Encode(got) != want { + t.Fatalf("operationData ABI mismatch:\n got %s\nwant %s", hexutil.Encode(got), want) + } + back, err := decodeOperationData(got) + if err != nil { + t.Fatalf("decode operationData: %v", err) + } + if back.Auth.AuctionKey != auth.AuctionKey || + back.Auth.BidAmount.Cmp(auth.BidAmount) != 0 || + back.Auth.MinBundleProfit.Cmp(auth.MinBundleProfit) != 0 || + back.Auth.Deadline.Cmp(auth.Deadline) != 0 { + t.Fatalf("auth round-trip mismatch: %+v", back.Auth) + } + if len(back.Legs) != 1 { + t.Fatalf("legs len = %d, want 1", len(back.Legs)) + } + if leg := back.Legs[0]; leg.MarketId != legs[0].MarketId || + leg.Borrower != legs[0].Borrower || + leg.MaxSeizeAssets.Cmp(legs[0].MaxSeizeAssets) != 0 || + leg.MinProfit.Cmp(legs[0].MinProfit) != 0 { + t.Fatalf("leg round-trip mismatch: %+v", leg) + } + if !bytes.Equal(back.AuthSig, authSig) { + t.Fatalf("authSig mismatch") + } +} + +func TestEncodeOperationDataRejectsMissingAuth(t *testing.T) { + leg := LiquidationLeg{Borrower: common.Address{19: 1}, MaxSeizeAssets: big.NewInt(1), MinProfit: big.NewInt(1)} + for name, auth := range map[string]operationAuth{ + "no bid": {MinBundleProfit: big.NewInt(1), Deadline: big.NewInt(1)}, + "no min bundle profit": {BidAmount: big.NewInt(1), Deadline: big.NewInt(1)}, + "no deadline": {BidAmount: big.NewInt(1), MinBundleProfit: big.NewInt(1)}, + "zero min bundle profit": { + BidAmount: big.NewInt(1), + MinBundleProfit: big.NewInt(0), + Deadline: big.NewInt(1), + }, + "zero deadline": { + BidAmount: big.NewInt(1), + MinBundleProfit: big.NewInt(1), + Deadline: big.NewInt(0), + }, + } { + t.Run(name, func(t *testing.T) { + if _, err := EncodeOperationData(auth, []LiquidationLeg{leg}, nil); err == nil { + t.Fatal("expected invalid auth error") + } + }) + } + if _, err := EncodeOperationData(operationAuth{ + BidAmount: big.NewInt(1), MinBundleProfit: big.NewInt(1), Deadline: big.NewInt(1), + }, nil, nil); err == nil { + t.Fatal("expected error for empty legs") + } +} + +func TestEncodeOperationDataRejectsInvalidLegs(t *testing.T) { + auth := operationAuth{BidAmount: big.NewInt(1), MinBundleProfit: big.NewInt(1), Deadline: big.NewInt(1)} + valid := LiquidationLeg{ + Borrower: common.Address{19: 1}, + MaxSeizeAssets: big.NewInt(1), + MinProfit: big.NewInt(1), + } + for name, mutate := range map[string]func(*LiquidationLeg){ + "nil maxSeizeAssets": func(l *LiquidationLeg) { l.MaxSeizeAssets = nil }, + "zero maxSeizeAssets": func(l *LiquidationLeg) { l.MaxSeizeAssets = big.NewInt(0) }, + "nil minProfit": func(l *LiquidationLeg) { l.MinProfit = nil }, + "zero minProfit": func(l *LiquidationLeg) { l.MinProfit = big.NewInt(0) }, + "negative minProfit": func(l *LiquidationLeg) { l.MinProfit = big.NewInt(-1) }, + } { + t.Run(name, func(t *testing.T) { + leg := valid + mutate(&leg) + if _, err := EncodeOperationData(auth, []LiquidationLeg{leg}, nil); err == nil { + t.Fatal("expected invalid leg error") + } + }) + } + if _, err := EncodeOperationData(auth, []LiquidationLeg{valid}, nil); err != nil { + t.Fatalf("valid leg must encode: %v", err) + } +} + +func TestCallbackAuthDigestBindsLegs(t *testing.T) { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatal(err) + } + auth := operationAuth{ + AuctionKey: common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + BidAmount: big.NewInt(100), + MinBundleProfit: big.NewInt(200), + Deadline: big.NewInt(300), + } + legs := []LiquidationLeg{{ + MarketId: common.Hash{31: 1}, + Borrower: common.Address{19: 2}, + MaxSeizeAssets: big.NewInt(3), + MinProfit: big.NewInt(4), + }} + digest, err := CallbackAuthDigest(big.NewInt(11155111), common.Address{19: 3}, common.Address{19: 4}, auth, legs) + if err != nil { + t.Fatal(err) + } + sig, err := crypto.Sign(digest.Bytes(), key) + if err != nil { + t.Fatal(err) + } + pub, err := crypto.SigToPub(digest.Bytes(), sig) + if err != nil { + t.Fatal(err) + } + if got, want := crypto.PubkeyToAddress(*pub), crypto.PubkeyToAddress(key.PublicKey); got != want { + t.Fatalf("recovered %s, want %s", got, want) + } + + changed := legs + changed[0].MinProfit.Add(changed[0].MinProfit, big.NewInt(1)) + changedDigest, err := CallbackAuthDigest(big.NewInt(11155111), common.Address{19: 3}, common.Address{19: 4}, auth, changed) + if err != nil { + t.Fatal(err) + } + if changedDigest == digest { + t.Fatal("digest must change when leg minProfit changes") + } + changedAuth := auth + changedAuth.Deadline = big.NewInt(301) + changedDigest, err = CallbackAuthDigest(big.NewInt(11155111), common.Address{19: 3}, common.Address{19: 4}, changedAuth, legs) + if err != nil { + t.Fatal(err) + } + if changedDigest == digest { + t.Fatal("digest must change when auth deadline changes") + } +} diff --git a/internal/solvers/redstoneoev/rate.go b/internal/solvers/redstoneoev/rate.go new file mode 100644 index 00000000..6142507b --- /dev/null +++ b/internal/solvers/redstoneoev/rate.go @@ -0,0 +1,56 @@ +package redstoneoev + +// rate.go holds loan↔ETH rate resolution and loan/native conversions for profitability gates and bid sizing. + +import ( + "math/big" + + "github.com/symbioticfi/vault-solver/internal/chain" + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +// composeLoanPerEth derives loanPerEth (loan base units per 1 ETH) from two Chainlink-style oracle +// answers — ethUsd (ETH/USD, ethFeedDec decimals) and loanUsd (loan/USD, loanFeedDec decimals) — scaled +// to the loan token's own decimals: +// +// loanPerEth = ethUsd × 10^(loanDec + loanFeedDec) / (loanUsd × 10^ethFeedDec) +// +// e.g. ETH=$2500, USDC=$1, both feeds 8-dec, loanDec=6 → 2500e8 × 1e6 × 1e8 / (1e8 × 1e8) = 2500e6. +// Returns nil on any non-positive input so callers fail closed. +func composeLoanPerEth(ethUsd, loanUsd *big.Int, ethFeedDec, loanFeedDec, loanDec int) *big.Int { + if ethUsd == nil || loanUsd == nil || ethUsd.Sign() <= 0 || loanUsd.Sign() <= 0 { + return nil + } + num := new(big.Int).Mul(ethUsd, chain.Exp10(loanDec+loanFeedDec)) + den := new(big.Int).Mul(loanUsd, chain.Exp10(ethFeedDec)) + rate := new(big.Int).Quo(num, den) + if rate.Sign() <= 0 { + return nil + } + return rate +} + +func validRate(rate *big.Int) *big.Int { + if rate != nil && rate.Sign() > 0 { + return rate + } + return nil +} + +// loanToNative converts loan-token base units to native token base units at the loanPerEth rate, rounding +// down. It returns 0 when no positive rate is available. +func loanToNative(loan, rate *big.Int) *big.Int { + if rate == nil || rate.Sign() <= 0 || loan == nil { + return new(big.Int) + } + return morpho.MulDivDown(loan, morpho.Wad, rate) +} + +// nativeToLoan converts native token base units to loan-token base units at loanPerEth, rounding up so +// cost floors stay conservative. +func nativeToLoan(native, rate *big.Int) *big.Int { + if rate == nil || rate.Sign() <= 0 || native == nil { + return new(big.Int) + } + return morpho.MulDivUp(native, rate, morpho.Wad) +} diff --git a/internal/solvers/redstoneoev/reservations.go b/internal/solvers/redstoneoev/reservations.go new file mode 100644 index 00000000..6d4cee49 --- /dev/null +++ b/internal/solvers/redstoneoev/reservations.go @@ -0,0 +1,149 @@ +package redstoneoev + +// reservations.go holds the in-flight-bid headroom reservation subsystem and the auction-id dedup ring. + +import ( + "math/big" + "slices" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +// positionKey identifies a borrower position (one Morpho market + borrower) — the unit a bid liquidates. +type positionKey struct { + market common.Hash + borrower common.Address +} + +// reservedBid is one sent-but-not-yet-resolved bid's commitment against cached headroom: payBid native, +// predicted Executor-deposit gas debit, signed nonce, send time, and the positions it liquidates. +type reservedBid struct { + bidNative *big.Int + gasNative *big.Int + nonce uint64 + at time.Time + positions []positionKey + auctionID string + auctionKey common.Hash + gasUnits uint64 + gasRoutes string +} + +// reservationTTL is only a fallback for missed auction/liquidation result frames. Normal release is +// event-driven: a lost auction-result or our liquidation-result frees the bid immediately, while a won bid +// without a result stays pinned long enough for delayed settlement/nonce reconciliation. +const reservationTTL = 5 * time.Minute + +type inFlightState struct { + positions map[positionKey]bool + bidNative *big.Int + gasNative *big.Int +} + +// inFlightSnapshot returns, in ONE pass under resMu, everything buildBid needs about sent-but-unresolved +// bids: the (market,borrower) set, the reserved callback payBid native, and the reserved Executor-deposit +// gas debit. +func (s *Solver) inFlightSnapshot() inFlightState { + s.resMu.Lock() + defer s.resMu.Unlock() + out := inFlightState{bidNative: new(big.Int), gasNative: new(big.Int)} + if len(s.res) > 0 { + out.positions = make(map[positionKey]bool, len(s.res)) + } + for _, r := range s.res { + out.bidNative.Add(out.bidNative, orZero(r.bidNative)) + out.gasNative.Add(out.gasNative, orZero(r.gasNative)) + for _, p := range r.positions { + out.positions[p] = true + } + } + return out +} + +// reserve records the headroom a just-sent bid commits: bid native, predicted gas debit from +// the Executor deposit, and the positions it liquidates. +func (s *Solver) reserve(bidNative, gasNative *big.Int, nonce uint64, now time.Time, positions []positionKey, auctionID string, auctionKey common.Hash, gas gasPrediction) { + s.resMu.Lock() + defer s.resMu.Unlock() + s.res = append(s.res, reservedBid{ + bidNative: orZero(bidNative), + gasNative: orZero(gasNative), + nonce: nonce, + at: now, + positions: positions, + auctionID: auctionID, + auctionKey: auctionKey, + gasUnits: gas.Units, + gasRoutes: gasRoutesString(gas.Routes), + }) +} + +func (s *Solver) reservationByAuction(id string) (reservedBid, bool) { + if id == "" { + return reservedBid{}, false + } + s.resMu.Lock() + defer s.resMu.Unlock() + for _, r := range s.res { + if r.auctionID == id { + return r, true + } + } + return reservedBid{}, false +} + +func (s *Solver) releaseReservationByAuction(id string) { + if id == "" { + return + } + s.resMu.Lock() + defer s.resMu.Unlock() + s.res = slices.DeleteFunc(s.res, func(r reservedBid) bool { return r.auctionID == id }) +} + +// pruneReservations frees a reservation once its bid resolves: when nonce <= the on-chain nonce (the bid +// won and settled — a pending bid is signed with nonce = on-chain + 1, so settlement sets the on-chain nonce +// to exactly the consumed bid's, and `<=` releases precisely then), or once it has aged past reservationTTL. +// Still-pending bids stay pinned. +func (s *Solver) pruneReservations(onChainNonce uint64, now time.Time) { + s.resMu.Lock() + defer s.resMu.Unlock() + s.res = slices.DeleteFunc(s.res, func(r reservedBid) bool { + return r.resolved(onChainNonce, now) + }) +} + +func (r reservedBid) resolved(onChainNonce uint64, now time.Time) bool { + return r.nonce <= onChainNonce || now.Sub(r.at) > reservationTTL +} + +// maxSeenAuctions bounds the de-dup set (insertion-ordered eviction); ample for the auction cadence. +const maxSeenAuctions = 1024 + +// seenAuctions is a bounded, insertion-ordered de-dup set for auction ids: a re-subscribe on reconnect can +// replay a frame, and bidding twice for one auction burns a second nonce + reserves a second headroom. +// Touched only by the single WS read goroutine (handleMessage), so it needs no lock. +type seenAuctions struct { + set map[string]struct{} + order []string + cap int +} + +func newSeenAuctions(capacity int) *seenAuctions { + return &seenAuctions{set: make(map[string]struct{}, capacity), cap: capacity} +} + +// seen reports whether id was already processed; if not, it records it (evicting the oldest past cap). +func (s *seenAuctions) seen(id string) bool { + if _, ok := s.set[id]; ok { + return true + } + if len(s.order) >= s.cap { + delete(s.set, s.order[0]) + s.order = s.order[1:] + } + s.set[id] = struct{}{} + s.order = append(s.order, id) + return false +} diff --git a/internal/solvers/redstoneoev/sizing.go b/internal/solvers/redstoneoev/sizing.go new file mode 100644 index 00000000..dd02a97e --- /dev/null +++ b/internal/solvers/redstoneoev/sizing.go @@ -0,0 +1,174 @@ +package redstoneoev + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +// Candidate is a position to evaluate. The Morpho worker (our independently-tracked at-risk set) is the +// SOLE position source — the auction frame supplies prices only (docs/OEV-PLAN.md §3.1). sizeLeg (below) +// is the single shared decision/sizing path. +type Candidate struct { + MarketID common.Hash + Borrower common.Address + Market MarketInfo + Position morpho.PositionState +} + +// AdapterQuote is the LiquidLane adapter's redemption terms for a market's collateral: the discounted +// sell rate (getMaxRate = the adapter's oracle price × (1 − curator minDiscount), 1e18-scaled) and +// the token decimals needed to convert it to a loan-token amount. This is the price at which we +// actually offload the seized RWA into the vault — distinct from the Morpho market price that drives +// the liquidation itself. +type AdapterQuote struct { + MaxRate *big.Int // 1e18-scaled, minDiscount already applied + MaxAssets *big.Int // getMaxAssets: cap on swap output (loan units) before the adapter reverts + + // LoanScale/CollScale are 10^loanDec / 10^collDec (the vault asset / collateral token decimals), + // precomputed once when the quote is built (always — see buildQuote and the tests' newQuote) so the + // per-leg hot path reads them directly instead of recomputing big.Int.Exp. Invariant: both are + // non-nil on every AdapterQuote. + LoanScale *big.Int + CollScale *big.Int +} + +// partialSeizeFractionBps is the fixed fallback when full-collateral liquidations are disabled. +const partialSeizeFractionBps = 9000 + +// SizingParams controls liquidation sizing (from config). When full liquidation is allowed, a leg targets +// all borrower collateral; otherwise it targets a fixed partial seize. The target is still clamped down by +// the borrower's debt and the adapter's getMaxAssets redemption liquidity. Profit is linear in seize, so +// the full mode captures bad-debt opportunities instead of deliberately leaving the last collateral slice. +type SizingParams struct { + AllowFullLiquidation bool // true => target all collateral; false => fixed partialSeizeFractionBps + SwapHaircutBps int // EXTRA safety margin on the adapter's already-discounted output (slippage/staleness) +} + +type sizedLeg struct { + leg LiquidationLeg + expectedLoanOut *big.Int + profit *big.Int +} + +// expectedLoanOutFor estimates the loan-token output for selling `collIn` of seized collateral through +// quote q at the adapter's discounted rate minus the extra safety haircut: +// collIn × maxRate × 10^loanDec / (1e18 × 10^collDec), then × (1 − haircut). The RFQ solver replicates this +// same adapter formula in rfq/strategy.go amountOutForRate — keep both in sync (not unified: a shared helper +// would take several same-type big.Int args, a swap-footgun for fund pricing). +func expectedLoanOutFor(collIn *big.Int, q AdapterQuote, haircutBps int) *big.Int { + adapterOut := morpho.MulDivDown(new(big.Int).Mul(collIn, q.MaxRate), q.LoanScale, new(big.Int).Mul(morpho.Wad, q.CollScale)) + out := morpho.MulDivDown(adapterOut, big.NewInt(int64(10_000-haircutBps)), big.NewInt(10_000)) + // The adapter recomputes its rate ceiling with a different nested rounding (floor getAmountOut, THEN + // apply the curator discount) than our getMaxRate-derived value. Shave one unit so our estimate stays + // below the on-chain ceiling; negligible vs leg profit, and only binds at a near-zero haircut. + if out.Sign() > 0 { + out.Sub(out, big.NewInt(1)) + } + return out +} + +// collForBudget is the inverse of expectedLoanOutFor: the most collateral whose expected loan output stays +// within `budget` (loan-token / getMaxAssets units), so the selected leg does not rely on more redemption +// liquidity than the cached adapter state says exists. The callback still reads the live cap at settlement. +// Returns 0 when the quote can't price an exit. SwapHaircutBps is config-validated to [0, 10000), so +// 10000−haircut > 0. +func collForBudget(budget *big.Int, q AdapterQuote, haircutBps int) *big.Int { + h := int64(10_000 - haircutBps) + if h <= 0 || q.MaxRate == nil || q.MaxRate.Sign() <= 0 { + return new(big.Int) + } + num := new(big.Int).Mul(morpho.Wad, q.CollScale) + num.Mul(num, big.NewInt(10_000)) + den := new(big.Int).Mul(q.MaxRate, q.LoanScale) + den.Mul(den, big.NewInt(h)) + if den.Sign() == 0 { + return new(big.Int) + } + return morpho.MulDivDown(budget, num, den) +} + +// sizeLeg sizes ONE liquidation leg for candidate c, selling its WHOLE seizure through the single +// configured adapter (quote q) in one swap. It targets either all collateral or the fixed partial seize, +// CLAMPED by the borrower's full debt (so a small-debt / large-collateral position can't over-seize and +// revert the Morpho borrowShares underflow) AND by the adapter's getMaxAssets redemption liquidity (so the +// swap can't ask for more than the vault can allocate and revert InsufficientAllocate). +// +// Returns the callback leg, expected loan output, and gross loan profit (expectedLoanOut - repaid). ok=false +// when the position cannot liquidate profitably here. Bundle and gas economics are applied later by bundle +// selection and operationData. +func sizeLeg(c Candidate, price *big.Int, q AdapterQuote, accrued *big.Int, sp SizingParams) (sizedLeg, bool) { + m, p := c.Market.State, c.Position + if price == nil || price.Sign() <= 0 { + return sizedLeg{}, false + } + if q.MaxRate == nil || q.MaxRate.Sign() <= 0 { + return sizedLeg{}, false // can't price the exit + } + if !morpho.IsLiquidatableAt(p, price, m.Lltv, accrued, m.TotalBorrowShares) { + return sizedLeg{}, false + } + target := targetSeize(p.Collateral, sp.AllowFullLiquidation) + if target.Sign() <= 0 { + return sizedLeg{}, false + } + // LiquidationIncentiveFactor depends only on the market's lltv, so compute it ONCE here and feed it to + // both the full-debt clamp and the repayment quote (each recomputed it per leg before) — provably the + // same value. + lif := morpho.LiquidationIncentiveFactor(m.Lltv) + // Clamp the seize so the implied repayment never exceeds the borrower's debt. The leg sets MaxSeizeAssets + // with RepaidShares=0, so Morpho derives repaidShares from the seize and reverts (borrowShares underflow) + // once the implied repayment would exceed the outstanding debt — which happens whenever the target + // collateral is worth more debt than the borrower carries (small debt vs large collateral). maxSeize is + // the inverse forward-map at the full-debt point (rounded down), so a full liquidation clamps here and + // can't round up past the debt. maxSeize can floor to 0 for a dust position (debt worth < ~1 collateral + // unit); clamping target to 0 then returns ok=false below, so we skip it rather than submit a + // guaranteed-revert over-seize (do NOT guard on maxSeize > 0). + if maxSeize := morpho.MaxSeizeForFullDebt(p.BorrowShares, price, lif, accrued, m.TotalBorrowShares); target.Cmp(maxSeize) > 0 { + target = maxSeize + } + // Clamp the seize by cached adapter redemption liquidity. This is a bidding-time safety check; the + // callback reads the current getMaxAssets again before swapping. nil/0 ⇒ uncapped (unknown liquidity). + if q.MaxAssets != nil && q.MaxAssets.Sign() > 0 { + if fit := collForBudget(q.MaxAssets, q, sp.SwapHaircutBps); fit.Cmp(target) < 0 { + target = fit + } + } + if target.Sign() <= 0 { + return sizedLeg{}, false + } + expectedLoanOut := expectedLoanOutFor(target, q, sp.SwapHaircutBps) + if expectedLoanOut.Sign() <= 0 { + return sizedLeg{}, false + } + repaid := morpho.RepaidAssetsForSeizeAt(target, price, lif, accrued, m.TotalBorrowShares) + if expectedLoanOut.Cmp(repaid) <= 0 { + return sizedLeg{}, false // proceeds can't cover repayment after discount + haircut + } + profit := new(big.Int).Sub(expectedLoanOut, repaid) // > 0 here + leg := LiquidationLeg{ + MarketId: c.MarketID, + Borrower: c.Borrower, + MaxSeizeAssets: target, + } + return sizedLeg{leg: leg, expectedLoanOut: expectedLoanOut, profit: profit}, true +} + +func targetSeize(collateral *big.Int, allowFull bool) *big.Int { + if collateral == nil { + return new(big.Int) + } + if allowFull { + return new(big.Int).Set(collateral) + } + return morpho.MulDivDown(collateral, big.NewInt(partialSeizeFractionBps), big.NewInt(10_000)) +} + +func orZero(n *big.Int) *big.Int { + if n == nil { + return big.NewInt(0) + } + return new(big.Int).Set(n) +} diff --git a/internal/solvers/redstoneoev/sizing_test.go b/internal/solvers/redstoneoev/sizing_test.go new file mode 100644 index 00000000..6fa2118b --- /dev/null +++ b/internal/solvers/redstoneoev/sizing_test.go @@ -0,0 +1,302 @@ +package redstoneoev + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/chain" + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +// newQuote builds a USDC(6)/RWA(18) adapter quote with the hot-path scales precomputed, mirroring the +// production buildQuote invariant that LoanScale/CollScale are always non-nil. maxAssets nil ⇒ uncapped. +func newQuote(maxRate string, maxAssets *big.Int) AdapterQuote { + return AdapterQuote{ + MaxRate: mustBig(maxRate), MaxAssets: maxAssets, + LoanScale: chain.Exp10(6), CollScale: chain.Exp10(18), + } +} + +func TestTargetSeizeModes(t *testing.T) { + collateral := mustBig("1000000000000000000") + if got := targetSeize(collateral, true); got.Cmp(collateral) != 0 { + t.Fatalf("full target = %s, want %s", got, collateral) + } + wantPartial := mustBig("900000000000000000") + if got := targetSeize(collateral, false); got.Cmp(wantPartial) != 0 { + t.Fatalf("partial target = %s, want %s", got, wantPartial) + } +} + +// evalLeg sizes a position against the single configured adapter's quote. +func evalLeg(c Candidate, price *big.Int, q AdapterQuote, nowTs uint64, sp SizingParams) (LiquidationLeg, *big.Int, bool) { + accrued := morpho.AccruedTotalBorrowAssets(c.Market.State, nowTs) + sized, ok := sizeLeg(c, price, q, accrued, sp) + return sized.leg, sized.profit, ok +} + +// TestEvaluateLegTargetsFullCollateral proves the default sizing path captures full-collateral opportunities, +// including bad-debt-style cases, instead of leaving a configurable bps slice behind. +func TestEvaluateLegTargetsFullCollateral(t *testing.T) { + m := goldenMarket() + cand := Candidate{ + MarketID: common.HexToHash("0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5"), + Borrower: common.HexToAddress("0x629d764eC8563AFA701709B52c1a215e865632dE"), + Market: MarketInfo{State: m}, + Position: goldenBorrower(), + } + price := mustBig("1550000000000000000000000000") // $1550 market price + // Adapter sells the RWA at $1550 minus the curator's 1% minDiscount -> getMaxRate 1534.5e18. + q := newQuote("1534500000000000000000", nil) + sp := SizingParams{AllowFullLiquidation: true, SwapHaircutBps: 0} + + leg, profit, ok := evalLeg(cand, price, q, m.LastUpdate, sp) + if !ok { + t.Fatal("expected a profitable leg at $1550") + } + if leg.MaxSeizeAssets.String() != "1000000000000000000" { // 1 TCOL + t.Fatalf("seized = %s, want 1 TCOL", leg.MaxSeizeAssets) + } + // expectedLoanOut at the adapter rate: 1 TCOL × 1534.5 = 1534.5 TLOAN; profit ≈ 49.6 TLOAN. + expectedLoanOut := expectedLoanOutFor(leg.MaxSeizeAssets, q, sp.SwapHaircutBps) + if expectedLoanOut.Cmp(big.NewInt(1_533_000_000)) < 0 || expectedLoanOut.Cmp(big.NewInt(1_536_000_000)) > 0 { + t.Fatalf("expectedLoanOut = %s, want ~1534.5e6", expectedLoanOut) + } + if profit.Cmp(big.NewInt(45_000_000)) < 0 || profit.Cmp(big.NewInt(55_000_000)) > 0 { + t.Fatalf("profit = %s, want ~50e6", profit) + } +} + +func TestSizeLegAllowsFullBadDebtSeize(t *testing.T) { + lltv := mustBig("500000000000000000") // 0.5 + price := mustBig("1000000000000000000000000000") // 1000 loan per 1e18 collateral + totalBorrowAssets := mustBig("10000000000") // 10,000 loan tokens at 6 decimals + totalBorrowShares := new(big.Int).Set(totalBorrowAssets) // 1:1 shares↔assets + collateral := mustBig("1000000000000000000") // 1 collateral + debtShares := mustBig("1200000000") // 1,200 loan tokens: debt > full collateral value + state := morpho.MarketState{TotalBorrowAssets: totalBorrowAssets, TotalBorrowShares: totalBorrowShares, + Lltv: lltv, BorrowRatePerSec: big.NewInt(0), Fee: big.NewInt(0), LastUpdate: assignNowTs} + c := Candidate{ + MarketID: assignMarketID, Borrower: common.Address{19: 9}, + Market: MarketInfo{Params: abiMarketParams{LoanToken: tokenA}, State: state}, + Position: morpho.PositionState{BorrowShares: debtShares, Collateral: collateral}, + } + accrued := morpho.AccruedTotalBorrowAssets(state, assignNowTs) + lif := morpho.LiquidationIncentiveFactor(lltv) + if maxSeize := morpho.MaxSeizeForFullDebt(debtShares, price, lif, accrued, totalBorrowShares); maxSeize.Cmp(collateral) <= 0 { + t.Fatalf("fixture must be bad-debt-like: maxSeizeForFullDebt=%s <= collateral=%s", maxSeize, collateral) + } + q := newQuote("1200000000000000000000", nil) // exit at 1200 loan per collateral, above the full-collateral repayment. + full, ok := sizeLeg(c, price, q, accrued, SizingParams{AllowFullLiquidation: true, SwapHaircutBps: 0}) + if !ok { + t.Fatal("full bad-debt seize should be profitable") + } + if full.leg.MaxSeizeAssets.Cmp(collateral) != 0 { + t.Fatalf("full bad-debt seize = %s, want all collateral %s", full.leg.MaxSeizeAssets, collateral) + } + partial, ok := sizeLeg(c, price, q, accrued, SizingParams{AllowFullLiquidation: false, SwapHaircutBps: 0}) + if !ok { + t.Fatal("partial fallback should also size") + } + if partial.leg.MaxSeizeAssets.Cmp(mustBig("900000000000000000")) != 0 { + t.Fatalf("partial seize = %s, want fixed 90%%", partial.leg.MaxSeizeAssets) + } + if full.profit.Cmp(partial.profit) <= 0 { + t.Fatalf("full bad-debt seize should capture more total profit: full=%s partial=%s", full.profit, partial.profit) + } +} + +// TestEvaluateLegRejectsBadPrice covers the fail-closed guard against a zero/negative settlement price +// (a malformed auction frame) — without it, maxBorrow=0 flags every position liquidatable with phantom +// profit and the bot bids into a guaranteed revert. +func TestEvaluateLegRejectsBadPrice(t *testing.T) { + m := goldenMarket() + cand := Candidate{Market: MarketInfo{State: m}, Position: goldenBorrower()} + q := newQuote("1534500000000000000000", mustBig("100000000000")) + sp := SizingParams{AllowFullLiquidation: true} + for _, bad := range []*big.Int{big.NewInt(0), big.NewInt(-1), nil} { + if _, _, ok := evalLeg(cand, bad, q, m.LastUpdate, sp); ok { + t.Fatalf("price %v must be rejected", bad) + } + } +} + +func TestEvaluateLegSkipsHealthy(t *testing.T) { + m := goldenMarket() + cand := Candidate{Market: MarketInfo{State: m}, Position: goldenBorrower()} + q := newQuote("1534500000000000000000", nil) + // Healthy at the live $2000 price. + if _, _, ok := evalLeg(cand, mustBig("2000000000000000000000000000"), q, m.LastUpdate, + SizingParams{AllowFullLiquidation: true, SwapHaircutBps: 200}); ok { + t.Fatal("must not liquidate a healthy position") + } +} + +func TestEvaluateLegSkipsUnprofitableExit(t *testing.T) { + m := goldenMarket() + cand := Candidate{Market: MarketInfo{State: m}, Position: goldenBorrower()} + q := newQuote("1400000000000000000000", nil) + // The position is liquidatable at $1550, but the adapter exit proceeds do not cover repayment. + if _, _, ok := evalLeg(cand, mustBig("1550000000000000000000000000"), q, m.LastUpdate, + SizingParams{AllowFullLiquidation: true, SwapHaircutBps: 0}); ok { + t.Fatal("must skip when adapter output cannot cover repayment") + } +} + +const assignNowTs = 1781243340 + +var assignMarketID = common.HexToHash("0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5") + +// sizeFixture builds the sizing params + a candidate factory over the seeded golden market (loan token +// tokenA), so the sizing tests can size real legs at a given adapter quote. +func sizeFixture() (SizingParams, func(b byte) Candidate, *big.Int) { + sp := SizingParams{AllowFullLiquidation: true, SwapHaircutBps: 0} + info := MarketInfo{Params: abiMarketParams{LoanToken: tokenA}, State: goldenMarket()} + cand := func(b byte) Candidate { + var addr common.Address + addr[19] = b + return Candidate{MarketID: assignMarketID, Borrower: addr, Market: info, Position: goldenBorrower()} + } + return sp, cand, mustBig("1550000000000000000000000000") // price +} + +// TestSizeLegClampsToGetMaxAssets proves the single adapter's getMaxAssets liquidity clamp: when the +// adapter can't absorb the full target seize, the leg is re-sized down so its expected loan output stays +// within the cached getMaxAssets budget. +func TestSizeLegClampsToGetMaxAssets(t *testing.T) { + _, cand, price := sizeFixture() + sp := SizingParams{AllowFullLiquidation: false, SwapHaircutBps: 0} + c := cand(1) + accrued := morpho.AccruedTotalBorrowAssets(c.Market.State, assignNowTs) + + // Uncapped first, to learn the full expectedLoanOut. + full, ok := sizeLeg(c, price, newQuote("1780000000000000000000", nil), accrued, sp) + if !ok { + t.Fatal("uncapped leg should size") + } + + // Cap the adapter below the full expectedLoanOut so the clamp binds. + uncapped := full.expectedLoanOut + budget := new(big.Int).Div(uncapped, big.NewInt(2)) + capped, ok := sizeLeg(c, price, newQuote("1780000000000000000000", budget), accrued, sp) + if !ok { + t.Fatal("capped leg should still size (smaller)") + } + if capped.expectedLoanOut.Cmp(budget) > 0 { + t.Fatalf("leg over-draws the adapter: expectedLoanOut=%s > getMaxAssets=%s", capped.expectedLoanOut, budget) + } + if capped.expectedLoanOut.Cmp(uncapped) >= 0 { + t.Fatalf("a tight budget must trim below the uncapped expectedLoanOut: capped=%s uncapped=%s", capped.expectedLoanOut, uncapped) + } + if capped.profit.Cmp(full.profit) >= 0 { + t.Fatalf("clamped leg should net less profit: capped=%s full=%s", capped.profit, full.profit) + } +} + +// TestSizeLegReturnsExpectedLoanOut proves the strategy computes one adapter output per liquidatable +// candidate while keeping that output out of the signed callback leg. +func TestSizeLegReturnsExpectedLoanOut(t *testing.T) { + sp, cand, price := sizeFixture() + c := cand(1) + accrued := morpho.AccruedTotalBorrowAssets(c.Market.State, assignNowTs) + q := newQuote("1780000000000000000000", mustBig("1000000000000")) + sized, ok := sizeLeg(c, price, q, accrued, sp) + if !ok { + t.Fatal("position should liquidate") + } + if sized.expectedLoanOut == nil || sized.expectedLoanOut.Sign() <= 0 { + t.Fatalf("sizing must return a positive expectedLoanOut, got %v", sized.expectedLoanOut) + } + if sized.leg.MaxSeizeAssets.Sign() <= 0 { + t.Fatalf("leg should seize collateral, got maxSeizeAssets=%s", sized.leg.MaxSeizeAssets) + } +} + +// TestSizeLegClampsSeizeToDebt is the regression for review F2: a barely-liquidatable position with a +// small debt but large collateral. The leg sets MaxSeizeAssets with RepaidShares=0, so Morpho derives the +// repaid shares from the seize and reverts (borrowShares underflow) if the implied repayment exceeds the +// borrower's outstanding debt. Seizing the unclamped 90% target would over-repay; the fix clamps the seize so +// morpho.RepaidAssetsForSeizeAt(MaxSeizeAssets) ≤ the borrower's debt — a full liquidation that never reverts. +func TestSizeLegClampsSeizeToDebt(t *testing.T) { + lltv := mustBig("500000000000000000") // 0.5 — widens the over-repay window vs the golden 0.86 + price := mustBig("1000000000000000000000000000000000000") // 1e36 (collateral≈loan units) + totalBorrowAssets := mustBig("1000000000000000000000000") // 1:1 shares↔assets, no accrual + totalBorrowShares := new(big.Int).Set(totalBorrowAssets) + coll := mustBig("1000000000000000000") // 1e18 collateral + // Debt just above maxBorrow → liquidatable, but worth far less than 90% of the collateral's value. + debtShares := new(big.Int).Add(morpho.MaxBorrow(coll, price, lltv), big.NewInt(1_000_000)) + + state := morpho.MarketState{ + TotalBorrowAssets: totalBorrowAssets, TotalBorrowShares: totalBorrowShares, + Lltv: lltv, BorrowRatePerSec: big.NewInt(0), Fee: big.NewInt(0), LastUpdate: assignNowTs, + } + coll18 := common.HexToAddress("0x00000000000000000000000000000000000000c0") + c := Candidate{ + MarketID: assignMarketID, Borrower: common.Address{19: 1}, + Market: MarketInfo{Params: abiMarketParams{LoanToken: tokenA, CollateralToken: coll18}, State: state}, + Position: morpho.PositionState{BorrowShares: debtShares, Collateral: coll}, + } + accrued := morpho.AccruedTotalBorrowAssets(state, assignNowTs) + debt := morpho.BorrowedAssetsAt(c.Position, accrued, totalBorrowShares) + + // Sanity: the UNCLAMPED 90% target really would over-repay (so the clamp is exercised, not vacuous). + unclampedTarget := morpho.MulDivDown(coll, big.NewInt(9000), big.NewInt(10_000)) + if morpho.RepaidAssetsForSeizeAt(unclampedTarget, price, morpho.LiquidationIncentiveFactor(lltv), accrued, totalBorrowShares).Cmp(debt) <= 0 { + t.Fatal("test fixture is vacuous: the unclamped 90% seize does not over-repay") + } + + sp := SizingParams{AllowFullLiquidation: false, SwapHaircutBps: 0} + // MaxRate sized so the swap proceeds clear the repayment (profitable): expectedLoanOut = collIn·rate·1e6/(1e18·1e18). + q := newQuote("2000000000000000000000000000000", mustBig("100000000000000000000000000000000")) + sized, ok := sizeLeg(c, price, q, accrued, sp) + if !ok { + t.Fatal("a liquidatable position should size a leg") + } + repaid := morpho.RepaidAssetsForSeizeAt(sized.leg.MaxSeizeAssets, price, morpho.LiquidationIncentiveFactor(lltv), accrued, totalBorrowShares) + if repaid.Cmp(debt) > 0 { + t.Fatalf("seize over-repays: morpho.RepaidAssetsForSeizeAt(%s)=%s > borrowerDebt=%s → Morpho borrowShares underflow", + sized.leg.MaxSeizeAssets, repaid, debt) + } + if sized.leg.MaxSeizeAssets.Cmp(unclampedTarget) >= 0 { + t.Fatalf("seize was not clamped below the 90%% target: seized=%s target=%s", sized.leg.MaxSeizeAssets, unclampedTarget) + } +} + +// TestSizeLegSkipsDustPosition closes the F2 verify gap: maxSeizeForFullDebt = floor(debtAssets·lif·1e36/price) +// floors to 0 for a dust position (tiny debt under a high collateral price). The clamp must then drive target to 0 +// so the leg is SKIPPED (ok=false); the earlier guard that ignored a zero maxSeize left target unclamped and +// over-seized into a borrowShares underflow. Fixture: 2-wei collateral (target=1, past the early guard) at a 2e36 +// price with lltv 0.2 (keeps it liquidatable: MaxBorrow floors to 0) and a 1-share debt → maxSeize floors to 0. +func TestSizeLegSkipsDustPosition(t *testing.T) { + lltv := mustBig("200000000000000000") // 0.2 — keeps the high-priced dust position liquidatable + price := mustBig("2000000000000000000000000000000000000") // 2e36 + totalBorrowAssets := mustBig("1000000000000000000000000") // 1:1, no accrual + totalBorrowShares := new(big.Int).Set(totalBorrowAssets) + coll := big.NewInt(2) // target = morpho.MulDivDown(2, 9000, 10000) = 1 (>0, clears the early target guard) + debtShares := big.NewInt(1) // 1 share → 1 asset; > morpho.MaxBorrow(2, 2e36, 0.2) = 0 ⇒ liquidatable + + state := morpho.MarketState{ + TotalBorrowAssets: totalBorrowAssets, TotalBorrowShares: totalBorrowShares, + Lltv: lltv, BorrowRatePerSec: big.NewInt(0), Fee: big.NewInt(0), LastUpdate: assignNowTs, + } + coll18 := common.HexToAddress("0x00000000000000000000000000000000000000c0") + c := Candidate{ + MarketID: assignMarketID, Borrower: common.Address{19: 3}, + Market: MarketInfo{Params: abiMarketParams{LoanToken: tokenA, CollateralToken: coll18}, State: state}, + Position: morpho.PositionState{BorrowShares: debtShares, Collateral: coll}, + } + accrued := morpho.AccruedTotalBorrowAssets(state, assignNowTs) + if !morpho.IsLiquidatableAt(c.Position, price, lltv, accrued, totalBorrowShares) { + t.Fatal("fixture must be liquidatable so the clamp path is reached") + } + if morpho.MaxSeizeForFullDebt(debtShares, price, morpho.LiquidationIncentiveFactor(lltv), accrued, totalBorrowShares).Sign() != 0 { + t.Fatal("fixture is not a dust case: maxSeizeForFullDebt must floor to 0") + } + sp := SizingParams{AllowFullLiquidation: false, SwapHaircutBps: 0} + q := newQuote("2000000000000000000000000000000", mustBig("100000000000000000000000000000000")) + if _, ok := sizeLeg(c, price, q, accrued, sp); ok { + t.Fatal("a dust position whose full-debt seize floors to 0 must be skipped (ok=false), not over-seized") + } +} diff --git a/internal/solvers/redstoneoev/solver.go b/internal/solvers/redstoneoev/solver.go new file mode 100644 index 00000000..4b18c909 --- /dev/null +++ b/internal/solvers/redstoneoev/solver.go @@ -0,0 +1,779 @@ +// Package redstoneoev implements the RedStone Atom OEV liquidation solver: it subscribes to OEV +// auctions over WebSocket, computes liquidatable Morpho Blue positions over our own independently-tracked +// at-risk set (Morpho API, or Sepolia testMonitor seeds; the auction frame supplies prices only), signs EXECUTOR_V6 bids, +// and replies with solve payloads that settle through an on-chain IOperationCallback contract (the single +// LiquidLane adapter). It self-registers via init(). See docs/OEV-PLAN.md. +package redstoneoev + +import ( + "context" + "encoding/json" + "math/big" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/morpho" + "github.com/symbioticfi/vault-solver/internal/solver" +) + +// Name is the registry key that selects this solver from config. +const Name = "redstone-oev" + +// minDeposit is the Executor's MIN_DEPOSIT (0.00001 ETH) — below this, settlement reverts (§6.2). +var minDeposit = big.NewInt(1e13) + +// skipNoLegs is the bounded skip reason for "no liquidatable leg this auction" (a metric label). +const skipNoLegs = "no_legs" + +// skipGasUnprofitable is the bounded skip reason for bundles whose loan profit does not cover estimated +// settlement gas plus the configured min-profit margin. +const skipGasUnprofitable = "gas_unprofitable" + +// skipStaleEpoch is the bounded skip reason for missing or stale monitor snapshot epochs. +const skipStaleEpoch = "stale_epoch" + +// skipStaleState is the bounded skip reason for a background cache older than intervals.maxStateAgeMs — +// a loop that stopped storing (stuck upstream, wedged RPC) must not keep bidding on its last state forever. +const skipStaleState = "stale_state" + +const ( + skipDepositLow = "deposit_low" + skipCallbackBalance = "callback_balance" + skipEmptyAuctionID = "empty_auction_id" +) + +//nolint:gochecknoinits // self-registration with the solver framework is the intended plugin pattern. +func init() { + solver.Register(Name, factory) +} + +// Solver is the RedStone OEV bidding strategy. +type Solver struct { + cfg *Config + deps solver.Deps + chainID *big.Int + dryRun bool // OEV_DRY_RUN: observe mode — sign + log would-bids, never send (env knob, not config) + reader *reader + mon monitorSource // Morpho market/position monitor — the single OEV opportunity source + nonces *nonceStore + breaker *breaker + metrics *metrics + ws *wsClient + seen *seenAuctions // de-dup of already-processed auction ids (WS-goroutine-only) + log logr.Logger + + state stateCache // cached executor accounting + callback balance, refreshed by the ops loop + + // resMu guards res: the per-bid reservation of payBid native and predicted gas debit committed by bids + // already SENT but not yet reflected on-chain. buildBid debits these reservations from the cached callback + // balance and Executor deposit so bids inside one ops-poll window cannot over-commit either funding pot. + // pruneReservations frees a bid once it RESOLVES — its nonce fell below the on-chain nonce (submitted → + // settled or reverted; the fresh read reflects it) or it aged past reservationTTL as a last-resort cleanup + // for missed result frames — so fresh unresolved bids keep pinning headroom. + resMu sync.Mutex + res []reservedBid +} + +func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { + cfg, err := parseConfig(raw) + if err != nil { + return nil, err + } + apiKey := os.Getenv(cfg.APIKeyEnv) + if apiKey == "" { + return nil, errors.Errorf("%s: ws api key env %q is empty", Name, cfg.APIKeyEnv) + } + // Dev/test knobs come from env (read at point of use), never config: the on-chain price basis and the + // dry-run observe mode. Malformed values fail closed here. + onchainPrice, err := onchainPriceForTestEnv() + if err != nil { + return nil, errors.Errorf("%s: %w", Name, err) + } + testMonitor, err := testMonitorEnv() + if err != nil { + return nil, errors.Errorf("%s: %w", Name, err) + } + dryRun, err := dryRunEnv() + if err != nil { + return nil, errors.Errorf("%s: %w", Name, err) + } + chainID := deps.Chain.ChainID() + if !chainID.IsInt64() || chainID.Sign() <= 0 { + return nil, errors.Errorf("%s: chain id %s out of supported range", Name, chainID) + } + log := deps.Log.WithName(Name) + rdr := newReader(deps.Chain, log) + var mon monitorSource + if testMonitor { + mon, err = newTestMonitor(rdr, log, cfg) + if err != nil { + return nil, errors.Errorf("%s: %w", Name, err) + } + } else { + if cfg.MorphoAPIURL == "" { + return nil, errors.Errorf("%s: morphoApiUrl is required unless %s=true", Name, envTestMonitor) + } + mon = newAPIMonitor(rdr, log, cfg, chainID.Int64()) + } + if onchainPrice && !testMonitor { + return nil, errors.Errorf("%s: %s requires %s=true", Name, envOnchainPrice, envTestMonitor) + } + + var mx *metrics + if deps.Metrics != nil { + if mx, err = newMetrics(deps.Metrics.Registerer()); err != nil { + return nil, err + } + } + + s := &Solver{ + cfg: cfg, + deps: deps, + chainID: chainID, + dryRun: dryRun, + reader: rdr, + mon: mon, + nonces: &nonceStore{}, + breaker: newBreaker(cfg.BreakerMaxFailures, cfg.BreakerWindow), + metrics: mx, + seen: newSeenAuctions(maxSeenAuctions), + log: log, + } + topics := []string{"oev/liquidations", "oev/feeds", "oev/notify/" + strings.ToLower(cfg.Callback.Hex())} + s.ws = newWSClient(wsConfig{URL: cfg.WSURL, APIKey: apiKey, Topics: topics}, log, s.handleMessage) + return s, nil +} + +// Name identifies the solver. +func (s *Solver) Name() string { return Name } + +// Run warms the caches, starts the monitor + ops loops, and serves the WS auction stream until ctx +// is cancelled. +func (s *Solver) Run(ctx context.Context) error { + s.log.Info("starting", + "callback", s.cfg.Callback.Hex(), "executor", s.cfg.Executor.Hex(), + "adapter", s.cfg.Adapter.Hex(), "monitor", s.mon.name(), + "dryRun", s.dryRun, "signer", s.deps.Signer.Address().Hex()) + s.mon.refresh(ctx) // seed market quotes before state; the gas predictor derives its collateral set from them + s.refreshState(ctx) // seed nonce + deposit + callback balance before any bid + + // Start the background loops and join them on shutdown so no goroutine outlives Run (and races + // deps teardown). The monitor runs its own market/position refresh loops; the WS client blocks + // until ctx is cancelled; the ops loop keys off the same ctx. + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); s.mon.run(ctx) }() + go func() { defer wg.Done(); s.opsLoop(ctx) }() + err := s.ws.Run(ctx) + wg.Wait() + return err +} + +// opsLoop periodically refreshes the Executor accounting (nonce/deposit/locked) used for pre-bid +// checks and nonce reconciliation. +func (s *Solver) opsLoop(ctx context.Context) { + t := time.NewTicker(s.cfg.OpsPoll) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + s.refreshState(ctx) + } + } +} + +// refreshState reads the signer's Executor accounting and the callback's native balance into the cache, +// and reconciles the nonce high-water mark. Run by the ops loop so deposit/nonce/balance stay fresh. +// +// The Executor-state read is the load-bearing one: nonce reconciliation, reservation pruning, and the +// deposit-low alarm all derive from st alone. A transient BalanceAt failure must NOT skip that bookkeeping +// (it would leave reservations pinning headroom and the nonce high-water mark stale). So on a balance read +// failure we keep the previously-cached callback balance (don't overwrite with a bad value) but STILL run +// the executor-derived bookkeeping (applyExecutorState). +func (s *Solver) refreshState(ctx context.Context) { + head, herr := s.latestHeadState(ctx) + if herr != nil { + s.log.Error(herr, "read block for state refresh failed; keeping cache") + return + } + epoch := newReadEpoch(head.Number, time.Now()) + st, err := s.reader.ReadExecutorState(ctx, s.cfg.Executor, s.deps.Signer.Address()) + if err != nil { + s.log.Error(err, "read executor state failed; keeping cache") + return + } + // Callback native balance: on a read failure keep the last good cached value rather than overwriting it + // (an absent cache stays absent — pre-bid funding then fails closed on state_unknown). + bal, berr := s.deps.Chain.BalanceAt(ctx, s.cfg.Callback, nil) + if berr != nil { + s.log.Error(berr, "read callback balance failed; keeping last cached balance") + if prev, ok := s.state.load(); ok { + bal = prev.CallbackNative + } else { + bal = nil + } + } + // Publish a usable state only with a callback balance in hand. Storing a nil balance would make load() + // report ready, and the callback_balance gate's Sub(CallbackNative, …) would nil-deref and crash the WS + // goroutine. With no balance (read failed AND no prior cache) keep the cache absent so pre-bid funding + // fails closed on state_unknown — as the comment above intends. applyExecutorState still runs the + // balance-independent bookkeeping (reservations/nonce/deposit) regardless. + if bal != nil { + rate := s.reader.ReadLoanEthRate(ctx, s.cfg.Adapter, s.cfg.LoanEthFeed, epoch.At) + gasState, gerr := s.reader.ReadGasPredictorState(ctx, s.cfg.Adapter, quoteCollateralsFromSnapshot(s.mon.snapshot())) + if gerr != nil { + s.log.Error(gerr, "read gas predictor state failed; keeping last cached predictor state") + if prev, ok := s.state.load(); ok { + gasState = prev.Gas + } + } + if !s.epochStillCurrent(ctx, epoch, "state") { + return + } + s.state.store(cachedState{ + Exec: st, CallbackNative: bal, Rate: rate, Gas: gasState, + GasLimit: head.GasLimit, UpdatedAt: time.Now(), + }) + } + s.applyExecutorState(st, bal, epoch.At) +} + +type latestHeadState struct { + Number uint64 + GasLimit uint64 +} + +func (s *Solver) latestHeadState(ctx context.Context) (latestHeadState, error) { + header, err := s.deps.Chain.HeaderByNumber(ctx, nil) + if err == nil { + if header == nil || header.Number == nil || !header.Number.IsUint64() { + return latestHeadState{}, errors.New("latest header missing uint64 block number") + } + return latestHeadState{Number: header.Number.Uint64(), GasLimit: header.GasLimit}, nil + } + head, berr := s.deps.Chain.BlockNumber(ctx) + if berr != nil { + return latestHeadState{}, err + } + s.log.Error(err, "read latest header failed; using RedStone gas limit cap") + return latestHeadState{Number: head}, nil +} + +func (s *Solver) epochStillCurrent(ctx context.Context, epoch readEpoch, label string) bool { + head, err := s.deps.Chain.BlockNumber(ctx) + if err != nil { + s.log.Error(err, "read block after "+label+" refresh failed; keeping cache") + return false + } + if head != epoch.Block { + s.log.V(1).Info("refresh crossed block boundary; keeping cache", "phase", label, "startBlock", epoch.Block, "endBlock", head) + return false + } + return true +} + +// applyExecutorState runs the bookkeeping derived purely from the Executor state read (nonce + deposit): +// reservation pruning, nonce high-water reconciliation, the balance gauges, and the deposit-low alarm. +// Split out of refreshState so it runs whenever ReadExecutorState succeeds — independent of the BalanceAt +// outcome. `bal` (the callback native, possibly the last cached value or nil) drives only the balance gauge. +// No I/O → directly unit-testable. +func (s *Solver) applyExecutorState(st ExecutorState, bal *big.Int, now time.Time) { + s.pruneReservations(st.Nonce.Uint64(), now) // free bids the fresh read shows resolved (or aged out) + s.nonces.reconcile(st.Nonce.Uint64()) + if bal != nil { + s.metrics.balances(weiFloat(st.Deposit), weiFloat(bal)) + } + // Deposit-drain alert: the gas pool drains on every settlement (even reverts; gas is debited from the + // deposit post-settlement, §6.2). Below MIN_DEPOSIT settlement always reverts, so surface that floor + // breach loudly; per-bid predicted-gas headroom is checked in buildBid. + belowFloor := st.Deposit.Cmp(minDeposit) < 0 + s.metrics.depositBelowFloor(belowFloor) + if belowFloor { + s.log.Error(errors.New("executor deposit below MIN_DEPOSIT"), + "bidding will skip until refueled (scripts/oev/oev-balance.sh topup-deposit)", + "depositWei", st.Deposit, "minDepositWei", minDeposit) + } + s.log.V(1).Info("state", "nonce", st.Nonce, "depositWei", st.Deposit, "locked", st.Locked, "callbackWei", bal) +} + +// handleMessage dispatches an inbound WS frame by op. Unknown/garbled frames are logged and dropped +// (the auctioneer is lenient and silent on bad input — §6.7). +func (s *Solver) handleMessage(ctx context.Context, raw []byte) { + op, err := opName(raw) + if err != nil { + s.log.V(1).Error(err, "drop unparseable frame") + return + } + switch op { + case "auction": + if isFeedAuction(raw) { + s.log.V(1).Info("ignoring feed auction") + return + } + s.handleAuction(raw) + case "auction-result": + var r AuctionResult + if err := json.Unmarshal(raw, &r); err != nil { + s.log.V(1).Error(err, "drop malformed frame", "op", op) + } else { + won := strings.EqualFold(r.Data.Liquidator, s.cfg.Callback.Hex()) + if won { + s.metrics.won() + } else { + s.releaseReservationByAuction(r.ID) + } + s.log.Info("auction-result", "id", r.ID, "winner", r.Data.Liquidator, "bid", r.Data.Bid, "won", won) + } + case "liquidation-result": + var r LiquidationResult + if err := json.Unmarshal(raw, &r); err != nil { + s.log.V(1).Error(err, "drop malformed frame", "op", op) + } else { + // This is the breaker's failure feed. The frame is delivered on both the broadcast oev/liquidations + // and the callback-scoped oev/notify/ subscription, so a result may belong to another + // solver — gate on Data.Liquidator == our callback (same won-detection as auction-result) before + // recording a failure, so a revert storm trips the breaker but other solvers' reverts never do. + ours := strings.EqualFold(r.Data.Liquidator, s.cfg.Callback.Hex()) + pred, hasPred := s.reservationByAuction(r.ID) + s.log.Info("liquidation-result", "id", r.ID, "success", r.Data.Success, + "txHash", r.Data.TxHash, "error", r.Data.Error, "ours", ours, + "predictedGas", pred.gasUnits, "predictedRoute", pred.gasRoutes) + if ours { + if hasPred && r.Data.TxHash != "" { + go s.attributeSettlementGas(ctx, r.Data.TxHash, pred) + } + s.releaseReservationByAuction(r.ID) + if !r.Data.Success { + s.breaker.recordFailure(time.Now()) + s.metrics.failed() + } + } + } + case "blacklisted": + var b Blacklisted + _ = json.Unmarshal(raw, &b) + s.breaker.blacklist() // actually halt bidding, not just log + s.log.Error(errors.New("api key blacklisted"), "halting bidding", "msg", b.Data.Msg) + default: + s.log.V(1).Info("ignoring frame", "op", op) + } +} + +// bidDecision is the outcome of evaluating one auction: either a ready-to-send solve (skip == "") or +// a bounded skip reason (a metric label, never free-form/attacker-derived). gross is the bundle's Σ +// loan-token profit, carried for logging only. +type bidDecision struct { + solve SolveMessage + legs int + gross *big.Int + bidNative *big.Int + gasNative *big.Int + nonce uint64 // the bid's signed nonce, so handleAuction reserves headroom keyed on it + positions []positionKey // the bundle's (market,borrower) legs, reserved so they aren't re-bid in-flight + gas gasPrediction + skip string +} + +// handleAuction is the hot path: unmarshal, run the (testable, I/O-free) buildBid, emit metrics, and +// either send the solve or log the skip. The ~400ms auction budget is observed via the hotPath latency +// histogram. +func (s *Solver) handleAuction(raw []byte) { + start := time.Now() + var a AuctionMessage + if err := json.Unmarshal(raw, &a); err != nil { + s.log.V(1).Error(err, "drop malformed auction") + return + } + s.metrics.auction() + key := a.dedupKey() + if key == "" { + s.metrics.skip(skipEmptyAuctionID) + s.log.Info("auction with empty id received; dropping", "timestamp", a.Timestamp, "timeoutMs", a.TimeoutMs) + return + } + // Drop a duplicate delivery of the same auction (a reconnect re-subscribe can replay frames): bidding + // twice would burn a second nonce and reserve a second headroom for one auction. + if s.seen.seen(key) { + s.metrics.skip("duplicate") + s.log.V(1).Info("duplicate auction; already processed", "auction", a.ID) + return + } + d := s.buildBid(a, time.Now) + s.metrics.latency(time.Since(start)) + + if d.skip != "" { + s.metrics.skip(d.skip) + s.log.V(1).Info("no bid", "auction", a.ID, "reason", d.skip) + return + } + if s.dryRun { + s.metrics.bid() + s.log.Info("DRY-RUN would bid", "auction", a.ID, "legs", d.legs, "nonce", d.solve.Data.Nonce, + "bidEth", d.solve.Data.Bid, "grossProfit", d.gross, "predictedGas", d.gas.Units, + "predictedRoute", gasRoutesString(d.gas.Routes)) + return + } + // Don't send a bid that overran the auction's own deadline: the auctioneer rejects late solves, and a + // sent-but-rejected bid would still reserve funding until the next refresh. Measure against the auction's + // TRUE deadline — elapsed since the auctioneer EMITTED the frame (a.Timestamp) — so a late-DELIVERED frame + // (WS transit) is dropped, not just one we were slow to process. Matters most with a remote signer whose + // latency lands inside buildBid's SignBid. + if a.TimeoutMs > 0 && tooLate(a.Timestamp, a.TimeoutMs, start, time.Now()) { + s.metrics.skip("too_late") + s.log.Info("bid not sent: auction deadline (since emit) exceeded", + "auction", a.ID, "timeoutMs", a.TimeoutMs, "sinceEmitMs", sinceEmitMs(a.Timestamp, time.Now()), + "localElapsedMs", time.Since(start).Milliseconds()) + return + } + if !s.ws.Send(marshal(d.solve)) { + // The frame never left the process — don't count it as a bid or reserve its funding. + s.metrics.skip("send_dropped") + s.log.Info("bid NOT sent (ws buffer full)", "auction", a.ID, "nonce", d.solve.Data.Nonce) + return + } + s.reserve(d.bidNative, d.gasNative, d.nonce, time.Now(), d.positions, a.ID, auctionKeyHash(a), d.gas) + s.metrics.bid() + s.log.Info("bid sent", "auction", a.ID, "legs", d.legs, "nonce", d.solve.Data.Nonce, + "bidEth", d.solve.Data.Bid, "grossProfit", d.gross, "predictedGas", d.gas.Units, + "predictedRoute", gasRoutesString(d.gas.Routes)) +} + +func (s *Solver) attributeSettlementGas(ctx context.Context, txHash string, pred reservedBid) { + if !common.IsHexHash(txHash) { + s.log.Info("settlement gas attribution skipped: bad tx hash", "txHash", txHash) + return + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + receipt, err := s.deps.Chain.TransactionReceipt(ctx, common.HexToHash(txHash)) + if err != nil { + s.log.Error(err, "settlement gas attribution failed", "txHash", txHash) + return + } + s.metrics.settlementGas(pred.gasUnits, receipt.GasUsed) + s.log.Info("settlement gas", "auction", pred.auctionID, "txHash", txHash, + "predictedGas", pred.gasUnits, "actualGas", receipt.GasUsed, "predictedRoute", pred.gasRoutes) + logCallbackEvents(s.log, s.cfg.Callback, pred.auctionKey, receipt) +} + +// buildBid evaluates an auction end-to-end: pick candidates from the configured source, size +// liquidatable legs with the shared Morpho math, select the best after-cost bundle, run the O(1) pre-bid checks +// against cached state, and sign the EXECUTOR_V6 bid. It performs no I/O (reads only the in-memory +// snapshot/state caches and the local signer), so it is deterministic and unit-testable; nowFn is +// injected for breaker/accrual timing. A non-empty skip reason means no bid was built. +// staleStateGate fails closed when any background cache is older than cfg.MaxStateAge: the monitor +// snapshot (Morpho markets/positions + adapter/vault quotes) or the ops-loop state (Executor accounting, +// callback balance, loan/ETH rate, gas predictor). Each cache stamps updatedAt only on a successful +// store, so a loop that keeps failing (and keeps its prior data) trips this gate instead of bidding on +// arbitrarily old state. +func (s *Solver) staleStateGate(auctionID string, now time.Time) string { + kv := make([]any, 0, 6) + if snap := s.mon.snapshot(); snap == nil || now.Sub(snap.updatedAt) > s.cfg.MaxStateAge { + var at time.Time + if snap != nil { + at = snap.updatedAt + } + kv = append(kv, "monitorAge", cacheAge(at, now)) + } + if st, ok := s.state.load(); !ok || now.Sub(st.UpdatedAt) > s.cfg.MaxStateAge { + var at time.Time + if ok { + at = st.UpdatedAt + } + kv = append(kv, "opsAge", cacheAge(at, now)) + } + if len(kv) == 0 { + return "" + } + s.log.Error(errors.New("background state stale"), "bid skipped: cache exceeds intervals.maxStateAgeMs", + append(kv, "maxStateAge", s.cfg.MaxStateAge, "auction", auctionID)...) + return skipStaleState +} + +// cacheAge renders a cache timestamp's age for the stale-state log; a zero timestamp means the cache +// never had a successful store. +func cacheAge(at, now time.Time) string { + if at.IsZero() { + return "never" + } + return now.Sub(at).String() +} + +func (s *Solver) buildBid(a AuctionMessage, nowFn func() time.Time) bidDecision { + now := nowFn() + if tripped, _ := s.breaker.tripped(now); tripped { + return bidDecision{skip: "breaker"} + } + if skip := s.staleStateGate(a.ID, now); skip != "" { + return bidDecision{skip: skip} + } + // Snapshot in-flight bids ONCE: the committed (market,borrower) set filters the scored legs below, and + // the reserved native debits the callback funding gate further down — all under a single resMu acquisition. + inFlight := s.inFlightSnapshot() + // Size legs off the monitor's snapshot. A stale cache contributes nothing (fail closed): one bid covers + // the whole selected bundle. + var scored []scoredLeg + staleSkip := "" + if ok, skip := s.fresh(a); !ok { + staleSkip = skip + } else { + scored = s.scoredLegs(a, now) + } + scored, hadLegs := dropInFlightLegs(scored, inFlight.positions) + if len(scored) == 0 { + switch { + case staleSkip != "": + return bidDecision{skip: staleSkip} // the snapshot was stale (fail closed) + case hadLegs: + return bidDecision{skip: "in_flight"} // all candidates already committed by unresolved bids + default: + return bidDecision{skip: skipNoLegs} + } + } + // Pre-bid checks (all O(1), from cached state). + st, ok := s.state.load() + if !ok { + return bidDecision{skip: "state_unknown"} + } + rate := validRate(st.Rate) + if rate == nil { + s.log.Info("bid skipped: loan/ETH rate unavailable", + "auction", a.ID, "scoredLegs", len(scored), "feedCount", len(a.Payload.Prices)) + return bidDecision{skip: skipGasUnprofitable} + } + gasPrice := new(big.Int).Set(s.cfg.MaxTxGasPrice) + feedCount := len(a.Payload.Prices) + b, skip := s.selectNetBundle(scored, rate, st.Gas, gasPrice, st.GasLimit, feedCount) + if skip != "" { + if skip == skipGasUnprofitable && len(b.legs) > 0 { + s.logBundleEconomics(a.ID, "bid skipped: bundle is not profitable after gas and bid", + b, rate, st.Gas, gasPrice, st.GasLimit, feedCount, len(scored)) + } + return bidDecision{skip: skip, gross: b.grossLoan} + } + priced := s.priceBundle(b, rate, st.Gas, gasPrice, feedCount) + + if st.Exec.Locked { + return bidDecision{skip: "signer_locked"} + } + if fundingSkip := s.fundingSkip(a, st, priced, inFlight, gasPrice); fundingSkip != "" { + return bidDecision{skip: fundingSkip} + } + // Encode operationData only after the cheap gates pass — it's the bundle's ABI pack, needed solely as + // SignBid's input, so defer it past state.load / signer_locked / deposit_low / callback_balance. + auth := operationAuth{ + AuctionKey: auctionKeyHash(a), + BidAmount: priced.bidNative, + MinBundleProfit: priced.minBundleProfitLoan, + Deadline: callbackAuthDeadline(now, s.cfg.CallbackAuthTTL), + } + authDigest, err := CallbackAuthDigest(s.chainID, s.cfg.Callback, s.cfg.Executor, auth, priced.callbackLegs) + if err != nil { + s.log.Error(err, "encode callback auth digest failed", "auction", a.ID) + return bidDecision{skip: "encode_error"} + } + authSig, err := s.deps.Signer.SignHash(authDigest) + if err != nil { + s.log.Error(err, "sign callback auth failed", "auction", a.ID) + return bidDecision{skip: "sign_error"} + } + opData, err := EncodeOperationData(auth, priced.callbackLegs, authSig) + if err != nil { + s.log.Error(err, "encode operationData failed", "auction", a.ID) + return bidDecision{skip: "encode_error"} + } + nonce := s.nonces.next(st.Exec.Nonce.Uint64()) + sig, err := SignBid(s.deps.Signer, s.chainID, s.cfg.Callback, opData, priced.bidNative, big.NewInt(int64(nonce)), gasPrice) + if err != nil { + s.log.Error(err, "sign bid failed", "auction", a.ID) + return bidDecision{skip: "sign_error"} + } + + positions := make([]positionKey, len(b.legs)) + for i, leg := range b.legs { + positions[i] = positionKey{market: leg.MarketId, borrower: leg.Borrower} + } + return bidDecision{ + legs: len(b.legs), + gross: b.grossLoan, + bidNative: priced.bidNative, + gasNative: priced.gasNative, + nonce: nonce, + positions: positions, + gas: priced.gas, + solve: SolveMessage{ + Op: "solve", ID: a.ID, + Data: SolveData{ + Bid: weiToEthString(priced.bidNative), + Nonce: new(big.Int).SetUint64(nonce).String(), + OperationCallback: s.cfg.Callback.Hex(), + OperationData: hexutil.Encode(opData), + LiquidationSig: hexutil.Encode(sig), + MaxTxGasPrice: gasPrice.String(), + Borrowers: b.borrowers(), + }, + }, + } +} + +func (s *Solver) fundingSkip(a AuctionMessage, st cachedState, priced pricedBundle, inFlight inFlightState, gasPrice *big.Int) string { + requiredDeposit := new(big.Int).Add(minDeposit, priced.gasNative) + availableDeposit := new(big.Int).Sub(orZero(st.Exec.Deposit), inFlight.gasNative) + if availableDeposit.Cmp(requiredDeposit) < 0 { + s.log.Info("bid skipped: executor deposit cannot cover predicted gas", + "auction", a.ID, "depositWei", st.Exec.Deposit, "reservedGasWei", inFlight.gasNative, + "availableWei", availableDeposit, "requiredWei", requiredDeposit, + "minDepositWei", minDeposit, "predictedGas", priced.gas.Units, "gasPriceWei", gasPrice) + return skipDepositLow + } + + availableCallback := new(big.Int).Sub(orZero(st.CallbackNative), inFlight.bidNative) + if availableCallback.Cmp(priced.bidNative) < 0 { + s.log.Info("bid skipped: callback balance cannot cover bid", + "auction", a.ID, "callbackWei", st.CallbackNative, "reservedBidWei", inFlight.bidNative, + "availableWei", availableCallback, "requiredWei", priced.bidNative) + return skipCallbackBalance + } + return "" +} + +func dropInFlightLegs(scored []scoredLeg, inFlight map[positionKey]bool) ([]scoredLeg, bool) { + hadLegs := len(scored) > 0 + if len(inFlight) == 0 { + return scored, hadLegs + } + kept := scored[:0] + for _, sl := range scored { + if !inFlight[positionKey{sl.MarketId, sl.Borrower}] { + kept = append(kept, sl) + } + } + return kept, hadLegs +} + +func (s *Solver) logBundleEconomics(auctionID, msg string, b chosenBundle, rate *big.Int, gasState *gasPredictorState, gasPrice *big.Int, gasLimit uint64, feedCount, scoredLegs int) { + gas := gasPredictionForBundleFeeds(b, gasState, feedCount) + grossNative := loanToNative(b.grossLoan, rate) + gasNative := gasCostNative(gas.Units, gasPrice) + netNative := s.bundleNetNativeForFeeds(b, rate, gasState, gasPrice, feedCount) + bidNative := s.bundleBidNative(b, rate) + s.log.Info(msg, + "auction", auctionID, + "scoredLegs", scoredLegs, + "selectedLegs", len(b.legs), + "feedCount", feedCount, + "grossLoan", b.grossLoan, + "grossNative", grossNative, + "gasUnits", gas.Units, + "gasNative", gasNative, + "gasPriceWei", gasPrice, + "bidNative", bidNative, + "minBundleProfitNative", s.minBundleProfitNative(bidNative), + "netNative", netNative, + "gasLimit", gasLimit, + "usableGasLimit", usableBundleGasLimit(gasLimit), + "routes", gasRoutesString(gas.Routes)) +} + +// tooLate reports whether the auction's deadline has already passed: the window is timeoutMs measured from +// the auctioneer EMIT time (emitMs, absolute epoch-ms — the same field clampTsAt trusts). Using emit (not +// frame-receipt) charges WS transit against the budget, so a stale frame doesn't pass the gate and send a +// doomed bid that needlessly reserves headroom. Clock-skew guard, exactly like clampTsAt: if emitMs is 0 or +// in the FUTURE (emit > now — a bogus/forward timestamp), don't trust it — fall back to the local +// frame-receipt measure (now − start). caller gates this on timeoutMs > 0. +func tooLate(emitMs int64, timeoutMs int, start, now time.Time) bool { + window := time.Duration(timeoutMs) * time.Millisecond + if emitMs <= 0 || emitMs > now.UnixMilli() { // no/forward emit timestamp → trust the local clock + return now.Sub(start) > window + } + return now.UnixMilli()-emitMs > int64(timeoutMs) +} + +// sinceEmitMs is the elapsed ms since the auctioneer emitted the frame (≤0 when emit is unset/forward), +// for the too_late log line. +func sinceEmitMs(emitMs int64, now time.Time) int64 { + if emitMs <= 0 { + return 0 + } + return now.UnixMilli() - emitMs +} + +func callbackAuthDeadline(now time.Time, ttl time.Duration) *big.Int { + return big.NewInt(now.Add(ttl).Unix()) +} + +// clampTsAt derives the accrual timestamp from the auction's (attacker-influenceable) timestamp, +// clamped to a sane window around the given clock so a bogus value can't skew interest accrual. A +// future timestamp is never trusted (accruing past `now` over-states debt and could flag a healthy +// position) — it clamps to `now`, which under-accrues vs the later settlement block (fail closed). +func clampTsAt(auctionMs int64, now time.Time) uint64 { + nowSec := now.Unix() + if auctionMs <= 0 { + return uint64(nowSec) + } + ts := auctionMs / 1000 + const skew = 600 // tolerate up to 10 min of staleness in the past + if ts < nowSec-skew || ts > nowSec { + return uint64(nowSec) + } + return uint64(ts) +} + +// weiToEthString formats wei as a decimal ether string exactly (the solve `bid` field, which must +// equal formatEther(bidWei) — §6.1): integer/fraction split, 18-digit fraction, trailing zeros +// trimmed. +func weiToEthString(wei *big.Int) string { + q, r := new(big.Int).DivMod(wei, morpho.Wad, new(big.Int)) // wad = 1e18 + if r.Sign() == 0 { + return q.String() + } + frac := r.String() + for len(frac) < 18 { + frac = "0" + frac + } + frac = strings.TrimRight(frac, "0") + return q.String() + "." + frac +} + +// weiFloat converts wei to a float64 for gauge reporting only (lossy at >2^53; fine for dashboards). +func weiFloat(n *big.Int) float64 { + f, _ := new(big.Float).SetInt(n).Float64() + return f +} + +// cachedState is the atomically-swapped snapshot of the on-chain state needed for pre-bid checks: +// the signer's Executor accounting plus the callback contract's native balance (must cover the bid +// or payBid underpays). Written by the ops loop, read lock-free on the hot path. +type cachedState struct { + Exec ExecutorState + CallbackNative *big.Int + Rate *big.Int + Gas *gasPredictorState + GasLimit uint64 + UpdatedAt time.Time // wall clock of the last successful ops-loop store +} + +type stateCache struct { + p atomic.Pointer[cachedState] +} + +func (s *stateCache) store(v cachedState) { s.p.Store(&v) } + +func (s *stateCache) load() (cachedState, bool) { + v := s.p.Load() + if v == nil { + return cachedState{}, false + } + return *v, true +} diff --git a/internal/solvers/redstoneoev/solver_test.go b/internal/solvers/redstoneoev/solver_test.go new file mode 100644 index 00000000..a9c1fbc5 --- /dev/null +++ b/internal/solvers/redstoneoev/solver_test.go @@ -0,0 +1,1636 @@ +package redstoneoev + +import ( + "context" + "encoding/json" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/go-logr/logr" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/morpho" + "github.com/symbioticfi/vault-solver/internal/solver" +) + +// seedAdapter is the LiquidLane adapter stamped into the seeded market, so tests can assert it flows +// snapshot → leg → operationData. +var seedAdapter = common.HexToAddress("0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b") + +// seededSolver wires a Solver that does no chain/WS I/O: a monitor whose snapshot is pre-populated +// (RedStone source), a stateCache with healthy accounting, and an in-memory signer — exactly the +// surface buildBid reads. nowFn drives accrual/breaker timing deterministically. +func seededSolver(t *testing.T) (*Solver, *testSigner) { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatal(err) + } + sgnr := &testSigner{key: key, addr: crypto.PubkeyToAddress(key.PublicKey)} + + id := common.HexToHash("0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5") + oracle := common.HexToAddress("0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D") + + mon := &apiMonitor{log: logr.Discard()} + mon.snap.Store(&snapshot{ + markets: map[common.Hash]MarketInfo{ + id: {Params: abiMarketParams{Oracle: oracle, Lltv: mustBig("860000000000000000")}, State: goldenMarket()}, + }, + // Cached on-chain oracle price ($1550) — used by testMonitor. + prices: map[common.Hash]*big.Int{id: mustBig("1550000000000000000000000000")}, + quotes: map[common.Hash]AdapterQuote{ + // The single adapter's quote: sells the RWA at ~$1780 (≈1% under the auctioned $1800.9); ample liquidity. + id: newQuote("1780000000000000000000", mustBig("100000000000")), + }, + // Independently-tracked at-risk positions — the SOLE candidate source + // now that the frame's pushed positions are no longer consumed. Both fixture borrowers are seeded so + // workerCandidates surfaces them, evaluated at the frame/onchain price. The captured frame still + // carries these same positions, but they're ignored: candidates come from snap.positions. + positions: map[common.Hash]map[common.Address]morpho.PositionState{ + id: { + // 0x629d… — goldenBorrower (1.0 TCOL, borrowShares 1685600000000000). + common.HexToAddress("0x629d764eC8563AFA701709B52c1a215e865632dE"): goldenBorrower(), + // 0x378a… — the frame's second borrower. + common.HexToAddress("0x378a49c640fd9eea888a6a553caae441e2fdebc6"): { + BorrowShares: mustBig("1582399974653062"), Collateral: mustBig("1000000000000000000"), + }, + }, + }, + block: 100, + blockTime: 1781243340, + updatedAt: auctionClock()(), + }) + + cfg := &Config{ + Executor: common.HexToAddress("0xfdFB1862a53a974b166d1f0D012f524Ebd2e0EbD"), + Callback: common.HexToAddress("0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1"), + Adapter: seedAdapter, + BidWei: mustBig("500000000000000"), // 0.0005 ETH flat bid + CallbackAuthTTL: defaultCallbackAuthTTL, + MaxTxGasPrice: big.NewInt(1_000_000_000), + MaxStateAge: defaultMaxStateAge, + Sizing: SizingParams{AllowFullLiquidation: true, SwapHaircutBps: 0}, + } + + s := &Solver{ + cfg: cfg, + chainID: big.NewInt(11155111), + nonces: &nonceStore{}, + breaker: newBreaker(3, time.Hour), + seen: newSeenAuctions(maxSeenAuctions), + log: logr.Discard(), + deps: solver.Deps{Signer: sgnr}, + // Disconnected WS client: Send just buffers into its channel, which tests drain to capture solves. + ws: newWSClient(wsConfig{URL: "wss://test", APIKey: "k", Topics: []string{"t"}}, logr.Discard(), func(context.Context, []byte) {}), + } + s.mon = mon + // Healthy accounting: deposit clears MIN_DEPOSIT; callback covers the bid. + s.state.store(cachedState{ + Exec: ExecutorState{Nonce: big.NewInt(7), Deposit: mustBig("100000000000000000"), Locked: false}, + CallbackNative: mustBig("1000000000000000000"), + Rate: mustBig("2500000000"), // 2500e6 loan base units per ETH + GasLimit: redstoneExecutorMaxGasUnits, + Gas: &gasPredictorState{ + FreeAssets: mustBig("100000000000"), + Withdrawable: mustBig("100000000000"), + Acquire: map[common.Address]*big.Int{}, + }, + UpdatedAt: auctionClock()(), + }) + return s, sgnr +} + +type testFataler interface { + Helper() + Fatalf(format string, args ...any) +} + +func snapshotOf(t testFataler, s *Solver) *snapshot { + t.Helper() + return s.mon.snapshot() +} + +func storeSnapshot(t testFataler, s *Solver, snap *snapshot) { + t.Helper() + switch m := s.mon.(type) { + case *apiMonitor: + m.snap.Store(snap) + case *testMonitor: + m.snap.Store(snap) + default: + t.Fatalf("unexpected monitor type %T", s.mon) + } +} + +func useOnchainTestMonitor(t *testing.T, s *Solver) { + t.Helper() + mon := &testMonitor{log: logr.Discard()} + mon.snap.Store(snapshotOf(t, s)) + s.mon = mon +} + +// setSnapshotBlockTime re-stamps the cached snapshot (and the ops state) for tests that drive +// handleMessage with wall-clock time: blockTime tracks the frame and both updatedAt stamps move to now +// so the stale-state gate sees freshly-refreshed caches. +func setSnapshotBlockTime(t *testing.T, s *Solver, tsMs int64) { + t.Helper() + snap := *snapshotOf(t, s) + snap.blockTime = uint64(tsMs / 1000) + snap.updatedAt = time.Now() + storeSnapshot(t, s, &snap) + if st, ok := s.state.load(); ok { + st.UpdatedAt = time.Now() + s.state.store(st) + } +} + +// auctionClock returns a clock within ±600s of the captured auction's timestamp, so clampTsAt keeps +// the auction timestamp (deterministic accrual) instead of falling back to wall-clock. +func auctionClock() func() time.Time { return func() time.Time { return time.Unix(1781243340, 0) } } + +// decodeAuction parses the captured live auction frame (the fixture every bid test starts from). +func decodeAuction(t *testing.T) AuctionMessage { + t.Helper() + var a AuctionMessage + if err := json.Unmarshal([]byte(capturedAuction), &a); err != nil { + t.Fatal(err) + } + return a +} + +func recoverCallbackAuthSigner(t *testing.T, s *Solver, op operationData) common.Address { + t.Helper() + legs := make([]LiquidationLeg, len(op.Legs)) + for i, leg := range op.Legs { + legs[i] = LiquidationLeg(leg) + } + digest, err := CallbackAuthDigest(s.chainID, s.cfg.Callback, s.cfg.Executor, op.Auth, legs) + if err != nil { + t.Fatalf("callback auth digest: %v", err) + } + sig := append([]byte(nil), op.AuthSig...) + if len(sig) != 65 { + t.Fatalf("callback auth signature len = %d, want 65", len(sig)) + } + if sig[64] >= 27 { + sig[64] -= 27 + } + pub, err := crypto.SigToPub(digest.Bytes(), sig) + if err != nil { + t.Fatalf("recover callback auth: %v", err) + } + return crypto.PubkeyToAddress(*pub) +} + +// TestBuildBidStaleStateGate pins the background-cache staleness gate: a monitor snapshot or ops state +// older than cfg.MaxStateAge fails closed with stale_state before any sizing runs. +func TestBuildBidStaleStateGate(t *testing.T) { + base := auctionClock()() + pastMax := func() time.Time { return base.Add(defaultMaxStateAge + time.Second) } + + t.Run("both caches stale", func(t *testing.T) { + s, _ := seededSolver(t) + if d := s.buildBid(decodeAuction(t), pastMax); d.skip != skipStaleState { + t.Fatalf("skip = %q, want %q", d.skip, skipStaleState) + } + }) + t.Run("monitor stale, ops fresh", func(t *testing.T) { + s, _ := seededSolver(t) + st, _ := s.state.load() + st.UpdatedAt = pastMax() + s.state.store(st) + if d := s.buildBid(decodeAuction(t), pastMax); d.skip != skipStaleState { + t.Fatalf("skip = %q, want %q", d.skip, skipStaleState) + } + }) + t.Run("ops stale, monitor fresh", func(t *testing.T) { + s, _ := seededSolver(t) + snap := *snapshotOf(t, s) + snap.updatedAt = pastMax() + storeSnapshot(t, s, &snap) + if d := s.buildBid(decodeAuction(t), pastMax); d.skip != skipStaleState { + t.Fatalf("skip = %q, want %q", d.skip, skipStaleState) + } + }) + t.Run("fresh caches pass the gate", func(t *testing.T) { + s, _ := seededSolver(t) + if d := s.buildBid(decodeAuction(t), auctionClock()); d.skip == skipStaleState { + t.Fatalf("fresh caches must not trip stale_state") + } + }) +} + +func TestBuildBidHappyPath(t *testing.T) { + s, sgnr := seededSolver(t) + a := decodeAuction(t) + + d := s.buildBid(a, auctionClock()) + if d.skip != "" { + t.Fatalf("expected a bid, got skip %q", d.skip) + } + if d.legs != 2 { + t.Fatalf("legs = %d, want both profitable same-market borrowers", d.legs) + } + if len(d.solve.Data.Borrowers) != 2 { + t.Fatalf("borrowers = %v, want 2", d.solve.Data.Borrowers) + } + if d.solve.Data.Bid != "0.0005" { + t.Fatalf("bid = %q, want 0.0005 (flat BidWei)", d.solve.Data.Bid) + } + if d.solve.Data.Nonce != "8" { // on-chain 7, next is strictly greater + t.Fatalf("nonce = %q, want 8", d.solve.Data.Nonce) + } + // Flat-bid path: gross carries the bundle's Σ loan-token profit (logging only); the bid is the flat BidWei. + if d.gross == nil || d.gross.Sign() <= 0 { + t.Fatalf("gross profit = %v, want > 0", d.gross) + } + + // Full sign path: the LiquidationSig must recover to our signer over the EXECUTOR_V6 digest the + // Executor verifies (keccak(opData) bound into the digest, EIP-191 wrapped). + opData, err := hexutil.Decode(d.solve.Data.OperationData) + if err != nil { + t.Fatal(err) + } + op, err := decodeOperationData(opData) + if err != nil { + t.Fatalf("decode operationData: %v", err) + } + if op.Auth.AuctionKey != auctionKeyHash(a) || op.Auth.BidAmount.Cmp(s.cfg.BidWei) != 0 || + op.Auth.MinBundleProfit.Sign() <= 0 || op.Auth.Deadline.Cmp(callbackAuthDeadline(auctionClock()(), s.cfg.CallbackAuthTTL)) != 0 { + t.Fatalf("bad operation auth: %+v", op.Auth) + } + if len(op.Legs) != 2 || op.Legs[0].MaxSeizeAssets.Sign() <= 0 || op.Legs[0].MinProfit.Sign() <= 0 { + t.Fatalf("encoded leg must carry maxSeizeAssets and minProfit, got %+v", op.Legs) + } + st, _ := s.state.load() + wantBundleFloor := nativeToLoan(new(big.Int).Add(d.gasNative, d.bidNative), st.Rate) + if op.Auth.MinBundleProfit.Cmp(wantBundleFloor) != 0 { + t.Fatalf("minBundleProfit = %s, want %s", op.Auth.MinBundleProfit, wantBundleFloor) + } + for i, leg := range op.Legs { + route := d.gas.Routes[i] + wantLegFloor := nativeToLoan(gasCostNative(gasUnitsForRoute(route), s.cfg.MaxTxGasPrice), st.Rate) + if leg.MinProfit.Cmp(wantLegFloor) != 0 { + t.Fatalf("leg %d minProfit = %s, want %s for route %s", i, leg.MinProfit, wantLegFloor, route) + } + } + if got := recoverCallbackAuthSigner(t, s, op); got != sgnr.addr { + t.Fatalf("callback auth recovered %s, want signer %s", got, sgnr.addr) + } + if got := recoverSolveSigner(t, s, d.solve.Data); got != sgnr.addr { + t.Fatalf("recovered %s, want signer %s", got, sgnr.addr) + } +} + +func TestBuildBidAllowsReplayedSameMarketBundle(t *testing.T) { + s, _ := seededSolver(t) + a := decodeAuction(t) + + d := s.buildBid(a, auctionClock()) + if d.skip != "" { + t.Fatalf("expected a bid, got skip %q", d.skip) + } + opData, err := hexutil.Decode(d.solve.Data.OperationData) + if err != nil { + t.Fatal(err) + } + op, err := decodeOperationData(opData) + if err != nil { + t.Fatalf("decode operationData: %v", err) + } + legs := op.Legs + if len(legs) != 2 { + t.Fatalf("encoded %d legs, want two same-market legs after replay", len(legs)) + } + if legs[0].MarketId != legs[1].MarketId { + t.Fatalf("fixture should select two borrowers from one market, got %s and %s", legs[0].MarketId, legs[1].MarketId) + } + state := morpho.AccruedMarketState(goldenMarket(), uint64(a.Timestamp/1000)) + positions := map[common.Address]morpho.PositionState{ + common.HexToAddress("0x629d764eC8563AFA701709B52c1a215e865632dE"): goldenBorrower(), + common.HexToAddress("0x378a49c640fd9eea888a6a553caae441e2fdebc6"): { + BorrowShares: mustBig("1582399974653062"), Collateral: mustBig("1000000000000000000"), + }, + } + for _, leg := range legs { + pos, ok := positions[leg.Borrower] + if !ok { + t.Fatalf("unexpected borrower %s", leg.Borrower) + } + replay, ok := morpho.ApplySeizeLiquidation(state, pos, leg.MaxSeizeAssets, mustBig("1550000000000000000000000000")) + if !ok { + t.Fatalf("encoded leg for %s does not replay against current simulated state", leg.Borrower) + } + state = replay.Market + positions[leg.Borrower] = replay.Position + } +} + +func TestBuildBidCapsBundleByCachedGasLimit(t *testing.T) { + s, _ := seededSolver(t) + st, _ := s.state.load() + oneUnknownLeg := fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstUnknownLeg + st.GasLimit = headerGasLimitForUsable(oneUnknownLeg) + s.state.store(st) + + d := s.buildBid(decodeAuction(t), auctionClock()) + if d.skip != "" { + t.Fatalf("expected one gas-fit bid, got skip %q", d.skip) + } + if d.legs != 1 { + t.Fatalf("legs = %d, want only one leg to fit cached gas limit", d.legs) + } + if d.gas.Units > usableBundleGasLimit(st.GasLimit) { + t.Fatalf("predicted gas %d exceeds usable limit %d", d.gas.Units, usableBundleGasLimit(st.GasLimit)) + } +} + +func TestComposeLoanPerEth(t *testing.T) { + cases := []struct { + name string + ethUsd, loanUsd *big.Int + ethFeedDec, loanFeedDec, loanDec int + want string + }{ + {"USDC at 2500, 8-dec feeds, 6-dec loan", mustBig("250000000000"), mustBig("100000000"), 8, 8, 6, "2500000000"}, + {"18-dec loan", mustBig("250000000000"), mustBig("100000000"), 8, 8, 18, "2500000000000000000000"}, + {"mixed feed decimals", mustBig("2500000000000000000000"), mustBig("100000000"), 18, 8, 6, "2500000000"}, + {"zero loan price", mustBig("250000000000"), big.NewInt(0), 8, 8, 6, ""}, + {"negative answer", big.NewInt(-1), mustBig("100000000"), 8, 8, 6, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := composeLoanPerEth(c.ethUsd, c.loanUsd, c.ethFeedDec, c.loanFeedDec, c.loanDec) + if c.want == "" { + if got != nil { + t.Fatalf("want nil, got %s", got) + } + return + } + if got == nil || got.String() != c.want { + t.Fatalf("got %v, want %s", got, c.want) + } + }) + } +} + +func TestValidRateAndConversions(t *testing.T) { + if got := validRate(nil); got != nil { + t.Fatalf("no cached oracle rate should fail closed, got %v", got) + } + + rate := mustBig("2500000000") + if got := validRate(rate); got == nil || got.String() != "2500000000" { + t.Fatalf("oracle rate present should be used, got %v", got) + } + + if got := loanToNative(mustBig("2500000000"), mustBig("2500000000")); got.Cmp(morpho.Wad) != 0 { + t.Fatalf("2500e6 loan at 2500e6/ETH = %s native units, want 1 ETH", got) + } + if got := loanToNative(mustBig("1"), nil); got.Sign() != 0 { + t.Fatalf("nil rate should convert to 0, got %s", got) + } + if got := nativeToLoan(morpho.Wad, mustBig("2500000000")); got.String() != "2500000000" { + t.Fatalf("1 native at 2500e6/native = %s loan units", got) + } +} + +func selectedBundleForTest(t *testing.T, s *Solver, a AuctionMessage) chosenBundle { + t.Helper() + scored := s.scoredLegs(a, auctionClock()()) + if len(scored) == 0 { + t.Fatal("precondition: expected scored legs") + } + st, _ := s.state.load() + b, skip := s.selectBundleWithGas(scored, st.Gas, st.GasLimit, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("precondition: selectBundle skip %q", skip) + } + return b +} + +func TestBuildBidGasProfitabilityGate(t *testing.T) { + a := decodeAuction(t) + + t.Run("net below min skips gas_unprofitable", func(t *testing.T) { + s, _ := seededSolver(t) + s.cfg.MaxTxGasPrice = mustBig("1000000000000000000") + if d := s.buildBid(a, auctionClock()); d.skip != skipGasUnprofitable { + t.Fatalf("skip = %q, want %q", d.skip, skipGasUnprofitable) + } + }) + + t.Run("exact boundary passes", func(t *testing.T) { + s, _ := seededSolver(t) + b := selectedBundleForTest(t, s, a) + st, _ := s.state.load() + gasUnits := gasPredictionForBundle(b, st.Gas).Units + if b.grossLoan.Cmp(new(big.Int).SetUint64(gasUnits)) <= 0 { + t.Fatalf("test fixture cannot form exact gas boundary: gross=%s gasUnits=%d", b.grossLoan, gasUnits) + } + s.cfg.BidWei = new(big.Int).Sub(b.grossLoan, new(big.Int).SetUint64(gasUnits)) + s.cfg.MaxTxGasPrice = big.NewInt(1) + st.Rate = morpho.Wad + s.state.store(st) + if d := s.buildBid(a, auctionClock()); d.skip != "" { + t.Fatalf("exact after-cost boundary should pass, got skip %q", d.skip) + } + }) + + t.Run("one wei below boundary skips", func(t *testing.T) { + s, _ := seededSolver(t) + b := selectedBundleForTest(t, s, a) + st, _ := s.state.load() + gasUnits := gasPredictionForBundle(b, st.Gas).Units + if b.grossLoan.Cmp(new(big.Int).SetUint64(gasUnits)) <= 0 { + t.Fatalf("test fixture cannot form gas boundary: gross=%s gasUnits=%d", b.grossLoan, gasUnits) + } + s.cfg.BidWei = new(big.Int).Sub(b.grossLoan, new(big.Int).SetUint64(gasUnits)) + s.cfg.BidWei.Add(s.cfg.BidWei, big.NewInt(1)) + s.cfg.MaxTxGasPrice = big.NewInt(1) + st.Rate = morpho.Wad + s.state.store(st) + if d := s.buildBid(a, auctionClock()); d.skip != skipGasUnprofitable { + t.Fatalf("skip = %q, want %q", d.skip, skipGasUnprofitable) + } + }) + + t.Run("dry-run without rate skips because callback auth needs loan profit floors", func(t *testing.T) { + s, _ := seededSolver(t) + s.dryRun = true + st, _ := s.state.load() + st.Rate = nil + s.state.store(st) + if d := s.buildBid(a, auctionClock()); d.skip != skipGasUnprofitable { + t.Fatalf("dry-run no-rate path should skip %q, got %q", skipGasUnprofitable, d.skip) + } + }) +} + +func TestBuildBidSignsConfiguredGasPriceCap(t *testing.T) { + s, _ := seededSolver(t) + s.cfg.MaxTxGasPrice = big.NewInt(1_000_000_000) + + d := s.buildBid(decodeAuction(t), auctionClock()) + if d.skip != "" { + t.Fatalf("expected bid, got skip %q", d.skip) + } + if d.solve.Data.MaxTxGasPrice != s.cfg.MaxTxGasPrice.String() { + t.Fatalf("maxTxGasPrice = %q, want configured cap %s", d.solve.Data.MaxTxGasPrice, s.cfg.MaxTxGasPrice) + } + if got := recoverSolveSigner(t, s, d.solve.Data); got != s.deps.Signer.Address() { + t.Fatalf("recovered %s, want signer %s", got, s.deps.Signer.Address()) + } +} + +func TestFactoryRejectsLiveBiddingWithoutRateSource(t *testing.T) { + t.Setenv("K", "k") + t.Setenv(envDryRun, "false") + + var node yaml.Node + if err := yaml.Unmarshal([]byte(wsline+addrs+api+okBid), &node); err != nil { + t.Fatal(err) + } + _, err := factory(node, solver.Deps{}) + if err == nil { + t.Fatal("expected live factory to reject config without loanEthFeed") + } +} + +// TestBuildBidPriceSource proves the price-source switch through buildBid: the test-only on-chain path sizes +// against the cached on-chain price (ignoring a healthy frame → §6.6 dev-settlement fix), while the +// production auctioned path trusts the frame — a healthy frame skips, and a liquidatable frame drives a +// full SIZED bid (the otherwise-untested mainnet sizing path, since the dev testbed only ever runs the +// on-chain test flag). (Monitor-level marketPrice resolution is covered by TestMarketPriceSource.) +func TestBuildBidPriceSource(t *testing.T) { + const feed = "0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D" + const px5000 = "5000000000000000000000000000" // healthy + const px1550 = "1550000000000000000000000000" // the golden position is liquidatable here + cases := []struct { + name string + onchainTest bool + framePx string + wantSkip string + wantSized bool // for the bidding case, assert a full leg was sized + }{ + {"onchain test flag bids against cached $1550 despite a healthy $5000 frame", true, px5000, "", false}, + {"auctioned trusts the healthy $5000 frame → no_legs", false, px5000, "no_legs", false}, + {"auctioned sizes a full bid at a liquidatable $1550 frame", false, px1550, "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + a := decodeAuction(t) + a.Payload.Prices = map[string]string{feed: tc.framePx} + s, _ := seededSolver(t) + if tc.onchainTest { + useOnchainTestMonitor(t, s) + } + d := s.buildBid(a, auctionClock()) + if d.skip != tc.wantSkip { + t.Fatalf("skip = %q, want %q", d.skip, tc.wantSkip) + } + if tc.wantSized && (d.legs < 1 || len(d.positions) < 1) { + t.Fatalf("expected ≥1 sized leg, got legs=%d positions=%d", d.legs, len(d.positions)) + } + }) + } +} + +// TestBuildBidReservesInFlightFunding checks that a sent bid's payBid native is debited from the cached +// headroom so a second auction in the same window can't double-spend it — and that clearing the +// reservation (as refreshState does after a fresh on-chain read) re-opens the headroom. +func TestBuildBidReservesInFlightFunding(t *testing.T) { + s, _ := seededSolver(t) + a := decodeAuction(t) + // Tighten the callback to exactly one bid's worth of native: a second in-flight bid must be blocked. + st, _ := s.state.load() + st.CallbackNative = new(big.Int).Set(s.cfg.BidWei) + s.state.store(st) + + d1 := s.buildBid(a, auctionClock()) + if d1.skip != "" { + t.Fatalf("first bid should succeed, got skip %q", d1.skip) + } + s.reserve(d1.bidNative, nil, d1.nonce, time.Unix(1781243340, 0), nil, "", common.Hash{}, gasPrediction{}) + + if d2 := s.buildBid(a, auctionClock()); d2.skip != skipCallbackBalance { + t.Fatalf("second in-flight bid should skip callback_balance (native already committed), got %q", d2.skip) + } + + // A fresh on-chain read whose nonce REACHED d1's (it settled: the Executor sets the nonce to the + // consumed bid's nonce) frees the reservation → headroom re-opens. Uses == d1.nonce, not +1, to pin + // that settlement (on-chain nonce == bid nonce) is what releases it. + s.pruneReservations(d1.nonce, time.Unix(1781243340, 0)) + if d3 := s.buildBid(a, auctionClock()); d3.skip != "" { + t.Fatalf("after the bid resolved a bid should be allowed again, got skip %q", d3.skip) + } +} + +func TestBuildBidChecksDepositGasHeadroom(t *testing.T) { + s, _ := seededSolver(t) + a := decodeAuction(t) + + probe := s.buildBid(a, auctionClock()) + if probe.skip != "" { + t.Fatalf("fixture should bid before tightening deposit, got skip %q", probe.skip) + } + required := new(big.Int).Add(minDeposit, probe.gasNative) + st, _ := s.state.load() + st.Exec.Deposit = new(big.Int).Sub(required, big.NewInt(1)) + s.state.store(st) + + if d := s.buildBid(a, auctionClock()); d.skip != "deposit_low" { + t.Fatalf("deposit below predicted gas headroom should skip deposit_low, got %q", d.skip) + } +} + +func TestBuildBidReservesInFlightGasFunding(t *testing.T) { + s, _ := seededSolver(t) + a := decodeAuction(t) + + probe := s.buildBid(a, auctionClock()) + if probe.skip != "" { + t.Fatalf("fixture should bid before tightening deposit, got skip %q", probe.skip) + } + st, _ := s.state.load() + st.Exec.Deposit = new(big.Int).Add(minDeposit, probe.gasNative) + s.state.store(st) + + d1 := s.buildBid(a, auctionClock()) + if d1.skip != "" { + t.Fatalf("first bid should fit exactly one gas reservation, got skip %q", d1.skip) + } + s.reserve(d1.bidNative, d1.gasNative, d1.nonce, time.Unix(1781243340, 0), nil, "", common.Hash{}, d1.gas) + + if d2 := s.buildBid(a, auctionClock()); d2.skip != skipDepositLow { + t.Fatalf("second bid should skip deposit_low because gas is already reserved, got %q", d2.skip) + } + + s.pruneReservations(d1.nonce, time.Unix(1781243340, 0)) + if d3 := s.buildBid(a, auctionClock()); d3.skip != "" { + t.Fatalf("after gas reservation clears the bid should fit again, got skip %q", d3.skip) + } +} + +// TestBuildBidSkipsInFlightPosition pins that a second rapid auction for an in-flight position is skipped +// instead of re-bid against the still-stale snapshot. +func TestBuildBidSkipsInFlightPosition(t *testing.T) { + s, _ := seededSolver(t) + onlyBorrower := common.HexToAddress("0x629d764eC8563AFA701709B52c1a215e865632dE") + snap := *snapshotOf(t, s) + for market, positions := range snap.positions { + for borrower := range positions { + if borrower != onlyBorrower { + delete(positions, borrower) + } + } + snap.positions[market] = positions + } + storeSnapshot(t, s, &snap) + a := decodeAuction(t) + + d1 := s.buildBid(a, auctionClock()) + if d1.skip != "" || len(d1.positions) == 0 { + t.Fatalf("first bid should succeed with reserved positions, got skip %q positions %d", d1.skip, len(d1.positions)) + } + s.reserve(d1.bidNative, nil, d1.nonce, time.Unix(1781243340, 0), d1.positions, "", common.Hash{}, gasPrediction{}) + + if d2 := s.buildBid(a, auctionClock()); d2.skip != "in_flight" { + t.Fatalf("a second auction for the same in-flight position(s) must skip in_flight, got %q", d2.skip) + } + + // Once the bid resolves (the on-chain nonce REACHES the bid's nonce — settlement sets it to exactly + // the consumed nonce), the positions free and become biddable again. Uses == d1.nonce, not +1. + s.pruneReservations(d1.nonce, time.Unix(1781243340, 0)) + if d3 := s.buildBid(a, auctionClock()); d3.skip != "" { + t.Fatalf("after the in-flight bid resolved the position should be biddable again, got %q", d3.skip) + } +} + +// TestPruneReservations pins the precise headroom release: a bid whose nonce fell below the on-chain nonce +// (submitted → settled/reverted) or that aged past reservationTTL (lost its auction) is freed, while a +// recent still-pending bid keeps its reservation. (A7). +func TestPruneReservations(t *testing.T) { + s, _ := seededSolver(t) + now := time.Unix(1781243340, 0) + s.reserve(big.NewInt(100), big.NewInt(10), 8, now, nil, "", common.Hash{}, gasPrediction{}) + s.reserve(big.NewInt(200), big.NewInt(20), 10, now, nil, "", common.Hash{}, gasPrediction{}) + s.reserve(big.NewInt(300), big.NewInt(30), 12, now.Add(-time.Hour), nil, "", common.Hash{}, gasPrediction{}) + + // nonce 10 frees 8 (below) AND 10 (settlement sets the on-chain nonce to the consumed bid's nonce, so + // nonce == r.nonce must release it — the F1 fix: `<=`, not `<`); 12 is freed by age (> TTL) → none left. + s.pruneReservations(10, now) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.Sign() != 0 || inFlight.gasNative.Sign() != 0 { + t.Fatalf("all reservations should be freed, got bid=%s gas=%s", inFlight.bidNative, inFlight.gasNative) + } + + s.reserve(big.NewInt(500), big.NewInt(50), 11, now, nil, "", common.Hash{}, gasPrediction{}) + s.pruneReservations(10, now) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.String() != "500" || inFlight.gasNative.String() != "50" { + t.Fatalf("a recent pending bid should be kept, got bid=%s gas=%s", inFlight.bidNative, inFlight.gasNative) + } + + // A bid is freed exactly when the on-chain nonce reaches its nonce (== r.nonce), not only when it passes. + s.pruneReservations(11, now) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.Sign() != 0 || inFlight.gasNative.Sign() != 0 { + t.Fatalf("bid with nonce == on-chain nonce should be freed at settlement, got bid=%s gas=%s", inFlight.bidNative, inFlight.gasNative) + } +} + +func TestWonReservationSurvivesDelayedSettlement(t *testing.T) { + s, _ := seededSolver(t) + now := time.Unix(1781243340, 0) + pos := []positionKey{{market: common.Hash{1}, borrower: common.Address{2}}} + s.reserve(big.NewInt(100), big.NewInt(10), 8, now, pos, "auction-won", common.Hash{}, gasPrediction{}) + + s.pruneReservations(7, now.Add(2*time.Minute)) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.String() != "100" || len(inFlight.positions) != 1 { + t.Fatalf("won bid must stay reserved while settlement is delayed, bid=%s positions=%d", inFlight.bidNative, len(inFlight.positions)) + } +} + +func TestReservationByAuctionCarriesGasPrediction(t *testing.T) { + s, _ := seededSolver(t) + pred := gasPrediction{Units: 350_000, Routes: []gasRoute{gasRouteAcquire}} + auctionKey := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + s.reserve(big.NewInt(100), big.NewInt(50), 8, time.Unix(1781243340, 0), nil, "auction-1", auctionKey, pred) + + got, ok := s.reservationByAuction("auction-1") + if !ok { + t.Fatal("reservationByAuction did not find sent bid") + } + if got.gasUnits != pred.Units || got.gasRoutes != "acquire" { + t.Fatalf("attribution = gas %d routes %q", got.gasUnits, got.gasRoutes) + } + if got.auctionKey != auctionKey { + t.Fatalf("auctionKey = %s, want %s", got.auctionKey, auctionKey) + } +} + +func TestAuctionResultReleasesLostBidReservation(t *testing.T) { + s, _ := seededSolver(t) + now := time.Unix(1781243340, 0) + pos := []positionKey{{market: common.Hash{1}, borrower: common.Address{2}}} + s.reserve(big.NewInt(100), big.NewInt(10), 8, now, pos, "auction-lost", common.Hash{}, gasPrediction{}) + + s.handleMessage(context.Background(), []byte(`{ + "op":"auction-result", + "id":"auction-lost", + "data":{"bid":"0.0005","liquidator":"0x1111111111111111111111111111111111111111"} + }`)) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.Sign() != 0 || inFlight.gasNative.Sign() != 0 || len(inFlight.positions) != 0 { + t.Fatalf("lost auction must release reservation, bid=%s gas=%s inflight=%v", inFlight.bidNative, inFlight.gasNative, inFlight.positions) + } + + s.reserve(big.NewInt(200), big.NewInt(20), 9, now, pos, "auction-won", common.Hash{}, gasPrediction{}) + s.handleMessage(context.Background(), []byte(`{ + "op":"auction-result", + "id":"auction-won", + "data":{"bid":"0.0005","liquidator":"`+`0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1`+`"} + }`)) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.String() != "200" || inFlight.gasNative.String() != "20" { + t.Fatalf("won auction must stay reserved until liquidation result/nonce, bid=%s gas=%s", inFlight.bidNative, inFlight.gasNative) + } +} + +func TestLiquidationResultReleasesOurReservation(t *testing.T) { + s, _ := seededSolver(t) + now := time.Unix(1781243340, 0) + pos := []positionKey{{market: common.Hash{1}, borrower: common.Address{2}}} + s.reserve(big.NewInt(100), big.NewInt(10), 8, now, pos, "auction-ours", common.Hash{}, gasPrediction{}) + + s.handleMessage(context.Background(), []byte(`{ + "op":"liquidation-result", + "id":"auction-ours", + "data":{"success":true,"txHash":"","liquidator":"`+`0x7Aa367073B5c2b6Db34cF843d2f1FEbd9dC042B1`+`","error":""} + }`)) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.Sign() != 0 || inFlight.gasNative.Sign() != 0 || len(inFlight.positions) != 0 { + t.Fatalf("our liquidation result must release reservation, bid=%s gas=%s inflight=%v", inFlight.bidNative, inFlight.gasNative, inFlight.positions) + } + + s.reserve(big.NewInt(200), big.NewInt(20), 9, now, pos, "auction-other", common.Hash{}, gasPrediction{}) + s.handleMessage(context.Background(), []byte(`{ + "op":"liquidation-result", + "id":"auction-other", + "data":{"success":true,"txHash":"","liquidator":"0x1111111111111111111111111111111111111111","error":""} + }`)) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.String() != "200" || inFlight.gasNative.String() != "20" { + t.Fatalf("other solver liquidation result must not release our reservation, bid=%s gas=%s", inFlight.bidNative, inFlight.gasNative) + } +} + +// TestApplyExecutorStateRunsWithoutBalance pins that Executor-state bookkeeping still runs when only the +// callback balance read failed. A transient BalanceAt error must not strand reservations or stale nonces. +func TestApplyExecutorStateRunsWithoutBalance(t *testing.T) { + s, _ := seededSolver(t) + now := time.Unix(1781243340, 0) + + // A sent bid (nonce 8) pinning headroom, plus a stale local nonce high-water mark (5). + s.reserve(big.NewInt(100), big.NewInt(10), 8, now, []positionKey{{market: common.Hash{1}, borrower: common.Address{2}}}, "", common.Hash{}, gasPrediction{}) + s.nonces.reconcile(5) + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.Sign() == 0 || inFlight.gasNative.Sign() == 0 { + t.Fatal("precondition: the reservation should be present") + } + + // On-chain nonce advanced to 9 (the bid settled). Run with bal=nil — the balance-read-failure path. + st := ExecutorState{Nonce: big.NewInt(9), Deposit: mustBig("100000000000000000"), Locked: false} + s.applyExecutorState(st, nil, now) + + // pruneReservations ran: nonce 8 <= 9 → the reservation is freed. + if inFlight := s.inFlightSnapshot(); inFlight.bidNative.Sign() != 0 || inFlight.gasNative.Sign() != 0 { + t.Fatalf("pruneReservations must run despite a failed balance read; bid=%s gas=%s", inFlight.bidNative, inFlight.gasNative) + } + // nonces.reconcile ran: the next nonce is strictly above the on-chain 9. + if got := s.nonces.next(0); got != 10 { + t.Fatalf("nonces.reconcile must run despite a failed balance read; next nonce = %d, want 10", got) + } +} + +// TestFullAuctionLifecycle drives the whole inbound-frame flow through handleMessage: an auction frame +// produces a signed solve on the wire, then tripping the breaker via its REAL input (recorded settlement +// failures, the same path the WS liquidation-result handler feeds) makes buildBid skip "breaker" so a fresh +// auction is dropped (no solve sent). (The WS-frame → recordFailure path is covered by +// TestLiquidationResultFeedsBreaker.) +func TestFullAuctionLifecycle(t *testing.T) { + s, sgnr := seededSolver(t) + useOnchainTestMonitor(t, s) // size against the cached $1550 (the dev settlement price) + + // 1) Auction → a solve is sent on the wire. Stamp the frame as freshly emitted so the too_late gate + // doesn't drop the captured fixture's long-past emit time. + fresh := decodeAuction(t) + fresh.Timestamp = time.Now().UnixMilli() + setSnapshotBlockTime(t, s, fresh.Timestamp) + s.handleMessage(context.Background(), marshal(fresh)) + frame := drainSend(s) + if frame == nil { + t.Fatal("expected a solve to be sent for a liquidatable auction") + } + var solve SolveMessage + if err := json.Unmarshal(frame, &solve); err != nil { + t.Fatal(err) + } + if solve.Op != "solve" || solve.ID != "6382e936-c915-496a-bb3e-fa3b4ccc3a8d" || len(solve.Data.Borrowers) != 2 { + t.Fatalf("bad solve: %+v", solve.Data) + } + // The signature recovers to our signer (full sign path through handleMessage). + if got := recoverSolveSigner(t, s, solve.Data); got != sgnr.addr { + t.Fatalf("solve signature does not recover to signer: got %s", got) + } + + // 2) Trip the breaker through its REAL input — recorded settlement failures (maxFailures=3 within the + // window), the same recordFailure path the WS liquidation-result handler feeds. Record at wall-clock now, + // since the hot path (handleAuction → buildBid) evaluates the breaker with time.Now. After this, tripped. + now := time.Now() + for i := 0; i < 3; i++ { + s.breaker.recordFailure(now) + } + if tripped, _ := s.breaker.tripped(now); !tripped { + t.Fatal("breaker should be tripped after 3 recorded failures within the window") + } + // buildBid (evaluated at the same wall clock as the hot path) must short-circuit to skip "breaker". + if d := s.buildBid(decodeAuction(t), time.Now); d.skip != "breaker" { + t.Fatalf("tripped breaker must skip the bid, got skip %q", d.skip) + } + + // 3) A fresh auction (new id so dedup can't mask it) is dropped by the breaker — nothing sent. + a := decodeAuction(t) + a.ID = "9999aaaa-0000-1111-2222-333344445555" + a.Timestamp = time.Now().UnixMilli() + setSnapshotBlockTime(t, s, a.Timestamp) + s.handleMessage(context.Background(), marshal(a)) + if extra := drainSend(s); extra != nil { + t.Fatalf("breaker tripped — expected no solve, got one: %s", extra) + } +} + +// drainSend returns the next buffered outbound frame, or nil if none is queued. +func drainSend(s *Solver) []byte { + select { + case f := <-s.ws.send: + return f + default: + return nil + } +} + +func TestFeedAuctionDoesNotBuildLiquidationBid(t *testing.T) { + s, _ := seededSolver(t) + raw := []byte(`{ + "op":"auction","id":"feed-auction", + "timestamp":1726058300000,"durationMs":400, + "payload":{"ETH":"250000000000","BTC":"6000000000000","USDC":"99878787"} + }`) + s.handleMessage(context.Background(), raw) + if frame := drainSend(s); frame != nil { + t.Fatalf("feed auction must not produce a liquidation solve: %s", frame) + } +} + +// TestRedstoneClosedPositionNotBid proves we bid off our own tracked on-chain state, not the frame's +// pushed positions: even though the captured frame lists the borrower as deeply underwater, our cached +// position shows it fully closed (zero debt/collateral), so buildBid computes it non-liquidatable and +// does not bid. (The frame's pushed positions are ignored entirely — candidates come from snap.positions.) +func TestRedstoneClosedPositionNotBid(t *testing.T) { + s, _ := seededSolver(t) + id := common.HexToHash("0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5") + borrower := common.HexToAddress("0x629d764eC8563AFA701709B52c1a215e865632dE") + + // Build a FRESH snapshot whose tracked set is a single CLOSED position and store it (the loaded snapshot is + // immutable once stored — mutating its maps would write through the atomic). The frame still lists it as + // liquidatable, but candidates come from snap.positions. + cur := snapshotOf(t, s) + fresh := *cur + fresh.positions = map[common.Hash]map[common.Address]morpho.PositionState{ + id: {borrower: {BorrowShares: big.NewInt(0), Collateral: big.NewInt(0)}}, + } + storeSnapshot(t, s, &fresh) + + if d := s.buildBid(decodeAuction(t), auctionClock()); d.skip != "no_legs" { + t.Fatalf("a closed tracked position is not liquidatable → no_legs, got %q", d.skip) + } +} + +// TestDryRunSuppressesSend pins the OEV_DRY_RUN observe mode: a profitable auction is fully evaluated +// (counted as a would-bid via metrics.bid()) but NO solve is sent on the wire — the operator can watch the +// bot's decisions against a live feed without funding or competing. +func TestDryRunSuppressesSend(t *testing.T) { + s, _ := seededSolver(t) + s.dryRun = true + useOnchainTestMonitor(t, s) // size against the cached $1550 + + // Real metrics on a fresh registry so we can read the would-bid counter back. + reg := prometheus.NewRegistry() + m, err := newMetrics(reg) + if err != nil { + t.Fatalf("newMetrics: %v", err) + } + s.metrics = m + + a := decodeAuction(t) + a.Timestamp = time.Now().UnixMilli() // freshly emitted so the too_late gate doesn't drop it + setSnapshotBlockTime(t, s, a.Timestamp) + s.handleAuction(marshal(a)) + + if f := drainSend(s); f != nil { + t.Fatalf("dry-run must not send a solve, got %s", f) + } + if got := testutil.ToFloat64(m.bids); got != 1 { + t.Fatalf("oev_bids_total = %v, want 1 (dry-run still counts the would-bid)", got) + } +} + +// TestHandleAuctionEmptyIdDropped pins the auction identity invariant: RedStone auctions must carry an id. +// Without it we cannot safely correlate solve/result frames, so the frame is ignored before bid building. +func TestHandleAuctionEmptyIdDropped(t *testing.T) { + s, _ := seededSolver(t) + useOnchainTestMonitor(t, s) // size against the cached $1550 + + a := decodeAuction(t) + a.ID = "" // the frame carries no id + a.Timestamp = time.Now().UnixMilli() // freshly emitted so the too_late gate doesn't drop it + setSnapshotBlockTime(t, s, a.Timestamp) + + if f := drainSend(s); f != nil { + t.Fatalf("precondition: send channel should be empty, got %s", f) + } + s.handleAuction(marshal(a)) + if f := drainSend(s); f != nil { + t.Fatalf("empty-id auction must be ignored, got solve %s", f) + } +} + +// TestDedupKey pins that only RedStone's auction id is a valid dedup key. +func TestDedupKey(t *testing.T) { + withID := AuctionMessage{ID: "abc"} + if got := withID.dedupKey(); got != "id:abc" { + t.Fatalf("present id must be the key, got %q", got) + } + if got := (AuctionMessage{}).dedupKey(); got != "" { + t.Fatalf("empty id must not produce a synthetic key, got %q", got) + } +} + +// tokenA is the single loan token used by the bundling tests (the seeded adapter's loan token). +var tokenA = common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") // USDC-like, 6dp + +// scoredFor builds a minimal single-swap scoredLeg with the given borrower nonce and profit (loan units). +func scoredFor(borrowerByte byte, profit *big.Int) scoredLeg { + var b common.Address + b[19] = borrowerByte + return scoredLeg{ + bundleLeg: bundleLeg{ + LiquidationLeg: LiquidationLeg{Borrower: b, MarketId: common.Hash{}}, + expectedLoanOut: profit, + }, + profit: profit, + } +} + +func headerGasLimitForUsable(usable uint64) uint64 { + return (usable*10_000 + bundleGasLimitSafetyBps - 1) / bundleGasLimitSafetyBps +} + +func TestBundleSearchBounds(t *testing.T) { + t.Run("candidate order keeps all candidates by gross", func(t *testing.T) { + const candidates = 600 + scored := make([]scoredLeg, 0, candidates) + for i := candidates; i > 0; i-- { + scored = append(scored, scoredLeg{ + bundleLeg: bundleLeg{ + LiquidationLeg: LiquidationLeg{Borrower: common.BigToAddress(big.NewInt(int64(i)))}, + }, + profit: big.NewInt(int64(i)), + }) + } + + got := sortedScoredLegs(scored) + if len(got) != candidates { + t.Fatalf("candidate count = %d, want %d", len(got), candidates) + } + if got[0].profit.Int64() != candidates || got[len(got)-1].profit.Int64() != 1 { + t.Fatalf("candidate order wrong: first=%s last=%s", got[0].profit, got[len(got)-1].profit) + } + }) + + t.Run("depth follows usable gas", func(t *testing.T) { + if got := bundleSearchDepth(1, defaultPriceUpdateFeeds); got != 0 { + t.Fatalf("depth below fixed gas = %d, want 0", got) + } + usable := fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstAcquireLeg + gasAdditionalAcquireLeg + if got := bundleSearchDepth(headerGasLimitForUsable(usable), defaultPriceUpdateFeeds); got != 2 { + t.Fatalf("depth = %d, want 2", got) + } + }) +} + +// TestSelectBundleSingleToken exercises the flat-bid selection: every scored leg is already expected-positive +// in sizeLeg, so selectBundle ranks by gross loan profit desc, keeps adding improving gas-fit legs, sums +// grossLoan, and only skips (no_legs) when the scored set is empty. +func TestSelectBundleSingleToken(t *testing.T) { + newSolver := func(cfg *Config) *Solver { + return &Solver{cfg: cfg, log: logr.Discard()} + } + + t.Run("bundles all profitable legs into one bid, grossLoan summed", func(t *testing.T) { + s := newSolver(&Config{}) + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{{}: mustBig("100000000000")}, + } + b, skip := s.selectBundleWithGas([]scoredLeg{ + scoredFor(1, mustBig("60000000")), + scoredFor(2, mustBig("30000000")), + scoredFor(3, mustBig("9000000")), + }, gasState, redstoneExecutorMaxGasUnits, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("unexpected skip %q", skip) + } + if len(b.legs) != 3 || b.grossLoan.String() != "99000000" { // 60+30+9 + t.Fatalf("legs=%d grossLoan=%s, want 3 / 99000000", len(b.legs), b.grossLoan) + } + }) + + t.Run("header gas limit caps the group, keeping the most profitable gas-fit subset", func(t *testing.T) { + s := newSolver(&Config{}) + twoAcquireLegs := fixedGasUnits(defaultPriceUpdateFeeds) + gasFirstAcquireLeg + gasAdditionalAcquireLeg + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{{}: mustBig("100000000")}, + } + b, skip := s.selectBundleWithGas([]scoredLeg{ + scoredFor(1, mustBig("10000000")), + scoredFor(2, mustBig("30000000")), + scoredFor(3, mustBig("20000000")), + }, gasState, headerGasLimitForUsable(twoAcquireLegs), defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("unexpected skip %q", skip) + } + if len(b.legs) != 2 || b.grossLoan.String() != "50000000" { // top two: 30 + 20 + t.Fatalf("legs=%d gross=%s, want 2 / 50000000", len(b.legs), b.grossLoan) + } + }) + + t.Run("empty scored set → no_legs", func(t *testing.T) { + s := newSolver(&Config{}) + if _, skip := s.selectBundle(nil); skip != "no_legs" { + t.Fatalf("skip = %q, want no_legs", skip) + } + }) + + t.Run("equal-profit legs ordered deterministically (borrower tie-break)", func(t *testing.T) { + s := newSolver(&Config{}) + // Equal profit + zero marketId, so the deterministic tie-break is the borrower byte (ascending) — + // the same signed bundle regardless of candidate iteration order. + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{{}: mustBig("30000000")}, + } + b, skip := s.selectBundleWithGas([]scoredLeg{ + scoredFor(3, mustBig("10000000")), + scoredFor(1, mustBig("10000000")), + scoredFor(2, mustBig("10000000")), + }, gasState, redstoneExecutorMaxGasUnits, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("unexpected skip %q", skip) + } + if len(b.legs) != 3 || b.legs[0].Borrower[19] != 1 || b.legs[1].Borrower[19] != 2 || b.legs[2].Borrower[19] != 3 { + t.Fatalf("borrower order = %d,%d,%d, want 1,2,3 (deterministic tie-break)", + b.legs[0].Borrower[19], b.legs[1].Borrower[19], b.legs[2].Borrower[19]) + } + }) +} + +// TestSelectBundlePerCollateralBudget pins the shared-liquidity cap: legs seizing the same collateral can't +// jointly over-commit that collateral's cached getMaxAssets (scoredFor sets expectedLoanOut = profit), so the bundle +// won't revert with InsufficientAllocate on settlement. A leg on a different collateral is unaffected. +func TestSelectBundlePerCollateralBudget(t *testing.T) { + s := &Solver{cfg: &Config{}, log: logr.Discard()} + collA := common.HexToAddress("0x00000000000000000000000000000000000000ca") + collB := common.HexToAddress("0x00000000000000000000000000000000000000cb") + withColl := func(byteID byte, profit int64, c common.Address, maxA int64) scoredLeg { + sl := scoredFor(byteID, big.NewInt(profit)) // expectedLoanOut == profit + sl.collateral = c + sl.maxAssets = big.NewInt(maxA) + return sl + } + // collA budget 100: leg#1 (60) fits; leg#2 (60) would push it to 120>100 → skipped. collB leg#3 fits. + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{collA: big.NewInt(100), collB: big.NewInt(100)}, + } + b, skip := s.selectBundleWithGas([]scoredLeg{ + withColl(1, 60, collA, 100), + withColl(2, 60, collA, 100), + withColl(3, 10, collB, 100), + }, gasState, redstoneExecutorMaxGasUnits, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("unexpected skip %q", skip) + } + got := map[byte]bool{} + for _, l := range b.legs { + got[l.Borrower[19]] = true + } + if len(b.legs) != 2 || !got[1] || got[2] || !got[3] { + t.Fatalf("included borrowers = %v (legs=%d), want {1,3} — the over-committing same-collateral leg dropped", + got, len(b.legs)) + } + if b.grossLoan.String() != "70" { // 60 (leg#1) + 10 (leg#3); leg#2 excluded + t.Fatalf("grossLoan = %s, want 70", b.grossLoan) + } +} + +func TestSelectBundleAllowsSameMarketStaticLegs(t *testing.T) { + s := &Solver{cfg: &Config{}, log: logr.Discard()} + marketA := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + marketB := common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + withMarket := func(byteID byte, profit int64, market common.Hash) scoredLeg { + sl := scoredFor(byteID, big.NewInt(profit)) + sl.MarketId = market + return sl + } + + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{{}: big.NewInt(150)}, + } + b, skip := s.selectBundleWithGas([]scoredLeg{ + withMarket(1, 60, marketA), + withMarket(2, 50, marketA), + withMarket(3, 40, marketB), + }, gasState, redstoneExecutorMaxGasUnits, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("unexpected skip %q", skip) + } + got := map[byte]bool{} + for _, leg := range b.legs { + got[leg.Borrower[19]] = true + } + if len(b.legs) != 3 || !got[1] || !got[2] || !got[3] { + t.Fatalf("selected borrowers = %v (legs=%d), want both same-market static legs plus other market", got, len(b.legs)) + } +} + +func TestSelectBundleReplaysSameMarketSources(t *testing.T) { + s := &Solver{ + cfg: &Config{ + Sizing: SizingParams{AllowFullLiquidation: true, SwapHaircutBps: 0}, + }, + log: logr.Discard(), + } + market := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + coll := common.HexToAddress("0x00000000000000000000000000000000000000c0") + info := MarketInfo{ + Params: abiMarketParams{LoanToken: tokenA, CollateralToken: coll, Lltv: mustBig("500000000000000000")}, + State: morpho.MarketState{ + TotalSupplyAssets: mustBig("5000000000"), + TotalSupplyShares: mustBig("5000000000"), + TotalBorrowAssets: mustBig("3000000000"), + TotalBorrowShares: mustBig("3000000000"), + Lltv: mustBig("500000000000000000"), + Fee: big.NewInt(0), + BorrowRatePerSec: big.NewInt(0), + }, + } + price := mustBig("1000000000000000000000000000") + quote := newQuote("1200000000000000000000", nil) + replayable := func(byteID byte) scoredLeg { + var borrower common.Address + borrower[19] = byteID + pos := morpho.PositionState{BorrowShares: mustBig("1200000000"), Collateral: mustBig("1000000000000000000")} + cand := Candidate{MarketID: market, Borrower: borrower, Market: info, Position: pos} + sized, ok := sizeLeg(cand, price, quote, info.State.TotalBorrowAssets, s.cfg.Sizing) + if !ok { + t.Fatal("fixture should size") + } + leg := sized.leg + leg.MaxSeizeAssets = big.NewInt(1) // stale/bogus: selection must ignore and recompute from source + return scoredLeg{ + bundleLeg: bundleLeg{ + LiquidationLeg: leg, + expectedLoanOut: big.NewInt(1), + collateral: coll, + }, + profit: mustBig("999999999999999999"), + source: evalItem{cand: cand, price: price, quote: quote, accrued: info.State.TotalBorrowAssets}, + replay: true, + } + } + + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{coll: mustBig("10000000000000000000000")}, + } + b, skip := s.selectBundleWithGas([]scoredLeg{replayable(1), replayable(2)}, gasState, redstoneExecutorMaxGasUnits, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("unexpected skip %q", skip) + } + if len(b.legs) != 2 { + t.Fatalf("selected %d same-market replayed legs, want 2", len(b.legs)) + } + if b.legs[0].MaxSeizeAssets.Cmp(big.NewInt(1)) == 0 || b.legs[0].expectedLoanOut.Cmp(big.NewInt(1)) == 0 { + t.Fatalf("selected stale precomputed leg instead of replaying source: %+v", b.legs[0]) + } + if b.grossLoan.Cmp(mustBig("999999999999999999")) >= 0 { + t.Fatalf("grossLoan used stale bogus profit: %s", b.grossLoan) + } + if _, ok := morpho.ApplySeizeLiquidation(info.State, replayable(1).source.cand.Position, b.legs[0].MaxSeizeAssets, price); !ok { + t.Fatal("first replayed leg should apply to initial market state") + } +} + +func TestSelectNetBundleAvoidsGrossBestGasFalseSkip(t *testing.T) { + collHigh := common.HexToAddress("0x00000000000000000000000000000000000000aa") + collLow := common.HexToAddress("0x00000000000000000000000000000000000000bb") + withColl := func(byteID byte, profit int64, c common.Address) scoredLeg { + sl := scoredFor(byteID, big.NewInt(profit)) + sl.collateral = c + return sl + } + s := &Solver{ + cfg: &Config{ + MaxTxGasPrice: big.NewInt(1), + Sizing: SizingParams{}, + }, + log: logr.Discard(), + } + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{collLow: big.NewInt(1_000_000)}, + } + b, skip := s.selectNetBundle([]scoredLeg{ + withColl(1, 640_000, collHigh), // gross-best, but unknown route is net-negative even as a marginal leg + withColl(2, 600_000, collLow), // lower gross, acquire route clears fixed + acquire gas + }, morpho.Wad, gasState, big.NewInt(1), 0, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("lower-gross passing route should be selected, got skip %q", skip) + } + if len(b.legs) != 1 || b.legs[0].Borrower[19] != 2 { + t.Fatalf("selected borrowers = %+v, want only lower-gross acquire leg", b.legs) + } + if got := s.bundleNetNative(b, morpho.Wad, gasState, big.NewInt(1)); got.Cmp(big.NewInt(1)) < 0 { + t.Fatalf("selected bundle net = %s, want >= min margin", got) + } + + t.Run("searches past gross-only candidate window", func(t *testing.T) { + const formerGrossWindow = 512 + withAddr := func(addr common.Address, profit int64, c common.Address) scoredLeg { + return scoredLeg{ + bundleLeg: bundleLeg{ + LiquidationLeg: LiquidationLeg{Borrower: addr}, + expectedLoanOut: big.NewInt(profit), + collateral: c, + }, + profit: big.NewInt(profit), + } + } + scored := make([]scoredLeg, 0, formerGrossWindow+2) + for i := 0; i <= formerGrossWindow; i++ { + scored = append(scored, withAddr(common.BigToAddress(big.NewInt(int64(i+1))), 620_000, collHigh)) + } + wantBorrower := common.BigToAddress(big.NewInt(10_000)) + scored = append(scored, withAddr(wantBorrower, 600_000, collLow)) + + gotBundle, gotSkip := s.selectNetBundle(scored, morpho.Wad, gasState, big.NewInt(1), redstoneExecutorMaxGasUnits, defaultPriceUpdateFeeds) + if gotSkip != "" { + t.Fatalf("lower-gross passing leg after the old window should be selected, got skip %q", gotSkip) + } + if len(gotBundle.legs) != 1 || gotBundle.legs[0].Borrower != wantBorrower { + t.Fatalf("selected borrowers = %+v, want only lower-gross acquire leg past gross-only window", gotBundle.legs) + } + }) +} + +func TestSelectNetBundleAllowsSameMarketStaticLegs(t *testing.T) { + market := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + collA := common.HexToAddress("0x00000000000000000000000000000000000000a1") + collB := common.HexToAddress("0x00000000000000000000000000000000000000b2") + withMarket := func(byteID byte, profit int64, c common.Address) scoredLeg { + sl := scoredFor(byteID, big.NewInt(profit)) + sl.MarketId = market + sl.collateral = c + return sl + } + s := &Solver{ + cfg: &Config{ + MaxTxGasPrice: big.NewInt(1), + Sizing: SizingParams{}, + }, + log: logr.Discard(), + } + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{ + collA: big.NewInt(700_000), + collB: big.NewInt(700_000), + }, + } + b, skip := s.selectNetBundle([]scoredLeg{ + withMarket(1, 700_000, collA), + withMarket(2, 700_000, collB), + }, morpho.Wad, gasState, big.NewInt(1), 0, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("unexpected skip %q", skip) + } + if len(b.legs) != 2 { + t.Fatalf("selected %d same-market legs, want 2", len(b.legs)) + } +} + +func TestSelectNetBundleSharesBaseGasAcrossLegs(t *testing.T) { + collA := common.HexToAddress("0x00000000000000000000000000000000000000a1") + collB := common.HexToAddress("0x00000000000000000000000000000000000000b2") + withColl := func(byteID byte, profit int64, c common.Address) scoredLeg { + sl := scoredFor(byteID, big.NewInt(profit)) + sl.collateral = c + return sl + } + s := &Solver{ + cfg: &Config{ + MaxTxGasPrice: big.NewInt(1), + Sizing: SizingParams{}, + }, + log: logr.Discard(), + } + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{ + collA: big.NewInt(590_000), + collB: big.NewInt(590_000), + }, + } + b, skip := s.selectNetBundle([]scoredLeg{ + withColl(1, 590_000, collA), // singleton cannot cover fixed + acquire gas + withColl(2, 590_000, collB), // together shares fixed gas and clears the bundle gate + }, morpho.Wad, gasState, big.NewInt(1), 0, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("combined bundle should share base gas and pass, got skip %q", skip) + } + if len(b.legs) != 2 { + t.Fatalf("selected %d legs, want 2", len(b.legs)) + } + if got := s.bundleNetNative(b, morpho.Wad, gasState, big.NewInt(1)); got.Cmp(big.NewInt(1)) < 0 { + t.Fatalf("selected bundle net = %s, want >= min margin", got) + } +} + +func TestSelectNetBundleSearchesPastGreedyBudgetTrap(t *testing.T) { + coll := common.HexToAddress("0x00000000000000000000000000000000000000cc") + withColl := func(byteID byte, profit int64) scoredLeg { + sl := scoredFor(byteID, big.NewInt(profit)) + sl.collateral = coll + sl.maxAssets = big.NewInt(1_240_000) + return sl + } + s := &Solver{ + cfg: &Config{ + BidWei: big.NewInt(0), + MaxTxGasPrice: big.NewInt(1), + Sizing: SizingParams{}, + }, + log: logr.Discard(), + } + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{coll: big.NewInt(1_400_000)}, + } + b, skip := s.selectNetBundle([]scoredLeg{ + withColl(1, 700_000), // gross-best consumes too much shared budget to pair with either 500k leg + withColl(2, 620_000), + withColl(3, 620_000), + }, morpho.Wad, gasState, big.NewInt(1), 0, defaultPriceUpdateFeeds) + if skip != "" { + t.Fatalf("expected lower-gross pair to pass, got skip %q", skip) + } + got := map[byte]bool{} + for _, leg := range b.legs { + got[leg.Borrower[19]] = true + } + if len(b.legs) != 2 || got[1] || !got[2] || !got[3] { + t.Fatalf("selected borrowers = %v (legs=%d), want {2,3}", got, len(b.legs)) + } + if gotNet := s.bundleNetNative(b, morpho.Wad, gasState, big.NewInt(1)); gotNet.Cmp(big.NewInt(1)) < 0 { + t.Fatalf("selected bundle net = %s, want >= min margin", gotNet) + } +} + +func TestSearchBundleDoesNotRequireMonotonicScore(t *testing.T) { + s := &Solver{cfg: &Config{}, log: logr.Discard()} + legs := []scoredLeg{ + scoredFor(1, big.NewInt(1)), + scoredFor(2, big.NewInt(1)), + } + scoreFn := func(b chosenBundle) *big.Int { + if len(b.legs) < 2 { + return big.NewInt(-1) + } + return big.NewInt(10) + } + + gasState := &gasPredictorState{ + FreeAssets: big.NewInt(0), + Withdrawable: big.NewInt(0), + Acquire: map[common.Address]*big.Int{{}: big.NewInt(2)}, + } + best, ok := s.searchBundle(legs, gasState, redstoneExecutorMaxGasUnits, defaultPriceUpdateFeeds, scoreFn) + if !ok { + t.Fatal("search should keep temporary negative states when a deeper bundle can become profitable") + } + if len(best.bundle.legs) != 2 { + t.Fatalf("selected %d legs, want 2", len(best.bundle.legs)) + } +} + +func TestBundleBidNativeUsesProfitShareFloor(t *testing.T) { + b := chosenBundle{grossLoan: big.NewInt(1_000)} + s := &Solver{ + cfg: &Config{ + BidWei: big.NewInt(100), + TotalBundleProfitBps: 2_000, + }, + log: logr.Discard(), + } + if got := s.bundleBidNative(b, morpho.Wad); got.Cmp(big.NewInt(200)) != 0 { + t.Fatalf("bid = %s, want 20%% of gross native", got) + } + s.cfg.TotalBundleProfitBps = 500 + if got := s.bundleBidNative(b, morpho.Wad); got.Cmp(big.NewInt(100)) != 0 { + t.Fatalf("bid = %s, want minimal bid floor", got) + } +} + +// TestBuildBidStaleEpoch pins the fail-closed epoch gate: a non-empty snapshot must be block-tagged and +// close enough to the auction timestamp that a stuck API cache cannot keep bidding indefinitely. +func TestBuildBidStaleEpoch(t *testing.T) { + a := decodeAuction(t) + now := auctionClock() + s, _ := seededSolver(t) + + fresh := *snapshotOf(t, s) + fresh.block, fresh.blockTime = 0, 0 + storeSnapshot(t, s, &fresh) + if d := s.buildBid(a, now); d.skip != skipStaleEpoch { + t.Fatalf("untagged snapshot must skip %s, got %q", skipStaleEpoch, d.skip) + } + + fresh.block, fresh.blockTime = 123, uint64(a.Timestamp/1000) + storeSnapshot(t, s, &fresh) + if d := s.buildBid(a, now); d.skip != "" { + t.Fatalf("current tagged snapshot should bid, got skip %q", d.skip) + } + + fresh.blockTime = uint64(a.Timestamp/1000) - uint64(snapshotMaxAuctionLag/time.Second) - 1 + storeSnapshot(t, s, &fresh) + if d := s.buildBid(a, now); d.skip != skipStaleEpoch { + t.Fatalf("old tagged snapshot must skip %s, got %q", skipStaleEpoch, d.skip) + } +} + +func TestLegResultCode(t *testing.T) { + code := new(big.Int).Lsh(big.NewInt(0xdeadbeef), 224) + code.Or(code, new(big.Int).Lsh(big.NewInt(42), 16)) + code.Or(code, new(big.Int).Lsh(big.NewInt(3), 8)) + code.Or(code, big.NewInt(7)) + + got := legResultCode(code) + if got.index != 42 || got.status != 3 || got.reason != 7 || got.selector != "0xdeadbeef" { + t.Fatalf("decoded code = (%d,%d,%d,%q), want (42,3,7,0xdeadbeef)", got.index, got.status, got.reason, got.selector) + } +} + +// TestSeenAuctions pins the bounded de-dup: first sight is new, repeats are seen, and the oldest id is +// evicted past cap (so a long-evicted id reads as new again). +func TestSeenAuctions(t *testing.T) { + s := newSeenAuctions(2) + if s.seen("a") { + t.Fatal("first sight of a should be new") + } + if !s.seen("a") { + t.Fatal("repeat of a should be seen") + } + _ = s.seen("b") // [a, b] + if s.seen("c") { // cap 2 → evict a → [b, c] + t.Fatal("c is new") + } + if s.seen("a") { + t.Fatal("a was evicted past cap; should read as new again") + } +} + +// TestLiquidationResultFeedsBreaker pins the WS-driven failure breaker: a liquidation-result frame for OUR +// callback with success:false records exactly one breaker failure (and trips at maxFailures); a success:true +// frame, and a failure for ANOTHER liquidator, record none. This is the sole breaker-failure feed now that +// the on-chain event scan is gone. +func TestLiquidationResultFeedsBreaker(t *testing.T) { + frame := func(liquidator string, success bool) []byte { + return marshal(LiquidationResult{ + Op: "liquidation-result", ID: "a", + Data: LiquidationResultData{Success: success, Liquidator: liquidator, TxHash: "0x1"}, + }) + } + now := time.Now() + + t.Run("success:false for our callback records a failure and trips at maxFailures", func(t *testing.T) { + s, _ := seededSolver(t) // breaker maxFailures = 3 + for i := 0; i < 3; i++ { + s.handleMessage(context.Background(), frame(s.cfg.Callback.Hex(), false)) + } + if tripped, _ := s.breaker.tripped(now); !tripped { + t.Fatal("3 failed liquidation-result frames for our callback must trip the breaker") + } + }) + + t.Run("success:true records none", func(t *testing.T) { + s, _ := seededSolver(t) + for i := 0; i < 5; i++ { + s.handleMessage(context.Background(), frame(s.cfg.Callback.Hex(), true)) + } + if tripped, _ := s.breaker.tripped(now); tripped { + t.Fatal("successful liquidation-result frames must not trip the breaker") + } + }) + + t.Run("a failure for another liquidator records none", func(t *testing.T) { + s, _ := seededSolver(t) + other := common.HexToAddress("0x2222222222222222222222222222222222222222").Hex() + for i := 0; i < 5; i++ { + s.handleMessage(context.Background(), frame(other, false)) + } + if tripped, _ := s.breaker.tripped(now); tripped { + t.Fatal("another solver's failed liquidations must not trip our breaker") + } + }) +} + +// TestTooLate pins that the auction window is measured from the auctioneer emit time. A late-delivered +// frame is dropped; a bogus/future emit timestamp falls back to local elapsed time. +func TestTooLate(t *testing.T) { + now := time.Unix(1781243340, 0) + const timeoutMs = 500 + emit := func(deltaMs int64) int64 { return now.UnixMilli() + deltaMs } + + cases := []struct { + name string + emitMs int64 + start time.Time // local frame-receipt time + wantBad bool + }{ + // Emitted (timeoutMs + slack) ago → past the deadline since emit, even though we just received it. + {"late-delivered frame (emit + slack ago)", emit(-(timeoutMs + 100)), now, true}, + // Emitted exactly at the window edge → not yet too late (strictly greater trips it). + {"emit exactly at the window edge", emit(-timeoutMs), now, false}, + // Fresh frame, emitted just now and just received → in budget. + {"fresh frame", emit(-10), now, false}, + // Emit unset (0): trust the local clock — a slow local path (start long ago) is too late. + {"no emit ts, slow local path", 0, now.Add(-time.Duration(timeoutMs+100) * time.Millisecond), true}, + {"no emit ts, fast local path", 0, now.Add(-10 * time.Millisecond), false}, + // Forward emit timestamp (clock skew / bogus): fall back to the local clock, don't trust emit. + {"future emit ts falls back to local (fast)", emit(5000), now.Add(-10 * time.Millisecond), false}, + {"future emit ts falls back to local (slow)", emit(5000), now.Add(-time.Duration(timeoutMs+100) * time.Millisecond), true}, + } + for _, c := range cases { + if got := tooLate(c.emitMs, timeoutMs, c.start, now); got != c.wantBad { + t.Errorf("%s: tooLate(emit=%d, start=%v) = %v, want %v", c.name, c.emitMs, c.start, got, c.wantBad) + } + } +} + +func TestBuildBidSkips(t *testing.T) { + clock := auctionClock() + healthy := mustBig("100000000000000000000000000000000000000000000") + + stateWith := func(s *Solver, deposit, callback *big.Int, locked bool) cachedState { + st, _ := s.state.load() + st.Exec = ExecutorState{Nonce: big.NewInt(7), Deposit: deposit, Locked: locked} + st.CallbackNative = callback + return st + } + tests := []struct { + name string + mut func(*Solver) + priceOverride *big.Int // if set, re-prices the auction oracle so the position is healthy + want string + }{ + {name: "breaker", mut: func(s *Solver) { s.breaker.blacklist() }, want: "breaker"}, + {name: "signer_locked", mut: func(s *Solver) { + s.state.store(stateWith(s, mustBig("100000000000000000"), mustBig("1000000000000000000"), true)) + }, want: "signer_locked"}, + {name: "deposit_low", mut: func(s *Solver) { + s.state.store(stateWith(s, big.NewInt(1), mustBig("1000000000000000000"), false)) // below MIN_DEPOSIT (1e13) + }, want: "deposit_low"}, + {name: "callback_balance", mut: func(s *Solver) { + s.state.store(stateWith(s, mustBig("100000000000000000"), big.NewInt(1), false)) + }, want: "callback_balance"}, + {name: "no_legs_when_healthy", priceOverride: healthy, want: "no_legs"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + frame := decodeAuction(t) + s, _ := seededSolver(t) + if tc.mut != nil { + tc.mut(s) + } + if tc.priceOverride != nil { + frame.Payload.Prices = map[string]string{"0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D": tc.priceOverride.String()} + } + if d := s.buildBid(frame, clock); d.skip != tc.want { + t.Fatalf("skip = %q, want %q", d.skip, tc.want) + } + }) + } +} diff --git a/internal/solvers/redstoneoev/testflags.go b/internal/solvers/redstoneoev/testflags.go new file mode 100644 index 00000000..36e279bd --- /dev/null +++ b/internal/solvers/redstoneoev/testflags.go @@ -0,0 +1,43 @@ +package redstoneoev + +// testflags.go reads dev/test knobs from env vars at point of use. Production leaves them unset. +// Malformed values fail closed (error) so a typo can't silently widen scope. + +import ( + "os" + "strings" + + "github.com/go-errors/errors" +) + +const ( + envOnchainPrice = "OEV_ONCHAIN_PRICE_FOR_TEST" // "true"/"1" → dev-testbed on-chain price basis + envTestMonitor = "OEV_TEST_MONITOR" // "true"/"1" → use Sepolia harness on-chain Morpho monitor + envDryRun = "OEV_DRY_RUN" // "true"/"1" → observe mode: sign + log would-bids, never send +) + +// onchainPriceForTestEnv reports whether OEV_ONCHAIN_PRICE_FOR_TEST selects the dev-testbed on-chain +// price basis ("true"/"1", case-insensitive); unset/false → false; a malformed value → error. +func onchainPriceForTestEnv() (bool, error) { return envBool(envOnchainPrice) } + +// testMonitorEnv reports whether OEV_TEST_MONITOR selects the Sepolia harness monitor that reads Morpho +// market/position state on-chain for configured test seeds. +func testMonitorEnv() (bool, error) { return envBool(envTestMonitor) } + +// dryRunEnv reports whether OEV_DRY_RUN puts the bot in observe mode — sign + log each would-bid but never +// send it ("true"/"1", case-insensitive); unset/false → false; a malformed value → error. +func dryRunEnv() (bool, error) { return envBool(envDryRun) } + +// envBool reads a boolean env flag, failing closed: unset/""/"false"/"0" → false; "true"/"1" → true +// (case-insensitive, trimmed); any other SET value → error — so a typo (e.g. OEV_DRY_RUN=ture) can't +// silently flip the bot into live bidding instead of the intended observe mode. +func envBool(key string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { + case "", "false", "0": + return false, nil + case "true", "1": + return true, nil + default: + return false, errors.Errorf("%s: invalid bool %q (want true/1 or false/0)", key, os.Getenv(key)) + } +} diff --git a/internal/solvers/redstoneoev/testhelpers_test.go b/internal/solvers/redstoneoev/testhelpers_test.go new file mode 100644 index 00000000..9cd77b27 --- /dev/null +++ b/internal/solvers/redstoneoev/testhelpers_test.go @@ -0,0 +1,36 @@ +package redstoneoev + +import ( + "math/big" + + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +// mustBig parses a base-10 big.Int, panicking on malformed input — a test-only literal helper. +func mustBig(s string) *big.Int { + n, ok := new(big.Int).SetString(s, 10) + if !ok { + panic("bad big int: " + s) + } + return n +} + +// goldenMarket is the live Sepolia test market state read on-chain (docs/OEV-PLAN.md §6.5/§6.7): +// TLOAN(6dp)/TCOL(18dp), lltv 0.86, IRM borrowRateView = 182418302 wad/sec, lastUpdate 1780059204. +func goldenMarket() morpho.MarketState { + return morpho.MarketState{ + TotalSupplyAssets: big.NewInt(100000000068), + TotalSupplyShares: mustBig("100000000000000000"), + TotalBorrowAssets: big.NewInt(4730000068), + TotalBorrowShares: mustBig("4729999932892591"), + LastUpdate: 1780059204, + Fee: big.NewInt(0), + Lltv: mustBig("860000000000000000"), + BorrowRatePerSec: big.NewInt(182418302), + } +} + +// goldenBorrower is 0x629d… — 1.0 TCOL collateral, borrowShares 1685600000000000. +func goldenBorrower() morpho.PositionState { + return morpho.PositionState{BorrowShares: mustBig("1685600000000000"), Collateral: mustBig("1000000000000000000")} +} diff --git a/internal/solvers/redstoneoev/testmonitor.go b/internal/solvers/redstoneoev/testmonitor.go new file mode 100644 index 00000000..16385251 --- /dev/null +++ b/internal/solvers/redstoneoev/testmonitor.go @@ -0,0 +1,307 @@ +package redstoneoev + +import ( + "context" + "math/big" + "os" + "slices" + "strings" + "sync/atomic" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/chain" + "github.com/symbioticfi/vault-solver/internal/morpho" +) + +const ( + envTestMarkets = "OEV_TEST_MARKETS" + envTestPositions = "OEV_TEST_POSITIONS" +) + +// testMonitor is the Sepolia harness source. It enumerates nothing: markets/borrowers are supplied by the +// testbed manifest env, then market state and positions are read from the callback's Morpho contract. +type testMonitor struct { + reader *reader + log logr.Logger + callback common.Address + adapter common.Address + markets []common.Hash + positions []common.Address + monitorPoll time.Duration + + snap atomic.Pointer[snapshot] +} + +func newTestMonitor(r *reader, log logr.Logger, cfg *Config) (*testMonitor, error) { + markets, err := parseHashListEnv(envTestMarkets) + if err != nil { + return nil, err + } + if len(markets) == 0 { + return nil, errors.Errorf("%s: set at least one market id for %s", envTestMonitor, envTestMarkets) + } + positions, err := parseAddressListEnv(envTestPositions) + if err != nil { + return nil, err + } + if len(positions) == 0 { + return nil, errors.Errorf("%s: set at least one borrower for %s", envTestMonitor, envTestPositions) + } + m := &testMonitor{ + reader: r, + log: log.WithName("testMonitor"), + callback: cfg.Callback, + adapter: cfg.Adapter, + markets: markets, + positions: positions, + monitorPoll: cfg.MonitorPoll, + } + m.snap.Store(&snapshot{ + markets: map[common.Hash]MarketInfo{}, + prices: map[common.Hash]*big.Int{}, + quotes: map[common.Hash]AdapterQuote{}, + positions: map[common.Hash]map[common.Address]morpho.PositionState{}, + }) + return m, nil +} + +func (m *testMonitor) run(ctx context.Context) { + tick := time.NewTicker(m.monitorPoll) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + m.refresh(ctx) + } + } +} + +func (m *testMonitor) name() string { return "test" } + +func (m *testMonitor) refresh(ctx context.Context) { + header, err := m.reader.chain.HeaderByNumber(ctx, nil) + if err != nil || header == nil || header.Number == nil || !header.Number.IsUint64() { + m.log.Error(err, "test monitor header read failed; keeping cache") + return + } + morphoAddr, err := m.reader.callAddress(ctx, m.callback, callbackB.PackMORPHO(), callbackB.UnpackMORPHO) + if err != nil || morphoAddr == (common.Address{}) { + m.log.Error(err, "test monitor MORPHO read failed; keeping cache") + return + } + adapter, err := m.reader.readAdapterSnapshot(ctx, m.callback, m.adapter) + if err != nil { + m.log.Error(err, "test monitor adapter state unreadable; keeping cache") + return + } + params, err := m.reader.ResolveParams(ctx, morphoAddr, m.markets) + if err != nil { + m.log.Error(err, "test monitor market params read failed; keeping cache") + return + } + served := verifyAdapterPair(params, adapter.loan, adapter.redeemable) + want := make(map[common.Hash]abiMarketParams, len(served)) + serve := make(map[common.Hash]bool, len(served)) + for _, id := range served { + want[id] = params[id] + serve[id] = adapter.filler + } + if len(want) == 0 { + m.log.V(1).Info("test monitor found no adapter-served markets") + return + } + markets, prices, err := m.readMarkets(ctx, morphoAddr, want) + if err != nil { + m.log.Error(err, "test monitor market state read failed; keeping cache") + return + } + quotes, err := m.reader.ReadAdapterQuotes(ctx, want, m.adapter, serve) + if err != nil { + m.log.Error(err, "test monitor adapter quote read failed; keeping cache") + return + } + positions, err := m.readPositions(ctx, morphoAddr, markets) + if err != nil { + m.log.Error(err, "test monitor positions read failed; keeping cache") + return + } + end, err := m.reader.chain.HeaderByNumber(ctx, nil) + if err != nil || end == nil || end.Number == nil || !end.Number.IsUint64() { + m.log.Error(err, "test monitor end-header read failed; keeping cache") + return + } + if end.Number.Uint64() != header.Number.Uint64() { + m.log.V(1).Info("test monitor refresh crossed block boundary; keeping cache", + "startBlock", header.Number.Uint64(), "endBlock", end.Number.Uint64()) + return + } + m.snap.Store(&snapshot{ + markets: markets, prices: prices, quotes: compactQuotes(quotes), positions: positions, + block: header.Number.Uint64(), blockTime: header.Time, updatedAt: time.Now(), + }) +} + +func (m *testMonitor) readMarkets(ctx context.Context, morphoAddr common.Address, params map[common.Hash]abiMarketParams) (map[common.Hash]MarketInfo, map[common.Hash]*big.Int, error) { + ids := sortedMarketIDs(params) + calls := make([]chain.Call, 0, len(ids)*2) + for _, id := range ids { + p := params[id] + calls = append(calls, + chain.Call{Target: morphoAddr, AllowFailure: true, Data: morphoB.PackMarket(id)}, + chain.Call{Target: p.Oracle, AllowFailure: true, Data: oracleB.PackPrice()}, + ) + } + res, err := m.reader.chain.Multicall(ctx, calls) + if err != nil { + return nil, nil, err + } + if len(res) != len(calls) { + return nil, nil, errors.Errorf("testMonitor markets: got %d results, want %d", len(res), len(calls)) + } + markets := make(map[common.Hash]MarketInfo, len(ids)) + prices := make(map[common.Hash]*big.Int, len(ids)) + for i, id := range ids { + marketRes := res[i*2] + priceRes := res[i*2+1] + if !marketRes.Success || !priceRes.Success { + continue + } + state, ok := decodeTestMarketState(marketRes.ReturnData, params[id]) + if !ok { + continue + } + price, err := oracleB.UnpackPrice(priceRes.ReturnData) + if err != nil || price == nil || price.Sign() <= 0 { + continue + } + markets[id] = MarketInfo{Params: params[id], State: state} + prices[id] = price + } + return markets, prices, nil +} + +func (m *testMonitor) readPositions(ctx context.Context, morphoAddr common.Address, markets map[common.Hash]MarketInfo) (map[common.Hash]map[common.Address]morpho.PositionState, error) { + ids := sortedMarketIDsFromInfo(markets) + calls := make([]chain.Call, 0, len(ids)*len(m.positions)) + type slot struct { + id common.Hash + borrower common.Address + } + slots := make([]slot, 0, cap(calls)) + for _, id := range ids { + for _, borrower := range m.positions { + slots = append(slots, slot{id: id, borrower: borrower}) + calls = append(calls, chain.Call{Target: morphoAddr, AllowFailure: true, Data: morphoB.PackPosition(id, borrower)}) + } + } + res, err := m.reader.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if len(res) != len(calls) { + return nil, errors.Errorf("testMonitor positions: got %d results, want %d", len(res), len(calls)) + } + out := make(map[common.Hash]map[common.Address]morpho.PositionState, len(ids)) + for i, s := range slots { + if !res[i].Success { + continue + } + p, err := morphoB.UnpackPosition(res[i].ReturnData) + if err != nil || p.BorrowShares == nil || p.Collateral == nil { + continue + } + if out[s.id] == nil { + out[s.id] = make(map[common.Address]morpho.PositionState) + } + out[s.id][s.borrower] = morpho.PositionState{BorrowShares: p.BorrowShares, Collateral: p.Collateral} + } + return out, nil +} + +func decodeTestMarketState(data []byte, params abiMarketParams) (morpho.MarketState, bool) { + out, err := morphoB.UnpackMarket(data) + if err != nil || out.TotalSupplyAssets == nil || out.TotalSupplyShares == nil || + out.TotalBorrowAssets == nil || out.TotalBorrowShares == nil || out.LastUpdate == nil || + out.Fee == nil || params.Lltv == nil || !out.LastUpdate.IsUint64() { + return morpho.MarketState{}, false + } + return morpho.MarketState{ + TotalSupplyAssets: out.TotalSupplyAssets, + TotalSupplyShares: out.TotalSupplyShares, + TotalBorrowAssets: out.TotalBorrowAssets, + TotalBorrowShares: out.TotalBorrowShares, + LastUpdate: out.LastUpdate.Uint64(), + Fee: out.Fee, + Lltv: params.Lltv, + BorrowRatePerSec: big.NewInt(0), + }, true +} + +func sortedMarketIDs(params map[common.Hash]abiMarketParams) []common.Hash { + ids := make([]common.Hash, 0, len(params)) + for id := range params { + ids = append(ids, id) + } + slices.SortFunc(ids, common.Hash.Cmp) + return ids +} + +func sortedMarketIDsFromInfo(markets map[common.Hash]MarketInfo) []common.Hash { + ids := make([]common.Hash, 0, len(markets)) + for id := range markets { + ids = append(ids, id) + } + slices.SortFunc(ids, common.Hash.Cmp) + return ids +} + +func (m *testMonitor) candidates(auction AuctionMessage, nowTs uint64) []evalItem { + return candidatesFromCachedPrices(m.snapshot(), nowTs) +} + +func (m *testMonitor) snapshot() *snapshot { + return m.snap.Load() +} + +func parseHashListEnv(key string) ([]common.Hash, error) { + parts := splitEnvList(os.Getenv(key)) + out := make([]common.Hash, 0, len(parts)) + for _, p := range parts { + if !common.IsHexHash(p) { + return nil, errors.Errorf("%s: invalid hash %q", key, p) + } + out = append(out, common.HexToHash(p)) + } + return out, nil +} + +func parseAddressListEnv(key string) ([]common.Address, error) { + parts := splitEnvList(os.Getenv(key)) + out := make([]common.Address, 0, len(parts)) + for _, p := range parts { + if !common.IsHexAddress(p) { + return nil, errors.Errorf("%s: invalid address %q", key, p) + } + out = append(out, common.HexToAddress(p)) + } + return out, nil +} + +func splitEnvList(v string) []string { + fields := strings.FieldsFunc(v, func(r rune) bool { + return r == ',' || r == '\n' || r == '\t' || r == ' ' + }) + out := make([]string, 0, len(fields)) + for _, f := range fields { + if f = strings.TrimSpace(f); f != "" { + out = append(out, f) + } + } + return out +} diff --git a/internal/solvers/redstoneoev/testsigner_test.go b/internal/solvers/redstoneoev/testsigner_test.go new file mode 100644 index 00000000..37c49aa5 --- /dev/null +++ b/internal/solvers/redstoneoev/testsigner_test.go @@ -0,0 +1,72 @@ +package redstoneoev + +import ( + "crypto/ecdsa" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/symbioticfi/vault-solver/internal/parse" +) + +// testSigner is a minimal signer.Signer backed by an in-memory key, for tests. SignHash returns the +// 65-byte [R||S||V] form with V in {27,28}, matching the production signer contract. +type testSigner struct { + key *ecdsa.PrivateKey + addr common.Address +} + +func (s *testSigner) Address() common.Address { return s.addr } + +func (s *testSigner) SignHash(hash common.Hash) ([]byte, error) { + sig, err := crypto.Sign(hash.Bytes(), s.key) + if err != nil { + return nil, err + } + if sig[64] < 27 { + sig[64] += 27 + } + return sig, nil +} + +func (s *testSigner) SignTx(tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) { + return types.SignTx(tx, types.LatestSignerForChainID(chainID), s.key) +} + +// recoverSolveSigner recovers the EXECUTOR_V6 signer from a solve's signature over its operationData/bid/ +// nonce — the full on-the-wire verification the Executor performs. Shared by the buildBid / lifecycle / WS +// tests, which all assert the recovered address equals the bot's signer. +func recoverSolveSigner(t *testing.T, s *Solver, d SolveData) common.Address { + t.Helper() + opData, err := hexutil.Decode(d.OperationData) + if err != nil { + t.Fatalf("decode operationData: %v", err) + } + bid, err := parse.EthToWei(d.Bid, "bid") + if err != nil { + t.Fatalf("parse bid: %v", err) + } + digest, err := ExecutorV6Digest(s.chainID, s.cfg.Callback, crypto.Keccak256Hash(opData), bid, mustBig(d.Nonce), mustBig(d.MaxTxGasPrice)) + if err != nil { + t.Fatalf("digest: %v", err) + } + sig, err := hexutil.Decode(d.LiquidationSig) + if err != nil { + t.Fatalf("decode sig: %v", err) + } + if len(sig) != 65 { + t.Fatalf("sig len = %d, want 65", len(sig)) + } + if sig[64] >= 27 { + sig[64] -= 27 // SigToPub wants V in {0,1} + } + pub, err := crypto.SigToPub(ethSignedMessageHash(digest).Bytes(), sig) + if err != nil { + t.Fatalf("recover: %v", err) + } + return crypto.PubkeyToAddress(*pub) +} diff --git a/internal/solvers/redstoneoev/wsclient.go b/internal/solvers/redstoneoev/wsclient.go new file mode 100644 index 00000000..8629bcbe --- /dev/null +++ b/internal/solvers/redstoneoev/wsclient.go @@ -0,0 +1,246 @@ +package redstoneoev + +import ( + "context" + "math/rand" + "net/http" + "sync" + "time" + + "github.com/go-errors/errors" + "github.com/go-logr/logr" + "github.com/gorilla/websocket" +) + +// wsConfig tunes the resilient WS client. Timings default to the RedStone example client's values +// (docs/OEV-PLAN.md §6.1): server pings ~120s, connections forced-closed ~8h (rotate at ~7h). +type wsConfig struct { + URL string + APIKey string + Topics []string + + HandshakeTimeout time.Duration + PingInterval time.Duration + MsgTimeout time.Duration // reconnect if no inbound frame/pong within this + RotateAfter time.Duration // proactively reconnect before the server's ~8h cutoff + BackoffInitial time.Duration + BackoffMax time.Duration +} + +func (c *wsConfig) withDefaults() { + setDur(&c.HandshakeTimeout, 10*time.Second) + setDur(&c.PingInterval, 20*time.Second) + setDur(&c.MsgTimeout, 30*time.Second) + setDur(&c.RotateAfter, 7*time.Hour) + setDur(&c.BackoffInitial, 500*time.Millisecond) + setDur(&c.BackoffMax, 30*time.Second) +} + +// wsClient is a reconnecting WebSocket client: it (re)connects with the x-api-key header, re-sends +// the topic subscriptions after every connect, pings to keep the link alive, rotates before the +// server's cutoff, and delivers inbound frames to onMessage. Outbound solve frames go through Send, +// which is safe for the hot path (non-blocking; drops + returns false if the buffer is full) and +// discards stale buffered solves on every (re)connect. +type wsClient struct { + cfg wsConfig + log logr.Logger + onMsg func(context.Context, []byte) + dialer *websocket.Dialer + header http.Header + send chan []byte +} + +func newWSClient(cfg wsConfig, log logr.Logger, onMsg func(context.Context, []byte)) *wsClient { + cfg.withDefaults() + h := http.Header{} + h.Set("x-api-key", cfg.APIKey) + return &wsClient{ + cfg: cfg, + log: log.WithName("ws"), + onMsg: onMsg, + dialer: &websocket.Dialer{HandshakeTimeout: cfg.HandshakeTimeout}, + header: h, + send: make(chan []byte, 8), + } +} + +// Send enqueues an outbound frame (e.g. a solve) and reports whether it was accepted. Non-blocking: if +// the buffer is full the frame is dropped (returns false) so the hot path never blocks. A solve only +// lives ~400ms (one auction), so stale frames are dropped on reconnect (flushSendQueue) rather than +// written late to a closed auction — callers must treat false as "not sent" (don't count it as a bid). +func (w *wsClient) Send(frame []byte) bool { + select { + case w.send <- frame: + return true + default: + w.log.Info("ws send dropped (buffer full)") + return false + } +} + +// Run connects and serves until ctx is cancelled, reconnecting with jittered exponential backoff. +func (w *wsClient) Run(ctx context.Context) error { + backoff := w.cfg.BackoffInitial + for { + start := time.Now() + err := w.serveOnce(ctx) + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + w.log.Error(err, "ws connection ended; reconnecting") + } + // Reset backoff if the last connection was healthy for a while. + if time.Since(start) > w.cfg.BackoffMax { + backoff = w.cfg.BackoffInitial + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff + jitter()): + } + if backoff *= 2; backoff > w.cfg.BackoffMax { + backoff = w.cfg.BackoffMax + } + } +} + +// serveOnce dials, subscribes, and runs the read/write pumps until the connection drops, rotates, or +// ctx is cancelled. +func (w *wsClient) serveOnce(ctx context.Context) error { + conn, resp, err := w.dialer.DialContext(ctx, w.cfg.URL, w.header) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() // handshake response body; not used + } + if err != nil { + return errors.Errorf("dial %s: %w", w.cfg.URL, err) + } + w.log.Info("connected", "url", w.cfg.URL) + + // Drop any solves buffered during the downtime: a solve targets one auction (~400ms life), so + // anything still queued after a reconnect is stale. Start each connection with a clean send queue. + flushSendQueue(w.send) + + connCtx, cancel := context.WithCancel(ctx) + + // (Re)subscribe to all topics. + for _, topic := range w.cfg.Topics { + if werr := conn.WriteMessage(websocket.TextMessage, marshal(SubscribeMessage{Op: "subscribe", Topic: topic})); werr != nil { + cancel() + _ = conn.Close() + return errors.Errorf("subscribe %s: %w", topic, werr) + } + } + w.log.Info("subscribed", "topics", w.cfg.Topics) + + errCh := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); w.writePump(connCtx, conn, errCh) }() + go func() { defer wg.Done(); w.readPump(connCtx, conn, errCh) }() + + var retErr error + select { + case <-ctx.Done(): + retErr = ctx.Err() + case e := <-errCh: + retErr = e + } + // Tear the connection down and JOIN both pumps before returning, so no pump goroutine — and no + // second reader competing for w.send — outlives this connection into the next reconnect. + cancel() + _ = conn.Close() + wg.Wait() + return retErr +} + +// readPump reads frames, extends the read deadline on each, dispatches to onMsg, and answers server +// pings (gorilla auto-replies to pings via the default handler; we extend the deadline too). +func (w *wsClient) readPump(ctx context.Context, conn *websocket.Conn, errCh chan<- error) { + _ = conn.SetReadDeadline(time.Now().Add(w.cfg.MsgTimeout)) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(w.cfg.MsgTimeout)) + }) + conn.SetPingHandler(func(appData string) error { + _ = conn.SetReadDeadline(time.Now().Add(w.cfg.MsgTimeout)) + // Reply pong via the write pump is simplest, but gorilla allows a direct control write. + _ = conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(time.Second)) + return nil + }) + for { + _, data, err := conn.ReadMessage() + if err != nil { + select { + case errCh <- errors.Errorf("read: %w", err): + default: + } + return + } + _ = conn.SetReadDeadline(time.Now().Add(w.cfg.MsgTimeout)) + if ctx.Err() != nil { + return + } + w.onMsg(ctx, data) + } +} + +// writePump owns all writes (gorilla requires a single writer): outbound frames, periodic pings, and +// a rotation timer that forces a clean reconnect before the server's cutoff. +func (w *wsClient) writePump(ctx context.Context, conn *websocket.Conn, errCh chan<- error) { + ping := time.NewTicker(w.cfg.PingInterval) + defer ping.Stop() + rotate := time.NewTimer(w.cfg.RotateAfter + jitter()) + defer rotate.Stop() + for { + select { + case <-ctx.Done(): + return + case frame := <-w.send: + _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + if err := conn.WriteMessage(websocket.TextMessage, frame); err != nil { + w.nonblockErr(errCh, errors.Errorf("write: %w", err)) + return + } + case <-ping.C: + _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil { + w.nonblockErr(errCh, errors.Errorf("ping: %w", err)) + return + } + case <-rotate.C: + w.log.Info("rotating connection before server cutoff") + w.nonblockErr(errCh, errors.New("rotate")) + return + } + } +} + +func (w *wsClient) nonblockErr(errCh chan<- error, err error) { + select { + case errCh <- err: + default: + } +} + +// flushSendQueue empties the outbound buffer without blocking (used on (re)connect to discard stale solves). +func flushSendQueue(ch chan []byte) { + for { + select { + case <-ch: + default: + return + } + } +} + +func setDur(d *time.Duration, def time.Duration) { + if *d <= 0 { + *d = def + } +} + +// jitter returns 1–5s of randomness to desynchronize reconnects (matches the example client). The +// reconnect path is not security-sensitive, so math/rand is fine. +func jitter() time.Duration { + return time.Duration(1000+rand.Intn(4000)) * time.Millisecond //nolint:gosec // non-crypto jitter +} diff --git a/internal/solvers/redstoneoev/wsintegration_test.go b/internal/solvers/redstoneoev/wsintegration_test.go new file mode 100644 index 00000000..db82f85c --- /dev/null +++ b/internal/solvers/redstoneoev/wsintegration_test.go @@ -0,0 +1,76 @@ +package redstoneoev + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/gorilla/websocket" +) + +// wsintegration_test.go drives the REAL wsClient (connect → subscribe → read → reconnect-safe) end to +// end against an in-process httptest websocket server, with no chain (the solver reads only its seeded +// snapshot/state). The end-to-end solve + breaker path through handleMessage is covered by +// TestFullAuctionLifecycle (solver_test.go); here we pin the reconnect hygiene that the in-memory path +// can't exercise. + +// TestWSIntegrationDropsStaleSolveAcrossReconnect proves the reconnect hygiene fix (#6): a solve +// buffered while the connection is down is NOT replayed to the next connection (a stale auction has +// closed). One server drops the first connection, then accepts the reconnect and captures any SOLVE the +// client writes (subscribe frames are expected on reconnect and ignored). The URL never changes, so the +// Run goroutine's cfg reads stay race-free. +func TestWSIntegrationDropsStaleSolveAcrossReconnect(t *testing.T) { + s, _ := seededSolver(t) + useOnchainTestMonitor(t, s) + + var conns atomic.Int32 + dropped := make(chan struct{}, 1) + gotSolve := make(chan string, 4) + up := websocket.Upgrader{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := up.Upgrade(w, r, nil) + if err != nil { + return + } + if conns.Add(1) == 1 { // first connection: drop immediately so the client must reconnect + _ = c.Close() + dropped <- struct{}{} + return + } + defer c.Close() //nolint:errcheck // test teardown + for { // reconnect: capture only solve frames (subscribes are expected, ignored) + _, data, rerr := c.ReadMessage() + if rerr != nil { + return + } + if op, _ := opName(data); op == "solve" { + gotSolve <- string(data) + } + } + })) + defer srv.Close() + + s.ws = newWSClient(wsConfig{ + URL: "ws" + strings.TrimPrefix(srv.URL, "http"), APIKey: "k", + Topics: []string{"t"}, BackoffInitial: 10 * time.Millisecond, + }, logr.Discard(), s.handleMessage) + // Pre-load a solve into the send buffer as if a prior auction had queued it during the downtime. + s.ws.Send([]byte(`{"op":"solve","id":"stale","data":{}}`)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = s.ws.Run(ctx) }() + <-dropped // first connection happened and dropped (the buffered solve survived the drop) + + select { + case frame := <-gotSolve: + t.Fatalf("stale solve replayed across reconnect: %s", frame) + case <-time.After(500 * time.Millisecond): + // No solve written on the reconnect — flushSendQueue discarded the stale frame. ✓ + } +} diff --git a/internal/solvers/redstoneoev/wsmessages.go b/internal/solvers/redstoneoev/wsmessages.go new file mode 100644 index 00000000..51235ab5 --- /dev/null +++ b/internal/solvers/redstoneoev/wsmessages.go @@ -0,0 +1,122 @@ +package redstoneoev + +import ( + "encoding/json" + + "github.com/go-errors/errors" +) + +// Hand-written WS structs pinned to RedStone's zod schema and live auction frames; there is no upstream +// OpenAPI to generate from. +func opName(raw []byte) (string, error) { + var head struct { + Op string `json:"op"` + } + if err := json.Unmarshal(raw, &head); err != nil { + return "", errors.Errorf("ws: decode op: %w", err) + } + return head.Op, nil +} + +func isFeedAuction(raw []byte) bool { + var frame struct { + Payload map[string]json.RawMessage `json:"payload"` + } + if err := json.Unmarshal(raw, &frame); err != nil || len(frame.Payload) == 0 { + return false + } + if _, ok := frame.Payload["positions"]; ok { + return false + } + if _, ok := frame.Payload["prices"]; ok { + return false + } + return true +} + +type AuctionMessage struct { + Op string `json:"op"` + ID string `json:"id"` + Timestamp int64 `json:"timestamp"` + TimeoutMs int `json:"timeoutMs"` + Payload AuctionPayload `json:"payload"` +} + +type AuctionPayload struct { + Prices map[string]string `json:"prices"` +} + +// dedupKey returns the key used to suppress a replayed delivery of this auction. RedStone's auction id is +// the only valid identity; empty-id frames are dropped before bidding. +func (a AuctionMessage) dedupKey() string { + if a.ID == "" { + return "" + } + return "id:" + a.ID +} + +type AuctionResult struct { + Op string `json:"op"` + ID string `json:"id"` + Data AuctionResultData `json:"data"` +} + +type AuctionResultData struct { + Bid string `json:"bid"` + Liquidator string `json:"liquidator"` +} + +type LiquidationResult struct { + Op string `json:"op"` + ID string `json:"id"` + Data LiquidationResultData `json:"data"` +} + +type LiquidationResultData struct { + Success bool `json:"success"` + TxHash string `json:"txHash"` + Liquidator string `json:"liquidator"` + Error string `json:"error"` +} + +type Blacklisted struct { + Op string `json:"op"` + ID string `json:"id"` + Data BlacklistedData `json:"data"` +} + +type BlacklistedData struct { + Liquidator string `json:"liquidator"` + Msg string `json:"msg"` +} + +type SubscribeMessage struct { + Op string `json:"op"` + Topic string `json:"topic"` +} + +type SolveMessage struct { + Op string `json:"op"` + ID string `json:"id"` + Data SolveData `json:"data"` +} + +// SolveData carries the bid. `bid` is a decimal ether string of the signed wei bidAmount; `nonce` +// and `maxTxGasPrice` are decimal strings; `operationData`/`liquidationSig` are 0x-hex. +type SolveData struct { + Bid string `json:"bid"` + Nonce string `json:"nonce"` + OperationCallback string `json:"operationCallback"` + OperationData string `json:"operationData"` + LiquidationSig string `json:"liquidationSig"` + MaxTxGasPrice string `json:"maxTxGasPrice"` + Borrowers []string `json:"borrowers,omitempty"` +} + +func marshal(v any) []byte { + b, err := json.Marshal(v) + if err != nil { // unreachable: our outbound shapes are static and marshal-safe + panic("redstoneoev: marshal: " + err.Error()) + } + return b +} diff --git a/internal/solvers/redstoneoev/wsmessages_test.go b/internal/solvers/redstoneoev/wsmessages_test.go new file mode 100644 index 00000000..86f2791c --- /dev/null +++ b/internal/solvers/redstoneoev/wsmessages_test.go @@ -0,0 +1,84 @@ +package redstoneoev + +import ( + "encoding/json" + "testing" +) + +// capturedAuction is a real `oev/liquidations` frame shape (docs/OEV-PLAN.md §6.1): note `timeoutMs` +// (not the docs example's `durationMs`) and the liquidations payload nested under `payload`. +const capturedAuction = `{ + "op":"auction","id":"6382e936-c915-496a-bb3e-fa3b4ccc3a8d","timestamp":1781243340988,"timeoutMs":500, + "payload":{ + "positions":[ + {"market_unique_key":"0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5", + "borrower_address":"0x629d764ec8563afa701709b52c1a215e865632de","current_ltv":108.83, + "oracle_address":"0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D","lltv":"860000000000000000", + "collateral_decimals":18,"loan_decimals":6, + "collateral_address":"0x17e892d4E802B01d7DA49Ca3542560f6851AA4D3", + "loan_address":"0x468BB3245BF520a0CD030BDE029c98aCEAF84C9d", + "collateral_assets":"1000000000000000000","borrow_assets":"1685600048","borrow_shares":"1685600000000000"}, + {"market_unique_key":"0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5", + "borrower_address":"0x378a49c640fd9eea888a6a553caae441e2fdebc6","current_ltv":102.17, + "oracle_address":"0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D","lltv":"860000000000000000", + "collateral_decimals":18,"loan_decimals":6, + "collateral_address":"0x17e892d4E802B01d7DA49Ca3542560f6851AA4D3", + "loan_address":"0x468BB3245BF520a0CD030BDE029c98aCEAF84C9d", + "collateral_assets":"1000000000000000000","borrow_assets":"1582400019","borrow_shares":"1582399974653062"} + ], + "prices":{"0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D":"1800943620100000000000000000"} + } +}` + +func TestDecodeAuctionFrame(t *testing.T) { + if op, err := opName([]byte(capturedAuction)); err != nil || op != "auction" { + t.Fatalf("opName = %q, %v; want auction", op, err) + } + var a AuctionMessage + if err := json.Unmarshal([]byte(capturedAuction), &a); err != nil { + t.Fatal(err) + } + if a.ID != "6382e936-c915-496a-bb3e-fa3b4ccc3a8d" { + t.Fatalf("id = %q", a.ID) + } + if a.TimeoutMs != 500 { + t.Fatalf("timeoutMs = %d, want 500", a.TimeoutMs) + } + if got := a.Payload.Prices["0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D"]; got != "1800943620100000000000000000" { + t.Fatalf("price = %q", got) + } +} + +func TestDetectFeedAuctionFrame(t *testing.T) { + feed := []byte(`{ + "op":"auction","id":"e9803b9f-4318-4dc0-811d-23f2f0b938f2", + "timestamp":1726058300000,"durationMs":400, + "payload":{"ETH":"250000000000","BTC":"6000000000000","USDC":"99878787"} + }`) + if !isFeedAuction(feed) { + t.Fatal("flat feed auction must be detected") + } + if isFeedAuction([]byte(capturedAuction)) { + t.Fatal("liquidation auction must not be detected as a feed auction") + } +} + +func TestMarshalSolve(t *testing.T) { + msg := SolveMessage{Op: "solve", ID: "abc", Data: SolveData{ + Bid: "0.0005", Nonce: "3", OperationCallback: "0x7Aa3", OperationData: "0x1234", + LiquidationSig: "0xdead", MaxTxGasPrice: "60000000000", Borrowers: []string{"0x629d"}, + }} + var back map[string]any + if err := json.Unmarshal(marshal(msg), &back); err != nil { + t.Fatal(err) + } + if back["op"] != "solve" || back["id"] != "abc" { + t.Fatalf("solve top-level wrong: %v", back) + } + data, _ := back["data"].(map[string]any) + for _, k := range []string{"bid", "nonce", "operationCallback", "operationData", "liquidationSig", "maxTxGasPrice", "borrowers"} { + if _, ok := data[k]; !ok { + t.Fatalf("solve.data missing %q", k) + } + } +} diff --git a/internal/solvers/rfq/apitypes.go b/internal/solvers/rfq/apitypes.go index ecc0cfc4..6419301d 100644 --- a/internal/solvers/rfq/apitypes.go +++ b/internal/solvers/rfq/apitypes.go @@ -6,6 +6,8 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/parse" ) // quoteRequest is the backend → filler RFQ quote request (POST /quote). The validation tags drive @@ -80,11 +82,11 @@ func (q *quoteRequest) toStrategy(chainID int64) (*parsedQuote, error) { if !common.IsHexAddress(q.Swapper) { return nil, errors.Errorf("swapper: invalid address %q", q.Swapper) } - tokenIn, err := parseAddress(q.TokenIn, "tokenIn") + tokenIn, err := parse.Address(q.TokenIn, "tokenIn") if err != nil { return nil, err } - tokenOut, err := parseAddress(q.TokenOut, "tokenOut") + tokenOut, err := parse.Address(q.TokenOut, "tokenOut") if err != nil { return nil, err } @@ -118,11 +120,11 @@ func (q *quoteRequest) toStrategy(chainID int64) (*parsedQuote, error) { } func (v *quoteAdapter) parse(index int) (solverInventory, error) { - adapter, err := parseAddress(v.Adapter, idxField(index, "adapter")) + adapter, err := parse.Address(v.Adapter, idxField(index, "adapter")) if err != nil { return solverInventory{}, err } - asset, err := parseAddress(v.Asset, idxField(index, "asset")) + asset, err := parse.Address(v.Asset, idxField(index, "asset")) if err != nil { return solverInventory{}, err } diff --git a/internal/solvers/rfq/chainreader.go b/internal/solvers/rfq/chainreader.go index 91b59364..dab40009 100644 --- a/internal/solvers/rfq/chainreader.go +++ b/internal/solvers/rfq/chainreader.go @@ -2,15 +2,13 @@ package rfq import ( "context" - "math/big" - "sync" "github.com/ethereum/go-ethereum/common" "github.com/go-errors/errors" "github.com/go-logr/logr" "github.com/symbioticfi/vault-solver/api/bindings/erc4626" - "github.com/symbioticfi/vault-solver/api/bindings/rfq/adapter" + "github.com/symbioticfi/vault-solver/api/bindings/liquidlane/adapter" "github.com/symbioticfi/vault-solver/internal/chain" ) @@ -18,8 +16,8 @@ import ( // ABI change fails at compile time (see CLAUDE.md "Code generation"). var ( llAdapter = adapter.NewLiquidLaneAdapter() - // erc4626b serves both the vault's asset() and the asset token's decimals(): IERC4626 is an - // ERC-20, and a method's selector/return shape is fixed by the ABI regardless of target. + // erc4626b serves the vault's asset(): a method's selector/return shape is fixed by the ABI + // regardless of target. (Token decimals go through the shared chain.Decimals helper.) erc4626b = erc4626.NewIERC4626() ) @@ -28,116 +26,36 @@ var ( // (see resolveVaults), not re-read here. const readsPerAdapter = 3 -// reader performs the on-chain reads, batching via Multicall3. Token decimals are cached; the HTTP -// server serves quotes concurrently, so the cache is mutex-guarded. +// reader performs the on-chain reads, batching via Multicall3. Token decimals are resolved + cached by +// the shared chain.Decimals helper (its own mutex), so concurrent quote requests stay safe. type reader struct { chain *chain.Client log logr.Logger - - mu sync.Mutex - decimals map[common.Address]int + dec *chain.Decimals } func newReader(c *chain.Client, log logr.Logger) *reader { - return &reader{chain: c, log: log, decimals: make(map[common.Address]int)} + return &reader{chain: c, log: log, dec: chain.NewDecimals(c)} } // recoveryVault is one configured LiquidLane adapter plus the Vault and Asset derived from it. Config // carries only Adapter; Vault (adapter.vault()) and Asset (vault.asset()) are resolved on-chain at // startup (see resolveVaults) and are fixed for the adapter's lifetime. The entries double as the -// adapter whitelist source (see buildAdapterWhitelist) and the recovery candidate universe. +// adapter whitelist source (see buildAdapterWhitelist) and the fill-plan recovery candidate universe. type recoveryVault struct { Adapter common.Address Vault common.Address Asset common.Address } -// tokenDecimals returns the ERC-20 decimals for token, caching the result. +// tokenDecimals returns the ERC-20 decimals for token (cached). Delegates to the shared chain.Decimals. func (r *reader) tokenDecimals(ctx context.Context, token common.Address) (int, error) { - r.mu.Lock() - if d, ok := r.decimals[token]; ok { - r.mu.Unlock() - return d, nil - } - r.mu.Unlock() - - res, err := r.chain.Multicall(ctx, []chain.Call{{Target: token, Data: erc4626b.PackDecimals()}}) - if err != nil { - return 0, err - } - if len(res) != 1 || !res[0].Success { - return 0, errors.Errorf("erc20.decimals() reverted for %s", token) - } - d, err := erc4626b.UnpackDecimals(res[0].ReturnData) - if err != nil { - return 0, errors.Errorf("unpack decimals: %w", err) - } - r.mu.Lock() - r.decimals[token] = int(d) - r.mu.Unlock() - return int(d), nil -} - -// amountsOut prices each distinct asset by calling its representative adapter's getAmountOut(tokenIn, -// amount). The quote oracle is per asset-group: the representative is the first inventory entry seen -// for that asset, matching evaluateInventoryGroup in strategy.ts (inventories[0].adapter). Targets are -// heterogeneous (each call hits that asset's adapter). A reverting sub-call leaves the asset unpriced -// (the selector then skips it), so the map only holds successfully-priced assets. -func (r *reader) amountsOut( - ctx context.Context, tokenIn common.Address, inventories []solverInventory, amount *big.Int, -) (map[common.Address]*big.Int, error) { - // Pick the representative adapter per distinct asset (first seen), preserving deterministic order. - type group struct { - asset common.Address - adapter common.Address - } - var groups []group - seen := make(map[common.Address]bool, len(inventories)) - for _, inv := range inventories { - if seen[inv.Asset] { - continue - } - seen[inv.Asset] = true - groups = append(groups, group{asset: inv.Asset, adapter: inv.Adapter}) - } - if len(groups) == 0 { - return map[common.Address]*big.Int{}, nil - } - - calls := make([]chain.Call, len(groups)) - for i, g := range groups { - calls[i] = chain.Call{ - Target: g.adapter, - AllowFailure: true, - Data: llAdapter.PackGetAmountOut(tokenIn, amount), - } - } - res, err := r.chain.Multicall(ctx, calls) - if err != nil { - return nil, err - } - if len(res) != len(calls) { - return nil, errors.Errorf("amountsOut: got %d results for %d calls", len(res), len(calls)) - } - out := make(map[common.Address]*big.Int, len(groups)) - for i, rr := range res { - if !rr.Success { - r.log.V(1).Info("getAmountOut reverted; asset left unpriced", "asset", groups[i].asset.Hex()) - continue - } - amt, derr := llAdapter.UnpackGetAmountOut(rr.ReturnData) - if derr != nil { - r.log.V(1).Error(derr, "getAmountOut decode failed; asset left unpriced", "asset", groups[i].asset.Hex()) - continue - } - out[groups[i].asset] = amt - } - return out, nil + return r.dec.Get(ctx, token) } -// readVaultInventories reads each adapter's recovery views (paused, getMaxAssets(tokenIn), +// readVaultInventories reads each adapter's fill-time views (paused, getMaxAssets(tokenIn), // getMaxRate(tokenIn)) in one multicall, using the startup-resolved Vault/Asset (decimals cached). Used -// to rebuild a strategy when the quote-time one isn't cached (e.g. after a restart). Paused / failing / +// to rebuild a fill plan when the quote-time one isn't cached (e.g. after a restart). Paused / failing / // zero-liquidity adapters are dropped; direct legs only. Mirrors readAdapterInventories in inventories.ts. func (r *reader) readVaultInventories( ctx context.Context, tokenIn common.Address, vaults []recoveryVault, @@ -195,9 +113,9 @@ func (r *reader) readVaultInventories( // resolveVaults returns a copy of the configured entries with each Vault (adapter.vault()) and Asset // (vault.asset()) resolved from chain at startup — config carries only adapter addresses, both fixed // for the adapter's lifetime. Returning a fresh slice (rather than mutating the input) keeps the -// resolved recovery universe independent of the config slice. Two batched multicalls (adapters' +// resolved fill-plan recovery universe independent of the config slice. Two batched multicalls (adapters' // vault(), then those vaults' asset()); an entry whose reads revert is left zero and skipped by -// recovery (readVaultInventories needs a non-zero Asset). Errors only on a multicall transport failure. +// fill-time reads (readVaultInventories needs a non-zero Asset). Errors only on a multicall transport failure. func (r *reader) resolveVaults(ctx context.Context, vaults []recoveryVault) ([]recoveryVault, error) { out := make([]recoveryVault, len(vaults)) for i := range vaults { @@ -244,8 +162,8 @@ func (r *reader) resolveVaults(ctx context.Context, vaults []recoveryVault) ([]r // readPermissionedVaultInventories returns the subset of readVaultInventories the executor is // authorized to fill through: adapter.marketMaker() == executor, adapter.owner() == executor, or the -// marketMaker has delegated via adapter.isFiller(marketMaker, executor). Used in recovery so we never -// build a fill against an unauthorized adapter. Mirrors readPermissionedAdapterInventories in +// marketMaker has delegated via adapter.isFiller(marketMaker, executor). Used at fill time so we never +// build inputs for an unauthorized adapter. Mirrors readPermissionedAdapterInventories in // inventories.ts (marketMaker / owner / isFiller). func (r *reader) readPermissionedVaultInventories( ctx context.Context, executor, tokenIn common.Address, vaults []recoveryVault, diff --git a/internal/solvers/rfq/config.go b/internal/solvers/rfq/config.go index defe857e..1e125abb 100644 --- a/internal/solvers/rfq/config.go +++ b/internal/solvers/rfq/config.go @@ -8,22 +8,29 @@ import ( "github.com/go-errors/errors" "gopkg.in/yaml.v3" + "github.com/symbioticfi/vault-solver/internal/parse" "github.com/symbioticfi/vault-solver/internal/solver" ) // rawConfig mirrors the YAML shape; strings are parsed into typed values in parseConfig. type rawConfig struct { - BackendURL string `yaml:"backendUrl"` - BackendSharedSecretEnv string `yaml:"backendSharedSecretEnv"` - ListenAddr string `yaml:"listenAddr"` - Executor string `yaml:"executor"` - Reactor string `yaml:"reactor"` - PollIntervalMs int `yaml:"pollIntervalMs"` - OrderLimit int `yaml:"orderLimit"` - SolverMode string `yaml:"solverMode"` - TokensToQuote string `yaml:"tokensToQuote"` - PermissionedTokens []string `yaml:"permissionedTokens"` - Adapters []string `yaml:"adapters"` + BackendURL string `yaml:"backendUrl"` + BackendSharedSecretEnv string `yaml:"backendSharedSecretEnv"` + ListenAddr string `yaml:"listenAddr"` + Executor string `yaml:"executor"` + Reactor string `yaml:"reactor"` + PollIntervalMs int `yaml:"pollIntervalMs"` + OrderLimit int `yaml:"orderLimit"` + SolverMode string `yaml:"solverMode"` + TokensToQuote string `yaml:"tokensToQuote"` + PermissionedTokens []string `yaml:"permissionedTokens"` + Adapters []string `yaml:"adapters"` + Strategy rawStrategyConfig `yaml:"strategy"` +} + +type rawStrategyConfig struct { + Name string `yaml:"name"` + Config yaml.Node `yaml:"config"` } // Config is the validated, typed RFQ solver configuration. @@ -35,7 +42,8 @@ type Config struct { BackendSharedSecretEnv string // ListenAddr is the bind address for the quote HTTP server. ListenAddr string - // Executor is the Executor contract (the on-chain filler identity; the bot EOA holds CALLER_ROLE). + // Executor is the Executor contract (the on-chain filler identity; the bot EOA must be an authorized + // caller — added to the Executor's callers allowlist via setCallers by its owner). Executor common.Address // Reactor is the RFQ Reactor (used at execution time); optional. Reactor common.Address @@ -57,11 +65,17 @@ type Config struct { // TokensToQuote scope is evaluated against it. Empty means no input token is permissioned. PermissionedTokens map[common.Address]bool // Adapters is the configured LiquidLane adapter universe: in external mode the set quoting/filling is - // scoped to, and the candidate universe used to rebuild a strategy on-chain when the quote-time - // strategy isn't cached (e.g. after a restart). Config carries only adapter addresses; + // scoped to, and the candidate universe used to rebuild a fill plan when the quote-time plan isn't + // cached (e.g. after a restart). Config carries only adapter addresses; // each entry's Vault (adapter.vault()) and Asset (vault.asset()) are resolved on-chain at startup - // (see reader.resolveVaults) and are fixed for the adapter's lifetime. Empty disables recovery. + // (see reader.resolveVaults) and are fixed for the adapter's lifetime. Empty disables fill-plan recovery. Adapters []recoveryVault + Strategy StrategyConfig +} + +type StrategyConfig struct { + Name string + Config yaml.Node } // Solver-mode profiles (see Config.SolverMode). @@ -84,6 +98,7 @@ const ( defaultPollInterval = 3 * time.Second defaultOrderLimit = 20 defaultSolverMode = solverModeExternal + defaultStrategyName = "default" ) // parseConfig decodes and validates the opaque rfq solver config block. @@ -98,15 +113,15 @@ func parseConfig(node yaml.Node) (*Config, error) { if raw.BackendSharedSecretEnv == "" { return nil, errors.New("backendSharedSecretEnv is required") } - executor, err := parseAddress(raw.Executor, "executor") + executor, err := parse.Address(raw.Executor, "executor") if err != nil { return nil, err } - mode := orStr(raw.SolverMode, defaultSolverMode) + mode := parse.OrDefault(raw.SolverMode, defaultSolverMode) if mode != solverModeExternal && mode != solverModeInternal { return nil, errors.Errorf("solverMode: must be %q or %q, got %q", solverModeExternal, solverModeInternal, mode) } - scope := orStr(raw.TokensToQuote, tokensToQuoteAll) + scope := parse.OrDefault(raw.TokensToQuote, tokensToQuoteAll) if scope != tokensToQuoteAll && scope != tokensToQuotePermissioned && scope != tokensToQuotePermissionless { return nil, errors.Errorf("tokensToQuote: must be %q, %q or %q, got %q", tokensToQuoteAll, tokensToQuotePermissioned, tokensToQuotePermissionless, scope) @@ -115,15 +130,19 @@ func parseConfig(node yaml.Node) (*Config, error) { cfg := &Config{ BackendURL: raw.BackendURL, BackendSharedSecretEnv: raw.BackendSharedSecretEnv, - ListenAddr: orStr(raw.ListenAddr, defaultListenAddr), + ListenAddr: parse.OrDefault(raw.ListenAddr, defaultListenAddr), Executor: executor, PollInterval: defaultPollInterval, OrderLimit: defaultOrderLimit, SolverMode: mode, TokensToQuote: scope, + Strategy: StrategyConfig{ + Name: parse.OrDefault(raw.Strategy.Name, defaultStrategyName), + Config: raw.Strategy.Config, + }, } for i, t := range raw.PermissionedTokens { - addr, terr := parseNonZeroAddress(t, "permissionedTokens["+strconv.Itoa(i)+"]") + addr, terr := parse.NonZeroAddress(t, "permissionedTokens["+strconv.Itoa(i)+"]") if terr != nil { return nil, terr } @@ -138,22 +157,18 @@ func parseConfig(node yaml.Node) (*Config, error) { if raw.OrderLimit > 0 { cfg.OrderLimit = raw.OrderLimit } - // Reactor is optional (used by execution). Parse when present so a bad address fails fast. if raw.Reactor != "" { - if cfg.Reactor, err = parseAddress(raw.Reactor, "reactor"); err != nil { + if cfg.Reactor, err = parse.Address(raw.Reactor, "reactor"); err != nil { return nil, err } } for i, a := range raw.Adapters { - // The zero address is rejected so a placeholder fails at startup rather than weakening the - // whitelist. Vault + Asset are resolved on-chain at startup. - adapterAddr, verr := parseNonZeroAddress(a, "adapters["+strconv.Itoa(i)+"]") - if verr != nil { - return nil, verr + adapter, err := parse.NonZeroAddress(a, "adapters["+strconv.Itoa(i)+"]") + if err != nil { + return nil, err } - cfg.Adapters = append(cfg.Adapters, recoveryVault{Adapter: adapterAddr}) + cfg.Adapters = append(cfg.Adapters, recoveryVault{Adapter: adapter}) } - // External has no discounts fallback, so with no adapters it could quote/recover nothing — fail fast. if mode == solverModeExternal && len(cfg.Adapters) == 0 { return nil, errors.New(`solverMode "external" requires at least one adapters entry`) } @@ -164,44 +179,14 @@ func parseConfig(node yaml.Node) (*Config, error) { func (c *Config) usesDiscounts() bool { return c.SolverMode == solverModeInternal } // restrictsToAdapters reports whether the EXECUTION path (order filling, incl. discount-leg recovery) is -// scoped to the configured Adapters: external mode with ≥1 adapter. parseConfig requires external to have -// adapters; the len check guards hand-built Configs. Internal mode never restricts filling — discount -// recovery may legitimately route through any advertised adapter — so this stays external-only. +// scoped to the configured Adapters: external mode with at least one adapter. parseConfig requires external +// to have adapters; the len check guards hand-built Configs. func (c *Config) restrictsToAdapters() bool { return c.SolverMode == solverModeExternal && len(c.Adapters) > 0 } -// quoteScopesToAdapters reports whether the QUOTE path is scoped to the configured Adapters. It is a -// superset of restrictsToAdapters: external always scopes quoting (adapters are required), and internal -// scopes quoting too whenever ≥1 adapter is configured. This lets an internal-mode filler advertise quotes -// only for its own adapter universe (e.g. a per-solver adapter) without touching discount/execution -// semantics, which remain governed by restrictsToAdapters. Equivalent to len(Adapters) > 0 across the two -// valid modes, but written in terms of intent so the quote-vs-execution split is explicit. +// quoteScopesToAdapters reports whether the QUOTE path is scoped to the configured Adapters. It scopes in +// both modes whenever Adapters is non-empty, while execution scoping stays external-only. func (c *Config) quoteScopesToAdapters() bool { - return c.restrictsToAdapters() || (c.SolverMode == solverModeInternal && len(c.Adapters) > 0) -} - -func parseAddress(s, field string) (common.Address, error) { - if !common.IsHexAddress(s) { - return common.Address{}, errors.Errorf("%s: invalid address %q", field, s) - } - return common.HexToAddress(s), nil -} - -func parseNonZeroAddress(s, field string) (common.Address, error) { - addr, err := parseAddress(s, field) - if err != nil { - return common.Address{}, err - } - if addr == (common.Address{}) { - return common.Address{}, errors.Errorf("%s: zero address (placeholder not replaced?)", field) - } - return addr, nil -} - -func orStr(v, fallback string) string { - if v == "" { - return fallback - } - return v + return len(c.Adapters) > 0 } diff --git a/internal/solvers/rfq/config_test.go b/internal/solvers/rfq/config_test.go index a937728a..36c33fb4 100644 --- a/internal/solvers/rfq/config_test.go +++ b/internal/solvers/rfq/config_test.go @@ -8,7 +8,7 @@ import ( "gopkg.in/yaml.v3" ) -func parse(t *testing.T, body string) (*Config, error) { +func parseCfg(t *testing.T, body string) (*Config, error) { t.Helper() var doc yaml.Node if err := yaml.Unmarshal([]byte(body), &doc); err != nil { @@ -23,11 +23,11 @@ backendSharedSecretEnv: RFQ_BACKEND_SHARED_SECRET executor: "0x0000000000000000000000000000000000000010" ` -// oneAdapter is appended to make an external-mode config valid — external requires at least one adapter. +// oneAdapter is appended to make an external-mode config valid; external requires at least one adapter. const oneAdapter = "adapters:\n - \"0x0000000000000000000000000000000000000042\"\n" func TestParseConfig_Defaults(t *testing.T) { - cfg, err := parse(t, minimalConfig+oneAdapter) + cfg, err := parseCfg(t, minimalConfig+oneAdapter) if err != nil { t.Fatalf("parseConfig: %v", err) } @@ -41,35 +41,65 @@ func TestParseConfig_Defaults(t *testing.T) { t.Fatalf("orderLimit = %d, want %d", cfg.OrderLimit, defaultOrderLimit) } if cfg.SolverMode != solverModeExternal { - t.Fatalf("solverMode = %q, want %q (default)", cfg.SolverMode, solverModeExternal) + t.Fatalf("solverMode = %q, want %q", cfg.SolverMode, solverModeExternal) } if !cfg.restrictsToAdapters() { - t.Fatal("external mode with a configured adapter should restrict to adapters") + t.Fatal("external mode with a configured adapter should restrict execution to adapters") } if cfg.usesDiscounts() { - t.Fatal("usesDiscounts should default to false (external mode: discounts API is internal-only)") + t.Fatal("external mode should not use discounts") + } + if cfg.Strategy.Name != defaultStrategyName { + t.Fatalf("strategy.name = %q, want %q", cfg.Strategy.Name, defaultStrategyName) + } +} + +func TestParseConfig_Strategy(t *testing.T) { + cfg, err := parseCfg(t, minimalConfig+` +strategy: + name: webhook + config: + url: https://strategy.example +`+oneAdapter) + if err != nil { + t.Fatalf("parseConfig: %v", err) + } + if cfg.Strategy.Name != "webhook" { + t.Fatalf("strategy.name = %q, want webhook", cfg.Strategy.Name) + } + var raw struct { + URL string `yaml:"url"` + } + if err := cfg.Strategy.Config.Decode(&raw); err != nil { + t.Fatalf("decode strategy config: %v", err) + } + if raw.URL != "https://strategy.example" { + t.Fatalf("strategy url = %q", raw.URL) } } func TestParseConfig_SolverMode(t *testing.T) { a := "\n" + oneAdapter cases := map[string]struct { - yaml string - wantMode string - wantWhitelist, wantDiscounts, err bool + yaml string + wantMode string + wantRestrict bool + wantDiscounts bool + wantErr bool }{ - "external + adapters → restrict, no discounts": {yaml: "solverMode: external" + a, wantMode: "external", wantWhitelist: true, wantDiscounts: false}, - "external, no adapters → error": {yaml: "solverMode: external", err: true}, - "internal + adapters → discounts on, no restrict": {yaml: "solverMode: internal" + a, wantMode: "internal", wantWhitelist: false, wantDiscounts: true}, - "internal, no adapters → discounts on (optional)": {yaml: "solverMode: internal", wantMode: "internal", wantWhitelist: false, wantDiscounts: true}, - "default (unset) + adapters → external": {yaml: a, wantMode: "external", wantWhitelist: true, wantDiscounts: false}, - "default (unset), no adapters → error": {yaml: "", err: true}, - "invalid mode → error": {yaml: "solverMode: hybrid", err: true}, + "external + adapters": {yaml: "solverMode: external" + a, wantMode: solverModeExternal, wantRestrict: true}, + "external, no adapters": {yaml: "solverMode: external", wantErr: true}, + "internal + adapters": {yaml: "solverMode: internal" + a, wantMode: solverModeInternal, wantDiscounts: true}, + "internal, no adapters": {yaml: "solverMode: internal", wantMode: solverModeInternal, wantDiscounts: true}, + "default + adapters": {yaml: a, wantMode: solverModeExternal, wantRestrict: true}, + "default, no adapters": {wantErr: true}, + "invalid mode": {yaml: "solverMode: hybrid", wantErr: true}, + "old whitelist flag rejected": {yaml: "adapterWhitelistEnabled: true" + a, wantErr: true}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { - cfg, err := parse(t, minimalConfig+tc.yaml+"\n") - if tc.err { + cfg, err := parseCfg(t, minimalConfig+tc.yaml+"\n") + if tc.wantErr { if err == nil { t.Fatal("expected the config to be rejected") } @@ -81,8 +111,8 @@ func TestParseConfig_SolverMode(t *testing.T) { if cfg.SolverMode != tc.wantMode { t.Fatalf("solverMode = %q, want %q", cfg.SolverMode, tc.wantMode) } - if cfg.restrictsToAdapters() != tc.wantWhitelist { - t.Fatalf("restrictsToAdapters() = %v, want %v", cfg.restrictsToAdapters(), tc.wantWhitelist) + if cfg.restrictsToAdapters() != tc.wantRestrict { + t.Fatalf("restrictsToAdapters() = %v, want %v", cfg.restrictsToAdapters(), tc.wantRestrict) } if cfg.usesDiscounts() != tc.wantDiscounts { t.Fatalf("usesDiscounts() = %v, want %v", cfg.usesDiscounts(), tc.wantDiscounts) @@ -91,66 +121,42 @@ func TestParseConfig_SolverMode(t *testing.T) { } } -// TestParseConfig_QuoteScopesToAdapters pins the quote-vs-execution scoping split: the QUOTE path scopes -// to configured adapters in BOTH external and internal mode (quoteScopesToAdapters), while execution -// scoping (restrictsToAdapters) stays external-only. The internal+adapters row is the new behavior — an -// internal-mode filler advertises quotes only for its own adapter universe without restricting filling. func TestParseConfig_QuoteScopesToAdapters(t *testing.T) { a := "\n" + oneAdapter - type want struct { - quoteScope bool // quoteScopesToAdapters() - execRestrict bool // restrictsToAdapters() - } cases := map[string]struct { - yaml string - want want + yaml string + wantQuote bool + wantRestrict bool }{ - "external + adapters → quote scoped, exec restricted": { - yaml: "solverMode: external" + a, - want: want{quoteScope: true, execRestrict: true}, - }, - "internal + adapters → quote scoped, exec unrestricted": { - yaml: "solverMode: internal" + a, - want: want{quoteScope: true, execRestrict: false}, - }, - "internal, no adapters → neither scoped": { - yaml: "solverMode: internal", - want: want{quoteScope: false, execRestrict: false}, - }, - "default (unset) + adapters → quote scoped, exec restricted": { - yaml: a, - want: want{quoteScope: true, execRestrict: true}, - }, + "external + adapters": {yaml: "solverMode: external" + a, wantQuote: true, wantRestrict: true}, + "internal + adapters": {yaml: "solverMode: internal" + a, wantQuote: true}, + "internal, no adapters": {yaml: "solverMode: internal"}, + "default + adapters": {yaml: a, wantQuote: true, wantRestrict: true}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { - cfg, err := parse(t, minimalConfig+tc.yaml+"\n") + cfg, err := parseCfg(t, minimalConfig+tc.yaml+"\n") if err != nil { t.Fatalf("parseConfig: %v", err) } - if cfg.quoteScopesToAdapters() != tc.want.quoteScope { - t.Fatalf("quoteScopesToAdapters() = %v, want %v", cfg.quoteScopesToAdapters(), tc.want.quoteScope) - } - if cfg.restrictsToAdapters() != tc.want.execRestrict { - t.Fatalf("restrictsToAdapters() = %v, want %v", cfg.restrictsToAdapters(), tc.want.execRestrict) + if cfg.quoteScopesToAdapters() != tc.wantQuote { + t.Fatalf("quoteScopesToAdapters() = %v, want %v", cfg.quoteScopesToAdapters(), tc.wantQuote) } - // Quote scoping must always be a superset of execution scoping (filling never scopes when - // quoting doesn't). - if tc.want.execRestrict && !tc.want.quoteScope { - t.Fatal("invariant broken: execution restricted but quote not scoped") + if cfg.restrictsToAdapters() != tc.wantRestrict { + t.Fatalf("restrictsToAdapters() = %v, want %v", cfg.restrictsToAdapters(), tc.wantRestrict) } }) } } func TestParseConfig_UnknownKeyRejected(t *testing.T) { - if _, err := parse(t, minimalConfig+"pollIntervalMs: 100\nordreLimit: 5\n"); err == nil { + if _, err := parseCfg(t, minimalConfig+"pollIntervalMs: 100\nordreLimit: 5\n"); err == nil { t.Fatal("expected a typo'd key to be rejected") } } func TestParseConfig_Overrides(t *testing.T) { - cfg, err := parse(t, minimalConfig+` + cfg, err := parseCfg(t, minimalConfig+` listenAddr: ":9000" pollIntervalMs: 1500 orderLimit: 5 @@ -169,10 +175,7 @@ reactor: "0x0000000000000000000000000000000000000030" } func TestParseConfig_Adapters(t *testing.T) { - cfg, err := parse(t, minimalConfig+` -adapters: - - "0x0000000000000000000000000000000000000042" -`) + cfg, err := parseCfg(t, minimalConfig+oneAdapter) if err != nil { t.Fatalf("parseConfig: %v", err) } @@ -180,7 +183,6 @@ adapters: t.Fatalf("adapters = %d, want 1", len(cfg.Adapters)) } v := cfg.Adapters[0] - // Only Adapter comes from config; Vault/Asset are resolved on-chain at startup (zero here). if v.Adapter != common.HexToAddress("0x0000000000000000000000000000000000000042") { t.Fatalf("adapter entry not parsed: %+v", v) } @@ -195,7 +197,6 @@ func TestParseConfig_BadAdapter(t *testing.T) { adapters: - "not-an-address" `, - // A zero adapter feeds the whitelist; a placeholder must fail at startup. "zero adapter address": ` adapters: - "0x0000000000000000000000000000000000000000" @@ -203,7 +204,7 @@ adapters: } for name, body := range cases { t.Run(name, func(t *testing.T) { - if _, err := parse(t, minimalConfig+body); err == nil { + if _, err := parseCfg(t, minimalConfig+body); err == nil { t.Fatalf("expected an error for %q", name) } }) @@ -225,16 +226,12 @@ backendUrl: https://x backendSharedSecretEnv: S executor: "not-an-address" `, - // External mode (the default) has no discounts fallback, so an empty adapter list is rejected. - "external mode (default) requires adapters": minimalConfig, - "external mode (explicit) requires adapters": minimalConfig + "solverMode: external\n", - // Old flags folded into solverMode — a config still carrying them must fail (unknown key). - "removed adapterWhitelistEnabled key rejected": minimalConfig + "adapterWhitelistEnabled: true\n", - "removed discountsEnabled key rejected": minimalConfig + "discountsEnabled: true\n", + "external mode requires adapters": minimalConfig + "solverMode: external\n", + "removed discountsEnabled key rejected": minimalConfig + "discountsEnabled: true\n", } for name, body := range cases { t.Run(name, func(t *testing.T) { - if _, err := parse(t, body); err == nil { + if _, err := parseCfg(t, body); err == nil { t.Fatalf("expected an error for %q", name) } }) diff --git a/internal/solvers/rfq/discounts_disabled_test.go b/internal/solvers/rfq/discounts_disabled_test.go index d8446a78..e2c423f3 100644 --- a/internal/solvers/rfq/discounts_disabled_test.go +++ b/internal/solvers/rfq/discounts_disabled_test.go @@ -2,7 +2,6 @@ package rfq import ( "context" - "math/big" "testing" "time" @@ -27,6 +26,7 @@ func TestExecution_DiscountsDisabled_RecoverySkipsListDiscounts(t *testing.T) { txm := &fakeTxm{result: txmanager.Result{Hash: common.HexToHash("0xdead")}} e := newExec(t, st, be, txm) e.discountsEnabled = false // external solver; no vaults configured + e.strategy = fixedFillStrategy{} e.syncOnce(context.Background()) @@ -48,15 +48,10 @@ func TestExecution_DiscountsDisabled_RecoverySkipsListDiscounts(t *testing.T) { func TestExecution_DiscountsDisabled_FillFailsClosed(t *testing.T) { st, be := fillFixtures(t) h := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000ab") - st.putStrategy(&strategyRecord{ - QuoteID: "q1", TokenIn: tIn, TokenOut: tOut, Asset: tOut, AssetDecimals: 6, - AmountIn: big.NewInt(1_000000000000000000), QuotedAmountOut: big.NewInt(900000), AssetAmountOut: big.NewInt(900000), - Legs: []strategyLeg{{Adapter: vlt, AmountIn: big.NewInt(1_000000000000000000), AmountOut: big.NewInt(900000), MaxRate: big.NewInt(1), DiscountID: &h}}, - CreatedAt: time.Unix(0, 0), - }) txm := &fakeTxm{result: txmanager.Result{Hash: common.HexToHash("0xdead")}} e := newExec(t, st, be, txm) e.discountsEnabled = false + e.strategy = fixedFillStrategy{plan: discountFillPlan(h)} e.syncOnce(context.Background()) diff --git a/internal/solvers/rfq/execution.go b/internal/solvers/rfq/execution.go index 55d1d132..77aff716 100644 --- a/internal/solvers/rfq/execution.go +++ b/internal/solvers/rfq/execution.go @@ -10,6 +10,7 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" "github.com/go-errors/errors" "github.com/go-logr/logr" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" "github.com/symbioticfi/vault-solver/api/bindings/rfq/executor" "github.com/symbioticfi/vault-solver/internal/txmanager" @@ -53,6 +54,7 @@ type executionService struct { backend orderBackend store *store reader recoveryReader + strategy types.Strategy txm txSender log logr.Logger now func() time.Time @@ -61,11 +63,8 @@ type executionService struct { inflight map[string]bool } -// recoveryReader is the on-chain surface strategy recovery needs (satisfied by *reader). It extends -// the quote path's priceReader with the permissioned-inventory read; kept an interface so recovery is -// unit-testable without a chain backend. +// recoveryReader is the on-chain surface used to assemble fill-time strategy inputs. type recoveryReader interface { - priceReader readPermissionedVaultInventories( ctx context.Context, executor, tokenIn common.Address, vaults []recoveryVault, ) ([]solverInventory, error) @@ -96,7 +95,7 @@ func (e *executionService) syncOnce(ctx context.Context) { for _, o := range e.store.activeOrders() { e.handleOrder(ctx, o) } - e.store.sweep() // evict stale strategies/terminal orders so the maps stay bounded + e.store.sweep() // evict stale terminal orders so the maps stay bounded } func (e *executionService) pollOpenOrders(ctx context.Context) error { @@ -151,14 +150,6 @@ func (e *executionService) submitOrder(ctx context.Context, orderID string) { return } - selected := e.store.strategy(exec.quoteID) - if selected == nil { - if selected, err = e.recoverStrategy(ctx, exec); err != nil || selected == nil { - e.fail(orderID, "missing strategy for quoteId "+exec.quoteID) - return - } - } - order, err := decodeOrder(exec.encodedOrder) if err != nil { e.fail(orderID, "decode order: "+err.Error()) @@ -179,19 +170,10 @@ func (e *executionService) submitOrder(ctx context.Context, orderID string) { e.fail(orderID, "sum outputs: "+err.Error()) return } - // The strategy is looked up by the backend-supplied quoteId, so bind it to the awarded order's - // own terms before filling: a reused/mismatched quoteId must not let us fill on stale pricing. - if selected.Asset != outputToken { - e.fail(orderID, "strategy asset does not match order output token") - return - } - if selected.TokenIn != order.Request.TokenIn || selected.TokenOut != outputToken || - selected.AmountIn.Cmp(order.Request.AmountIn) != 0 { - e.fail(orderID, "stored strategy does not match the awarded order (tokenIn/tokenOut/amountIn)") - return - } - if selected.QuotedAmountOut.Cmp(required) < 0 { - e.fail(orderID, "stored strategy output is below the required order output") + + selected, err := e.buildFillPlan(ctx, exec, order, outputToken, required) + if err != nil || selected == nil { + e.fail(orderID, "strategy fill plan: "+errString(err)) return } @@ -269,23 +251,16 @@ func (e *executionService) reconcileTerminalStatus(ctx context.Context, orderID } } -// recoverStrategy rebuilds a strategy from current on-chain + backend state when the quote-time -// strategy is not cached (e.g. after a restart). Direct inventories come from the configured candidate -// vault universe; discount inventories come from the backend (independent of config), so a discount-only -// solver recovers with an empty `vaults` list. Bails only when neither source yields any inventory. -func (e *executionService) recoverStrategy(ctx context.Context, exec *executable) (*strategyRecord, error) { - order, err := decodeOrder(exec.encodedOrder) - if err != nil { - return nil, err - } - outputToken, ok := singleOutputToken(exec.outputs) - if !ok { - return nil, nil - } - required, err := sumOutputs(exec.outputs) - if err != nil { - return nil, err - } +// buildFillPlan gives the trusted strategy the awarded order terms plus current solver inputs. The +// strategy owns cached quote lookup and recovery; solver only assembles the snapshot and executes the +// returned plan. +func (e *executionService) buildFillPlan( + ctx context.Context, + exec *executable, + order executor.IReactorOrder, + outputToken common.Address, + required *big.Int, +) (*fillPlan, error) { // Direct inventories are filtered to adapters this executor is authorized to fill through. Skipped // when no candidate vaults are configured (a discount-only solver), leaving discount legs only. inv := make([]solverInventory, 0, len(e.vaults)+1) @@ -300,34 +275,18 @@ func (e *executionService) recoverStrategy(ctx context.Context, exec *executable if e.discountsEnabled { inv = append(inv, e.discountInventories(ctx, order.Request.TokenIn, inv)...) } - if len(inv) == 0 { - return nil, nil - } - tokenInDecimals, err := e.reader.tokenDecimals(ctx, order.Request.TokenIn) - if err != nil { - return nil, err - } - matching := matchingInventories(inv, outputToken) - oracle, err := e.reader.amountsOut(ctx, order.Request.TokenIn, matching, order.Request.AmountIn) - if err != nil { - return nil, err - } req := strategyRequest{ RequestID: exec.quoteID, QuoteID: exec.quoteID, TokenIn: order.Request.TokenIn, TokenOut: outputToken, Amount: order.Request.AmountIn, } - best := selectBestStrategy(req, inv, tokenInDecimals, oracle, e.now()) - if best == nil || best.QuotedAmountOut.Cmp(required) < 0 { - return nil, nil - } - e.store.putStrategy(best) - return best, nil + input := newFillInput(e.chainID, e.executor, req, inv, required, e.now()) + return e.strategy.BuildFillPlan(ctx, input) } // buildDiscountSwapInputs resolves each discount leg's fresh signed discount from the backend and // encodes it into the Executor's DiscountSwapInput. Direct-only strategies return nil. func (e *executionService) buildDiscountSwapInputs( - ctx context.Context, selected *strategyRecord, + ctx context.Context, selected *fillPlan, ) ([]executor.IReactorDiscountSwapInput, error) { var out []executor.IReactorDiscountSwapInput for _, leg := range selected.Legs { @@ -407,7 +366,7 @@ var errDiscountsDisabled = errors.New("discount leg present but discounts are di // toDiscountSwapInput converts a resolved signed discount + its strategy leg into the Executor input. func toDiscountSwapInput( - r *resolveDiscountResponse, leg strategyLeg, recipient common.Address, + r *resolveDiscountResponse, leg fillLeg, recipient common.Address, ) (executor.IReactorDiscountSwapInput, error) { d := r.Discount for _, a := range []string{d.Adapter, d.TokenToRedeem, d.Signer, d.Protocol} { @@ -460,6 +419,13 @@ func (e *executionService) fail(orderID, msg string) { e.store.markStatus(orderID, statusFailed, common.Hash{}, msg) } +func errString(err error) string { + if err == nil { + return "not available" + } + return err.Error() +} + func (e *executionService) acquire(orderID string) bool { e.inflightMu.Lock() defer e.inflightMu.Unlock() diff --git a/internal/solvers/rfq/execution_test.go b/internal/solvers/rfq/execution_test.go index 69777b7a..0257f6e6 100644 --- a/internal/solvers/rfq/execution_test.go +++ b/internal/solvers/rfq/execution_test.go @@ -11,6 +11,7 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" "github.com/go-errors/errors" "github.com/go-logr/logr" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" "github.com/symbioticfi/vault-solver/internal/txmanager" ) @@ -46,13 +47,11 @@ func (f *fakeBackend) listDiscounts(context.Context) (*discountsResponse, error) return f.discounts, nil } -// fakeRecoveryReader is the on-chain surface recoverStrategy needs. readPermissionedVaultInventories -// is only invoked when vaults are configured; the discount-only path uses tokenDecimals + amountsOut. +// fakeRecoveryReader is the solver-owned on-chain surface used to assemble fill-time inputs. +// readPermissionedVaultInventories is only invoked when vaults are configured. type fakeRecoveryReader struct { - decimals int - oracle map[common.Address]*big.Int - permInv []solverInventory - permErr error + permInv []solverInventory + permErr error } func (f *fakeRecoveryReader) readPermissionedVaultInventories( @@ -61,16 +60,6 @@ func (f *fakeRecoveryReader) readPermissionedVaultInventories( return f.permInv, f.permErr } -func (f *fakeRecoveryReader) tokenDecimals(context.Context, common.Address) (int, error) { - return f.decimals, nil -} - -func (f *fakeRecoveryReader) amountsOut( - context.Context, common.Address, []solverInventory, *big.Int, -) (map[common.Address]*big.Int, error) { - return f.oracle, nil -} - func (f *fakeRecoveryReader) resolveVaults(_ context.Context, vaults []recoveryVault) ([]recoveryVault, error) { return vaults, nil } @@ -95,20 +84,56 @@ func newExec(t *testing.T, st *store, be orderBackend, txm txSender) *executionS return &executionService{ chainID: 1, executor: common.HexToAddress("0x0000000000000000000000000000000000000010"), orderLimit: 20, backend: be, store: st, txm: txm, discountsEnabled: true, - log: logr.Discard(), now: func() time.Time { return time.Unix(0, 0) }, + strategy: fixedFillStrategy{plan: baseFillPlan()}, + log: logr.Discard(), now: func() time.Time { return time.Unix(0, 0) }, inflight: make(map[string]bool), } } -// seededStrategy + a backend order whose payload matches sampleOrder() from order_test.go. +type fixedFillStrategy struct { + plan *types.FillPlan + err error +} + +func (s fixedFillStrategy) DecideQuote( + context.Context, + types.QuoteInput, +) (types.QuoteOutput, error) { + return types.QuoteOutput{}, nil +} + +func (s fixedFillStrategy) BuildFillPlan( + context.Context, + types.FillInput, +) (*types.FillPlan, error) { + return s.plan, s.err +} + +func baseFillPlan() *types.FillPlan { + return &types.FillPlan{ + QuoteID: "q1", TokenIn: tIn, TokenOut: tOut, + AmountIn: big.NewInt(1_000000000000000000), QuotedAmountOut: big.NewInt(900000), + Legs: []types.FillLeg{{ + Adapter: vlt, AmountIn: big.NewInt(1_000000000000000000), AmountOut: big.NewInt(900000), + }}, + } +} + +func discountFillPlan(h common.Hash) *types.FillPlan { + return &types.FillPlan{ + QuoteID: "q1", TokenIn: tIn, TokenOut: tOut, + AmountIn: big.NewInt(1_000000000000000000), QuotedAmountOut: big.NewInt(900000), + Legs: []types.FillLeg{{ + Adapter: vlt, AmountIn: big.NewInt(1_000000000000000000), AmountOut: big.NewInt(900000), + MaxRate: big.NewInt(1), DiscountID: &h, + }}, + } +} + +// backend order whose payload matches sampleOrder() from order_test.go. func fillFixtures(t *testing.T) (*store, *fakeBackend) { t.Helper() st := newStore(func() time.Time { return time.Unix(0, 0) }) - st.putStrategy(&strategyRecord{ - QuoteID: "q1", TokenIn: tIn, TokenOut: tOut, Asset: tOut, AssetDecimals: 6, - AmountIn: big.NewInt(1_000000000000000000), QuotedAmountOut: big.NewInt(900000), AssetAmountOut: big.NewInt(900000), - Legs: []strategyLeg{{Adapter: vlt, AmountIn: big.NewInt(1_000000000000000000), AmountOut: big.NewInt(900000)}}, - }) encoded, err := orderTupleArgs.Pack(sampleOrder()) if err != nil { t.Fatalf("pack order: %v", err) @@ -157,13 +182,7 @@ func TestExecution_RevertMarksFailed(t *testing.T) { func TestExecution_DiscountFill(t *testing.T) { st, be := fillFixtures(t) - // Replace the cached strategy with a discount leg, and have the backend resolve the discount. h := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000ab") - st.putStrategy(&strategyRecord{ - QuoteID: "q1", TokenIn: tIn, TokenOut: tOut, Asset: tOut, AssetDecimals: 6, - AmountIn: big.NewInt(1_000000000000000000), QuotedAmountOut: big.NewInt(900000), AssetAmountOut: big.NewInt(900000), - Legs: []strategyLeg{{Adapter: vlt, AmountIn: big.NewInt(1_000000000000000000), AmountOut: big.NewInt(900000), MaxRate: big.NewInt(1), DiscountID: &h}}, - }) be.discount = &resolveDiscountResponse{ Discount: discountTerms{ Adapter: vlt.Hex(), TokenToRedeem: tIn.Hex(), Discount: "500", @@ -175,6 +194,7 @@ func TestExecution_DiscountFill(t *testing.T) { } txm := &fakeTxm{result: txmanager.Result{Hash: common.HexToHash("0xdead")}} e := newExec(t, st, be, txm) + e.strategy = fixedFillStrategy{plan: discountFillPlan(h)} e.syncOnce(context.Background()) @@ -190,12 +210,12 @@ func TestExecution_DiscountFill(t *testing.T) { } // TestExecution_DiscountOnlyRecovery_EmptyVaults proves a discount-only solver (no configured vaults) -// still recovers a strategy after a restart: recoverStrategy skips the direct (vault) read but consults -// the backend's live discounts, rebuilds a discount-leg strategy, and fills. (Regression: the old +// still rebuilds a fill plan after a restart: BuildFillPlan skips the direct (vault) read but consults +// the backend's live discounts, rebuilds a discount-leg plan, and fills. (Regression: the old // `len(vaults)==0` guard returned before the discount path ran.) func TestExecution_DiscountOnlyRecovery_EmptyVaults(t *testing.T) { _, be := fillFixtures(t) - st := newStore(func() time.Time { return time.Unix(0, 0) }) // empty store: no cached q1 → forces recovery + st := newStore(func() time.Time { return time.Unix(0, 0) }) h := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000ab") // Backend offers a live discount redeemable against tIn with collateral == tOut (the order's output). @@ -215,17 +235,16 @@ func TestExecution_DiscountOnlyRecovery_EmptyVaults(t *testing.T) { } txm := &fakeTxm{result: txmanager.Result{Hash: common.HexToHash("0xdead")}} e := newExec(t, st, be, txm) - // No vaults configured (discount-only solver); recovery reads decimals + oracle off the fake reader. - e.reader = &fakeRecoveryReader{decimals: 18, oracle: map[common.Address]*big.Int{tOut: big.NewInt(500000)}} + // No vaults configured (discount-only solver); fill-plan recovery prices via the default + // strategy's own dependency. + e.reader = &fakeRecoveryReader{} + e.strategy = newDefaultTestStrategy(18, map[common.Address]*big.Int{tOut: big.NewInt(500000)}) e.syncOnce(context.Background()) if rec := st.order("o1"); rec == nil || rec.Status != statusFilled { t.Fatalf("status = %v, want filled (discount-only recovery with empty vaults)", rec) } - if st.strategy("q1") == nil { - t.Fatalf("recovery did not persist a rebuilt strategy for q1") - } if be.resolveCalls != 1 { t.Fatalf("resolveDiscount calls = %d, want 1", be.resolveCalls) } @@ -239,12 +258,6 @@ func TestExecution_DiscountAdapterMismatchFails(t *testing.T) { // Strategy quotes a discount leg through vlt, but the backend resolves the discount to a // different adapter — the fill must be aborted without a tx. h := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000ab") - st.putStrategy(&strategyRecord{ - QuoteID: "q1", TokenIn: tIn, TokenOut: tOut, Asset: tOut, AssetDecimals: 6, - AmountIn: big.NewInt(1_000000000000000000), QuotedAmountOut: big.NewInt(900000), AssetAmountOut: big.NewInt(900000), - Legs: []strategyLeg{{Adapter: vlt, AmountIn: big.NewInt(1_000000000000000000), AmountOut: big.NewInt(900000), MaxRate: big.NewInt(1), DiscountID: &h}}, - CreatedAt: time.Unix(0, 0), // matches the frozen test clock so sweep keeps it across cycles - }) be.discount = &resolveDiscountResponse{ Discount: discountTerms{ Adapter: "0x00000000000000000000000000000000000000aa", // not the quoted leg's adapter @@ -257,6 +270,7 @@ func TestExecution_DiscountAdapterMismatchFails(t *testing.T) { } txm := &fakeTxm{result: txmanager.Result{Hash: common.HexToHash("0xdead")}} e := newExec(t, st, be, txm) + e.strategy = fixedFillStrategy{plan: discountFillPlan(h)} e.syncOnce(context.Background()) @@ -312,18 +326,19 @@ func TestExecution_DiscountInventoriesWhitelist(t *testing.T) { } } -func TestExecution_MissingStrategyFails(t *testing.T) { +func TestExecution_MissingFillPlanFails(t *testing.T) { _, be := fillFixtures(t) - st := newStore(func() time.Time { return time.Unix(0, 0) }) // empty store: no cached strategy, no vaults + st := newStore(func() time.Time { return time.Unix(0, 0) }) txm := &fakeTxm{result: txmanager.Result{}} e := newExec(t, st, be, txm) + e.strategy = fixedFillStrategy{} e.syncOnce(context.Background()) if rec := st.order("o1"); rec == nil || rec.Status != statusFailed { - t.Fatalf("status = %v, want failed (missing strategy)", rec) + t.Fatalf("status = %v, want failed (missing fill plan)", rec) } if txm.lastData != nil { - t.Fatalf("should not have sent a tx without a strategy") + t.Fatalf("should not have sent a tx without a fill plan") } } diff --git a/internal/solvers/rfq/gating_test.go b/internal/solvers/rfq/gating_test.go index 49c87b5a..61def08e 100644 --- a/internal/solvers/rfq/gating_test.go +++ b/internal/solvers/rfq/gating_test.go @@ -44,7 +44,7 @@ executor: "0x0000000000000000000000000000000000000010" solverMode: internal ` - cfg, err := parse(t, base+` + cfg, err := parseCfg(t, base+` tokensToQuote: permissioned permissionedTokens: - "0x2Ee6f1A395Bce7a7c5bF1D07bAaF9F8A0828A8d3" @@ -59,7 +59,7 @@ permissionedTokens: t.Errorf("expected mGLOBAL in PermissionedTokens") } - def, err := parse(t, base) + def, err := parseCfg(t, base) if err != nil { t.Fatalf("parse default: %v", err) } @@ -67,7 +67,7 @@ permissionedTokens: t.Errorf("default TokensToQuote = %q, want %q", def.TokensToQuote, tokensToQuoteAll) } - if _, err := parse(t, base+"tokensToQuote: bogus\n"); err == nil { + if _, err := parseCfg(t, base+"tokensToQuote: bogus\n"); err == nil { t.Errorf("expected error for invalid tokensToQuote") } } diff --git a/internal/solvers/rfq/order.go b/internal/solvers/rfq/order.go index 2c869b25..bed7ebc2 100644 --- a/internal/solvers/rfq/order.go +++ b/internal/solvers/rfq/order.go @@ -101,7 +101,7 @@ func encodeFill( // directSwaps maps a strategy's direct (non-discount) legs to the Executor's SwapInputs: each carries // its adapter and the per-adapter Swap tuple. The executor itself is the swap recipient (it forwards // outputs to the Reactor). Mirrors the swapInputs build in execution.ts (#submitOrder). -func directSwaps(selected *strategyRecord, tokenIn, executorAddr common.Address) []executor.IReactorSwapInput { +func directSwaps(selected *fillPlan, tokenIn, executorAddr common.Address) []executor.IReactorSwapInput { swaps := make([]executor.IReactorSwapInput, 0, len(selected.Legs)) for _, leg := range selected.Legs { if leg.DiscountID != nil { diff --git a/internal/solvers/rfq/order_test.go b/internal/solvers/rfq/order_test.go index e0b57b5e..033401ea 100644 --- a/internal/solvers/rfq/order_test.go +++ b/internal/solvers/rfq/order_test.go @@ -45,7 +45,7 @@ func sampleOrder() executor.IReactorOrder { func TestEncodeFill_SelectorMatchesMixedOverload(t *testing.T) { want := crypto.Keccak256([]byte(fillSignature))[:4] - swaps := directSwaps(&strategyRecord{Legs: []strategyLeg{{ + swaps := directSwaps(&fillPlan{Legs: []fillLeg{{ Adapter: vlt, AmountIn: big.NewInt(1_000000000000000000), AmountOut: big.NewInt(900000), }}}, tIn, common.HexToAddress("0x0000000000000000000000000000000000000010")) @@ -82,7 +82,7 @@ func TestDecodeOrder_RoundTrip(t *testing.T) { func TestDirectSwaps_SkipsDiscountLegs(t *testing.T) { h := common.HexToHash("0x01") - selected := &strategyRecord{Legs: []strategyLeg{ + selected := &fillPlan{Legs: []fillLeg{ {Adapter: vlt, AmountIn: big.NewInt(1), AmountOut: big.NewInt(2)}, {Adapter: vlt, AmountIn: big.NewInt(3), AmountOut: big.NewInt(4), DiscountID: &h}, // discount leg → skipped (P3) }} diff --git a/internal/solvers/rfq/quote.go b/internal/solvers/rfq/quote.go index 2cae2cad..d9b5a010 100644 --- a/internal/solvers/rfq/quote.go +++ b/internal/solvers/rfq/quote.go @@ -2,25 +2,18 @@ package rfq import ( "context" - "math/big" "strings" "time" "github.com/ethereum/go-ethereum/common" "github.com/go-errors/errors" "github.com/go-logr/logr" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" ) -// priceReader is the on-chain pricing surface the quote path needs (satisfied by *reader). It's an -// interface so the quote/HTTP logic can be unit-tested without a chain backend. -type priceReader interface { - tokenDecimals(ctx context.Context, token common.Address) (int, error) - amountsOut(ctx context.Context, tokenIn common.Address, inventories []solverInventory, amount *big.Int) (map[common.Address]*big.Int, error) -} - -// quoteService prices a backend RFQ request and persists the chosen strategy by quoteId. It is -// safe for concurrent use (the HTTP server serves quotes in parallel): its dependencies — the -// reader cache and the store — are individually synchronized, and it holds no mutable state itself. +// quoteService prices backend RFQ requests by handing filtered candidates to the strategy. It is safe +// for concurrent use (the HTTP server serves quotes in parallel): its dependencies are individually +// synchronized, and it holds no mutable state itself. type quoteService struct { chainID int64 executor common.Address @@ -29,8 +22,7 @@ type quoteService struct { // "permissionless" (see Config.TokensToQuote); evaluated against permissionedTokens. tokensToQuote string permissionedTokens map[common.Address]bool - reader priceReader - store *store + strategy types.Strategy log logr.Logger now func() time.Time } @@ -58,34 +50,27 @@ func (qs *quoteService) quote(ctx context.Context, q *quoteRequest) (*quoteRespo return nil, nil } - tokenInDecimals, err := qs.reader.tokenDecimals(ctx, req.TokenIn) - if err != nil { - return nil, errors.Errorf("quote: tokenIn decimals: %w", err) - } - - // Only asset == tokenOut is fillable, so we price exactly those inventories (each priced through - // its asset-group's representative adapter — see reader.amountsOut). - matching := matchingInventories(inv, req.TokenOut) - oracle, err := qs.reader.amountsOut(ctx, req.TokenIn, matching, req.Amount) + input := newQuoteInput(qs.chainID, qs.executor, req, inv, nil, qs.now()) + out, err := qs.strategy.DecideQuote(ctx, input) if err != nil { - return nil, errors.Errorf("quote: adapter getAmountOut: %w", err) + return nil, errors.Errorf("quote: strategy: %w", err) } - - best := selectBestStrategy(req, inv, tokenInDecimals, oracle, qs.now()) - if best == nil { + if out.Decision != types.DecisionQuote { qs.log.V(1).Info("declining quote: no viable strategy", "quoteId", q.QuoteID) return nil, nil } - qs.store.putStrategy(best) + if out.QuotedAmountOut == nil { + return nil, errors.New("quote: strategy returned quote without amountOut") + } qs.log.V(1).Info("quoted", "quoteId", q.QuoteID, "amountIn", req.Amount.String(), - "amountOut", best.QuotedAmountOut.String(), "legs", len(best.Legs)) + "amountOut", out.QuotedAmountOut.String(), "legs", len(out.Legs)) return "eResponse{ ChainID: qs.chainID, AmountIn: req.Amount.String(), - AmountOut: best.QuotedAmountOut.String(), + AmountOut: out.QuotedAmountOut.String(), Filler: lowerAddr(qs.executor), RequestID: q.RequestID, Swapper: lowerAddr(common.HexToAddress(q.Swapper)), // backend payloads use lowercase addresses @@ -95,19 +80,6 @@ func (qs *quoteService) quote(ctx context.Context, q *quoteRequest) (*quoteRespo }, nil } -// matchingInventories returns the inventories whose asset equals tokenOut (the only ones this filler -// can source), so the oracle prices exactly the asset-groups the selector will consider. -func matchingInventories(inv []solverInventory, tokenOut common.Address) []solverInventory { - out := make([]solverInventory, 0, len(inv)) - for _, v := range inv { - if v.Asset != tokenOut { - continue - } - out = append(out, v) - } - return out -} - // lowerAddr renders an address as lowercase hex; RFQ backend payloads use lowercase addresses. func lowerAddr(a common.Address) string { return strings.ToLower(a.Hex()) } diff --git a/internal/solvers/rfq/server_test.go b/internal/solvers/rfq/server_test.go index f3f5ce67..b0c98b35 100644 --- a/internal/solvers/rfq/server_test.go +++ b/internal/solvers/rfq/server_test.go @@ -2,7 +2,6 @@ package rfq import ( "bytes" - "context" "encoding/json" "io" "math/big" @@ -16,39 +15,15 @@ import ( "github.com/go-logr/logr" ) -// fakeReader stands in for the on-chain pricing reads in HTTP tests. -type fakeReader struct { - decimals int - oracle map[common.Address]*big.Int -} - -func (f fakeReader) tokenDecimals(context.Context, common.Address) (int, error) { - return f.decimals, nil -} - -func (f fakeReader) amountsOut( - _ context.Context, _ common.Address, inventories []solverInventory, _ *big.Int, -) (map[common.Address]*big.Int, error) { - out := make(map[common.Address]*big.Int) - for _, inv := range inventories { - if v, ok := f.oracle[inv.Asset]; ok { - out[inv.Asset] = v - } - } - return out, nil -} - const testSecret = "s3cr3t" func testServer() *server { execAddr := common.HexToAddress("0x0000000000000000000000000000000000000010") clk := func() time.Time { return time.Unix(0, 0) } - st := newStore(clk) q := "eService{ chainID: 1, executor: execAddr, - reader: fakeReader{decimals: 18, oracle: map[common.Address]*big.Int{tOut: big.NewInt(1_000000)}}, - store: st, + strategy: newDefaultTestStrategy(18, map[common.Address]*big.Int{tOut: big.NewInt(1_000000)}), log: logr.Discard(), now: clk, } @@ -170,16 +145,14 @@ func TestServer_QuoteWhitelist(t *testing.T) { MaxAssets: "10000000", MaxRate: "2000000000000000000", } cases := map[string]struct { - whitelist adapterWhitelist - adapters []quoteAdapter // nil keeps validQuoteBody's single vlt adapter - wantCode int - wantAdapter common.Address // the stored strategy's single leg (200 only) + whitelist adapterWhitelist + adapters []quoteAdapter // nil keeps validQuoteBody's single vlt adapter + wantCode int }{ "drops non-whitelisted adapters": { - whitelist: buildAdapterWhitelist(true, []recoveryVault{{Adapter: vlt}}), - adapters: append(validQuoteBody().Adapters, rogueAdapter), - wantCode: http.StatusOK, - wantAdapter: vlt, + whitelist: buildAdapterWhitelist(true, []recoveryVault{{Adapter: vlt}}), + adapters: append(validQuoteBody().Adapters, rogueAdapter), + wantCode: http.StatusOK, }, "no whitelisted adapter declines": { whitelist: buildAdapterWhitelist(true, []recoveryVault{{Adapter: rogue}}), @@ -190,10 +163,9 @@ func TestServer_QuoteWhitelist(t *testing.T) { wantCode: http.StatusNoContent, }, "disabled keeps all adapters": { - whitelist: buildAdapterWhitelist(false, []recoveryVault{{Adapter: vlt}}), - adapters: []quoteAdapter{rogueAdapter}, // only a non-configured adapter: still quoted - wantCode: http.StatusOK, - wantAdapter: rogue, + whitelist: buildAdapterWhitelist(false, []recoveryVault{{Adapter: vlt}}), + adapters: []quoteAdapter{rogueAdapter}, // only a non-configured adapter: still quoted + wantCode: http.StatusOK, }, } for name, tc := range cases { @@ -210,15 +182,15 @@ func TestServer_QuoteWhitelist(t *testing.T) { t.Fatalf("quote = %d, want %d (body %s)", rr.Code, tc.wantCode, rr.Body.String()) } - stored := srv.quotes.store.strategy(body.QuoteID) if tc.wantCode == http.StatusNoContent { - if stored != nil { - t.Fatalf("no strategy should be stored for a declined quote, got %+v", stored) - } return } - if stored == nil || len(stored.Legs) != 1 || stored.Legs[0].Adapter != tc.wantAdapter { - t.Fatalf("stored strategy = %+v, want a single leg through %s", stored, tc.wantAdapter.Hex()) + var resp quoteResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode quote response: %v", err) + } + if resp.AmountOut == "" { + t.Fatalf("quote response missing amountOut: %+v", resp) } }) } diff --git a/internal/solvers/rfq/solver.go b/internal/solvers/rfq/solver.go index e3956680..e8fe2faf 100644 --- a/internal/solvers/rfq/solver.go +++ b/internal/solvers/rfq/solver.go @@ -11,9 +11,12 @@ import ( "github.com/go-errors/errors" "github.com/go-logr/logr" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" "gopkg.in/yaml.v3" "github.com/symbioticfi/vault-solver/internal/solver" + _ "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/default" + _ "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/webhook" ) // Name is the registry key that selects this solver from config. @@ -46,6 +49,10 @@ func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { log := deps.Log.WithName(Name) st := newStore(time.Now) rdr := newReader(deps.Chain, log) + quoteStrategy, err := newStrategy(cfg.Strategy, deps.Chain, log) + if err != nil { + return nil, err + } var metrics *httpMetrics if deps.Metrics != nil { @@ -54,7 +61,7 @@ func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { } } - quotes, exec := buildServices(cfg, chainID, st, rdr, deps.TxManager, log) + quotes, exec := buildServices(cfg, chainID, st, rdr, deps.TxManager, quoteStrategy, log) return &Solver{ cfg: cfg, exec: exec, @@ -72,7 +79,7 @@ func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { // Split from factory so the config → service wiring (notably the adapter whitelist reaching both // services) is unit-testable without a chain client. func buildServices( - cfg *Config, chainID int64, st *store, rdr *reader, txm txSender, log logr.Logger, + cfg *Config, chainID int64, st *store, rdr *reader, txm txSender, quoteStrategy types.Strategy, log logr.Logger, ) (*quoteService, *executionService) { // The quote and execution paths scope to adapters independently. Quoting uses quoteScopesToAdapters() // so an internal-mode filler with configured adapters advertises quotes only for its own adapter @@ -88,8 +95,7 @@ func buildServices( whitelist: quoteWhitelist, tokensToQuote: cfg.TokensToQuote, permissionedTokens: cfg.PermissionedTokens, - reader: rdr, - store: st, + strategy: quoteStrategy, log: log, now: time.Now, } @@ -103,6 +109,7 @@ func buildServices( backend: newBackendClient(cfg.BackendURL), store: st, reader: rdr, + strategy: quoteStrategy, txm: txm, log: log, now: time.Now, diff --git a/internal/solvers/rfq/solver_test.go b/internal/solvers/rfq/solver_test.go index d82f451a..1c25e99e 100644 --- a/internal/solvers/rfq/solver_test.go +++ b/internal/solvers/rfq/solver_test.go @@ -37,14 +37,14 @@ func TestBuildServices_WhitelistWiring(t *testing.T) { // External + configured adapters ⇒ both quote and execution scope to the configured adapters. cfg.SolverMode = solverModeExternal - quotes, exec := buildServices(cfg, 1, st, nil, nil, logr.Discard()) + quotes, exec := buildServices(cfg, 1, st, nil, nil, nil, logr.Discard()) scopedToConfigured(t, "quote", quotes.whitelist) scopedToConfigured(t, "execution", exec.whitelist) // Internal + configured adapters ⇒ the QUOTE path scopes to the configured adapters, but execution // stays unrestricted (nil) so discount recovery can fill through any advertised adapter. cfg.SolverMode = solverModeInternal - quotes, exec = buildServices(cfg, 1, st, nil, nil, logr.Discard()) + quotes, exec = buildServices(cfg, 1, st, nil, nil, nil, logr.Discard()) scopedToConfigured(t, "quote", quotes.whitelist) if exec.whitelist != nil { t.Fatalf("internal mode: execution whitelist = %v, want nil (filling stays unrestricted)", exec.whitelist) @@ -52,7 +52,7 @@ func TestBuildServices_WhitelistWiring(t *testing.T) { // Internal + no adapters ⇒ neither path scopes (both nil): the filler quotes/fills off discounts only. cfg.Adapters = nil - quotes, exec = buildServices(cfg, 1, st, nil, nil, logr.Discard()) + quotes, exec = buildServices(cfg, 1, st, nil, nil, nil, logr.Discard()) if quotes.whitelist != nil || exec.whitelist != nil { t.Fatal("internal mode with no adapters should wire both whitelists nil (filtering off)") } @@ -74,10 +74,10 @@ func TestBuildServices_InternalModeQuoteScoping(t *testing.T) { Adapters: []recoveryVault{{Adapter: vlt}}, // the only adapter this filler is scoped to } - quotes, _ := buildServices(cfg, 1, st, nil, nil, logr.Discard()) - // buildServices wires a *reader; swap in the in-memory pricing fake (priceReader is the quote path's - // only chain dependency). The oracle prices the tOut asset-group at 1.000000 USDC. - quotes.reader = fakeReader{decimals: 18, oracle: map[common.Address]*big.Int{tOut: big.NewInt(1_000000)}} + quotes, _ := buildServices(cfg, 1, st, nil, nil, nil, logr.Discard()) + // buildServices wires real dependencies; swap in test fakes. The default strategy prices the tOut + // asset-group at 1.000000 USDC. + quotes.strategy = newDefaultTestStrategy(18, map[common.Address]*big.Int{tOut: big.NewInt(1_000000)}) rogue := common.HexToAddress("0x00000000000000000000000000000000000000aa") rogueAdapter := quoteAdapter{ @@ -96,10 +96,6 @@ func TestBuildServices_InternalModeQuoteScoping(t *testing.T) { if resp != nil { t.Fatalf("quote (only non-configured adapter): got %+v, want nil (declined: out of adapter scope)", resp) } - if stored := quotes.store.strategy(onlyRogue.QuoteID); stored != nil { - t.Fatalf("no strategy should be stored for an out-of-scope quote, got %+v", stored) - } - // (2) Request offering the configured adapter alongside the rogue one ⇒ quoted through the configured // adapter only (the rogue leg, despite a better rate, is filtered out before selection). mixed := validQuoteBody() // validQuoteBody's single adapter is vlt (the configured one) @@ -111,8 +107,7 @@ func TestBuildServices_InternalModeQuoteScoping(t *testing.T) { if resp == nil { t.Fatal("quote (configured + rogue): got nil, want a quote through the configured adapter") } - stored := quotes.store.strategy(mixed.QuoteID) - if stored == nil || len(stored.Legs) != 1 || stored.Legs[0].Adapter != vlt { - t.Fatalf("stored strategy = %+v, want a single leg through the configured adapter %s", stored, vlt.Hex()) + if resp.AmountOut != "1000000" { + t.Fatalf("amountOut = %s, want quote through the configured adapter", resp.AmountOut) } } diff --git a/internal/solvers/rfq/store.go b/internal/solvers/rfq/store.go index 2d2c1f12..f58b1d8c 100644 --- a/internal/solvers/rfq/store.go +++ b/internal/solvers/rfq/store.go @@ -5,6 +5,8 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/parse" ) // orderStatus is the local order lifecycle. queued → submitting → submitted → {filled|expired|failed}. @@ -24,10 +26,6 @@ func (s orderStatus) active() bool { } const ( - // strategyTTL bounds how long a quoted strategy is cached before eviction. A later award that - // misses the cache is rebuilt from on-chain state by recoverStrategy, so this only caps memory; - // it does not drop fillable orders. - strategyTTL = 3 * time.Hour // terminalOrderTTL is how long terminal orders (and their attempt counts) are retained for // reconciliation/observability before eviction. terminalOrderTTL = 3 * time.Hour @@ -54,51 +52,26 @@ type queuedOrder struct { // store is the filler's in-memory operational state. The HTTP server and the poll loop touch it // concurrently, so every accessor is mutex-guarded. type store struct { - mu sync.Mutex - strategies map[string]*strategyRecord // by quoteId - orders map[string]*orderRecord // by orderId - attempts map[string]int // by orderId - now func() time.Time + mu sync.Mutex + orders map[string]*orderRecord // by orderId + attempts map[string]int // by orderId + now func() time.Time } func newStore(now func() time.Time) *store { return &store{ - strategies: make(map[string]*strategyRecord), - orders: make(map[string]*orderRecord), - attempts: make(map[string]int), - now: now, + orders: make(map[string]*orderRecord), + attempts: make(map[string]int), + now: now, } } -/* ───────── strategies ───────── */ - -func (s *store) putStrategy(rec *strategyRecord) { - s.mu.Lock() - defer s.mu.Unlock() - s.strategies[rec.QuoteID] = rec -} - -// strategy returns the cached strategy for quoteID, or nil. It returns the shared pointer (not a -// clone): a strategyRecord is immutable after putStrategy, so concurrent readers are safe. Do not -// mutate a returned record in place — copy it, or that invariant (and the lack of a data race) breaks. -func (s *store) strategy(quoteID string) *strategyRecord { - s.mu.Lock() - defer s.mu.Unlock() - return s.strategies[quoteID] -} - // sweep evicts stale entries so the in-memory maps don't grow without bound over a long run: -// strategies older than strategyTTL, and terminal orders (with their attempt counts) untouched for -// longer than terminalOrderTTL. Called from the poll loop. +// terminal orders (with their attempt counts) untouched for longer than terminalOrderTTL. func (s *store) sweep() { s.mu.Lock() defer s.mu.Unlock() now := s.now() - for id, rec := range s.strategies { - if now.Sub(rec.CreatedAt) > strategyTTL { - delete(s.strategies, id) - } - } for id, rec := range s.orders { if !rec.Status.active() && now.Sub(rec.UpdatedAt) > terminalOrderTTL { delete(s.orders, id) @@ -129,7 +102,7 @@ func (s *store) upsertQueued(in queuedOrder) { rec.Status = statusQueued rec.LastError = "" } - rec.QuoteID = orStr(in.QuoteID, rec.QuoteID) + rec.QuoteID = parse.OrDefault(in.QuoteID, rec.QuoteID) rec.UpdatedAt = now } diff --git a/internal/solvers/rfq/strategies/default/chainreader.go b/internal/solvers/rfq/strategies/default/chainreader.go new file mode 100644 index 00000000..26ac3ecd --- /dev/null +++ b/internal/solvers/rfq/strategies/default/chainreader.go @@ -0,0 +1,87 @@ +package defaultstrategy + +import ( + "context" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" + + "github.com/symbioticfi/vault-solver/api/bindings/liquidlane/adapter" + "github.com/symbioticfi/vault-solver/internal/chain" +) + +var ( + llAdapter = adapter.NewLiquidLaneAdapter() +) + +// ChainReader is the on-chain pricing surface used by the RFQ default strategy. +type ChainReader struct { + chain *chain.Client + log logr.Logger + dec *chain.Decimals +} + +func NewChainReader(c *chain.Client, log logr.Logger) *ChainReader { + return &ChainReader{chain: c, log: log, dec: chain.NewDecimals(c)} +} + +func (r *ChainReader) TokenDecimals(ctx context.Context, token common.Address) (int, error) { + return r.dec.Get(ctx, token) +} + +func (r *ChainReader) AmountsOut( + ctx context.Context, + tokenIn common.Address, + candidates []types.QuoteCandidate, + amount *big.Int, +) (map[common.Address]*big.Int, error) { + type group struct { + asset common.Address + adapter common.Address + } + var groups []group + seen := make(map[common.Address]bool, len(candidates)) + for _, c := range candidates { + if seen[c.Asset] { + continue + } + seen[c.Asset] = true + groups = append(groups, group{asset: c.Asset, adapter: c.Adapter}) + } + if len(groups) == 0 { + return map[common.Address]*big.Int{}, nil + } + + calls := make([]chain.Call, len(groups)) + for i, g := range groups { + calls[i] = chain.Call{ + Target: g.adapter, + AllowFailure: true, + Data: llAdapter.PackGetAmountOut(tokenIn, amount), + } + } + res, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if len(res) != len(calls) { + return nil, errors.Errorf("amountsOut: got %d results for %d calls", len(res), len(calls)) + } + out := make(map[common.Address]*big.Int, len(groups)) + for i, rr := range res { + if !rr.Success { + r.log.V(1).Info("getAmountOut reverted; asset left unpriced", "asset", groups[i].asset.Hex()) + continue + } + amt, derr := llAdapter.UnpackGetAmountOut(rr.ReturnData) + if derr != nil { + r.log.V(1).Error(derr, "getAmountOut decode failed; asset left unpriced", "asset", groups[i].asset.Hex()) + continue + } + out[groups[i].asset] = amt + } + return out, nil +} diff --git a/internal/solvers/rfq/strategies/default/strategy.go b/internal/solvers/rfq/strategies/default/strategy.go new file mode 100644 index 00000000..2fe7b24c --- /dev/null +++ b/internal/solvers/rfq/strategies/default/strategy.go @@ -0,0 +1,441 @@ +package defaultstrategy + +import ( + "context" + "math/big" + "sort" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/liquidlanemath" + "github.com/symbioticfi/vault-solver/internal/solver" +) + +const Name = "default" +const fillPlanTTL = 3 * time.Hour + +type Config struct{} + +type Strategy struct { + pricing types.Pricing + now func() time.Time + + mu sync.Mutex + plans map[string]cachedFillPlan +} + +type cachedFillPlan struct { + plan *types.FillPlan + createdAt time.Time +} + +//nolint:gochecknoinits // solver-local strategy self-registration mirrors solver registration. +func init() { + strategies.Register(Name, NewFromConfig) +} + +func NewFromConfig(raw yaml.Node, deps strategies.Deps) (types.Strategy, error) { + var cfg Config + if err := decodeConfig(raw, &cfg); err != nil { + return nil, err + } + return New(NewChainReader(deps.Chain, deps.Log)), nil +} + +func New(pricing types.Pricing) *Strategy { + return &Strategy{pricing: pricing, now: time.Now, plans: make(map[string]cachedFillPlan)} +} + +func decodeConfig(node yaml.Node, out any) error { + if node.Kind == 0 { + node = yaml.Node{Kind: yaml.MappingNode} + } + return solver.DecodeStrict(node, out) +} + +func (s *Strategy) DecideQuote(ctx context.Context, input types.QuoteInput) (types.QuoteOutput, error) { + tokenInDecimals, err := s.pricing.TokenDecimals(ctx, input.TokenIn) + if err != nil { + return types.QuoteOutput{}, errors.Errorf("tokenIn decimals: %w", err) + } + candidates := matchingCandidates(input.Candidates, input.TokenOut) + oracle, err := s.pricing.AmountsOut(ctx, input.TokenIn, candidates, input.AmountIn) + if err != nil { + return types.QuoteOutput{}, err + } + out, ok := selectBest(input, candidates, tokenInDecimals, oracle) + if !ok { + return types.QuoteOutput{Decision: types.DecisionDecline, Reason: "no viable strategy"}, nil + } + plan, err := s.fillPlanFromQuote(input, out, tokenInDecimals) + if err != nil { + return types.QuoteOutput{}, err + } + s.remember(input.QuoteID, plan) + return out, nil +} + +func (s *Strategy) BuildFillPlan(ctx context.Context, input types.FillInput) (*types.FillPlan, error) { + if plan := s.cached(input); plan != nil { + return plan, nil + } + quoteInput := types.QuoteInput(input) + quoteInput.AmountIn = cloneBig(input.AmountIn) + quoteInput.RequiredAmountOut = cloneBig(input.RequiredAmountOut) + out, err := s.DecideQuote(ctx, quoteInput) + if err != nil || out.Decision == types.DecisionDecline { + return nil, err + } + plan := s.cached(input) + if plan == nil { + return nil, errors.New("rebuilt fill plan was not cached") + } + return plan, nil +} + +func matchingCandidates( + candidates []types.QuoteCandidate, + tokenOut common.Address, +) []types.QuoteCandidate { + out := make([]types.QuoteCandidate, 0, len(candidates)) + for _, c := range candidates { + if c.Asset != tokenOut { + continue + } + out = append(out, c) + } + return out +} + +// selectBest picks the best single-asset strategy for the request. It is a pure function: +// oracleByAsset supplies adapter.getAmountOut(tokenIn, amount) per candidate asset. +func selectBest( + input types.QuoteInput, + candidates []types.QuoteCandidate, + tokenInDecimals int, + oracleByAsset map[common.Address]*big.Int, +) (types.QuoteOutput, bool) { + groups := groupByAsset(candidates) + + keys := make([]common.Address, 0, len(groups)) + for k := range groups { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return keys[i].Hex() < keys[j].Hex() }) + + var best *types.QuoteOutput + for _, asset := range keys { + oracle := oracleByAsset[asset] + if oracle == nil { + continue + } + cand := evaluateGroup(input, groups[asset], tokenInDecimals, oracle) + if cand == nil { + continue + } + if best == nil || cand.QuotedAmountOut.Cmp(best.QuotedAmountOut) > 0 { + candidate := *cand + best = &candidate + } + } + if best == nil { + return types.QuoteOutput{}, false + } + return *best, true +} + +func groupByAsset(candidates []types.QuoteCandidate) map[common.Address][]types.QuoteCandidate { + groups := make(map[common.Address][]types.QuoteCandidate) + for _, c := range candidates { + groups[c.Asset] = append(groups[c.Asset], c) + } + return groups +} + +type eligibleLeg struct { + candidate types.QuoteCandidate + rate *big.Int +} + +func evaluateGroup( + input types.QuoteInput, + group []types.QuoteCandidate, + tokenInDecimals int, + oracleAmountOut *big.Int, +) *types.QuoteOutput { + asset := group[0].Asset + assetDecimals := group[0].AssetDecimals + if asset != input.TokenOut { + return nil + } + + privateRate := liquidlanemath.RateForAmountOut(oracleAmountOut, input.AmountIn, tokenInDecimals, assetDecimals) + + eligible := make([]eligibleLeg, 0, len(group)) + for _, c := range group { + effRate := privateRate + if c.DiscountID != nil { + effRate = c.MaxRate + } + if effRate.Sign() <= 0 { + continue + } + if c.DiscountID == nil && c.MaxRate.Cmp(effRate) < 0 { + continue + } + eligible = append(eligible, eligibleLeg{candidate: c, rate: effRate}) + } + + sort.SliceStable(eligible, func(i, j int) bool { + if c := eligible[i].rate.Cmp(eligible[j].rate); c != 0 { + return c > 0 + } + if c := eligible[i].candidate.MaxAssets.Cmp(eligible[j].candidate.MaxAssets); c != 0 { + return c > 0 + } + return eligible[i].candidate.MaxRate.Cmp(eligible[j].candidate.MaxRate) > 0 + }) + eligible = dedupeByAdapter(eligible) + if len(eligible) == 0 { + return nil + } + + remainingIn := new(big.Int).Set(input.AmountIn) + quotedAmountOut := new(big.Int) + var legs []types.QuoteLeg + for _, e := range eligible { + if remainingIn.Sign() == 0 { + break + } + c := e.candidate + maxAmountIn := liquidlanemath.MaxAmountInForRate(c.MaxAssets, e.rate, tokenInDecimals, c.AssetDecimals) + if maxAmountIn.Sign() == 0 { + continue + } + saturated := remainingIn.Cmp(maxAmountIn) > 0 + + var amountIn, amountOut *big.Int + if saturated { + amountIn = liquidlanemath.MinAmountInForAmountOut(c.MaxAssets, e.rate, tokenInDecimals, c.AssetDecimals) + amountOut = new(big.Int).Set(c.MaxAssets) + } else { + amountIn = new(big.Int).Set(remainingIn) + amountOut = liquidlanemath.AmountOutForRate(amountIn, e.rate, tokenInDecimals, c.AssetDecimals) + } + if amountOut.Sign() == 0 { + continue + } + + remainingIn.Sub(remainingIn, amountIn) + quotedAmountOut.Add(quotedAmountOut, amountOut) + legs = append(legs, types.QuoteLeg{ + CandidateID: c.ID, + AmountIn: amountIn, + AmountOut: amountOut, + }) + } + if len(legs) == 0 { + return nil + } + if remainingIn.Sign() != 0 { + return nil + } + + return &types.QuoteOutput{ + Decision: types.DecisionQuote, + QuotedAmountOut: quotedAmountOut, + Legs: legs, + } +} + +func dedupeByAdapter(legs []eligibleLeg) []eligibleLeg { + seen := make(map[string]bool, len(legs)) + out := legs[:0] + for _, e := range legs { + key := e.candidate.Adapter.Hex() + ":" + e.candidate.Asset.Hex() + if seen[key] { + continue + } + seen[key] = true + out = append(out, e) + } + return out +} + +func (s *Strategy) fillPlanFromQuote( + input types.QuoteInput, + out types.QuoteOutput, + tokenInDecimals int, +) (*types.FillPlan, error) { + if out.Decision != types.DecisionQuote { + return nil, errors.Errorf("invalid fill-plan decision %q", out.Decision) + } + if len(out.Legs) == 0 { + return nil, errors.New("quote output has no legs") + } + if out.QuotedAmountOut == nil || out.QuotedAmountOut.Sign() <= 0 { + return nil, errors.New("quote output has invalid quotedAmountOut") + } + + candidates := make(map[string]types.QuoteCandidate, len(input.Candidates)) + for _, c := range input.Candidates { + if c.ID == "" { + return nil, errors.New("candidate id is empty") + } + if _, ok := candidates[c.ID]; ok { + return nil, errors.Errorf("duplicate candidate id %q", c.ID) + } + candidates[c.ID] = c + } + + sumIn := new(big.Int) + sumOut := new(big.Int) + seen := make(map[string]bool, len(out.Legs)) + legs := make([]types.FillLeg, 0, len(out.Legs)) + for i, leg := range out.Legs { + if seen[leg.CandidateID] { + return nil, errors.Errorf("duplicate candidate %q", leg.CandidateID) + } + seen[leg.CandidateID] = true + c, ok := candidates[leg.CandidateID] + if !ok { + return nil, errors.Errorf("unknown candidate %q", leg.CandidateID) + } + if c.Asset != input.TokenOut { + return nil, errors.Errorf("candidate %q asset does not match tokenOut", leg.CandidateID) + } + if leg.AmountIn == nil || leg.AmountIn.Sign() <= 0 { + return nil, errors.Errorf("leg %d has invalid amountIn", i) + } + if leg.AmountOut == nil || leg.AmountOut.Sign() <= 0 { + return nil, errors.Errorf("leg %d has invalid amountOut", i) + } + if c.MaxAssets == nil || c.MaxAssets.Sign() <= 0 { + return nil, errors.Errorf("candidate %q has invalid maxAssets", leg.CandidateID) + } + if leg.AmountOut.Cmp(c.MaxAssets) > 0 { + return nil, errors.Errorf("leg %d exceeds candidate maxAssets", i) + } + if c.MaxRate == nil || c.MaxRate.Sign() <= 0 { + return nil, errors.Errorf("candidate %q has invalid maxRate", leg.CandidateID) + } + maxAmountOut := liquidlanemath.AmountOutForRate(leg.AmountIn, c.MaxRate, tokenInDecimals, c.AssetDecimals) + if leg.AmountOut.Cmp(maxAmountOut) > 0 { + return nil, errors.Errorf("leg %d exceeds candidate maxRate", i) + } + sumIn.Add(sumIn, leg.AmountIn) + sumOut.Add(sumOut, leg.AmountOut) + legs = append(legs, types.FillLeg{ + Adapter: c.Adapter, + AmountIn: cloneBig(leg.AmountIn), + AmountOut: cloneBig(leg.AmountOut), + MaxRate: cloneBig(c.MaxRate), + DiscountID: cloneHash(c.DiscountID), + }) + } + if sumIn.Cmp(input.AmountIn) != 0 { + return nil, errors.Errorf("strategy amountIn sum %s does not match request %s", sumIn, input.AmountIn) + } + if sumOut.Cmp(out.QuotedAmountOut) != 0 { + return nil, errors.Errorf("strategy amountOut sum %s does not match quotedAmountOut %s", sumOut, out.QuotedAmountOut) + } + if input.RequiredAmountOut != nil && out.QuotedAmountOut.Cmp(input.RequiredAmountOut) < 0 { + return nil, errors.New("strategy output is below required amount out") + } + return &types.FillPlan{ + QuoteID: input.QuoteID, + RequestID: input.RequestID, + TokenIn: input.TokenIn, + TokenOut: input.TokenOut, + AmountIn: cloneBig(input.AmountIn), + QuotedAmountOut: cloneBig(out.QuotedAmountOut), + Legs: legs, + }, nil +} + +func (s *Strategy) remember(quoteID string, plan *types.FillPlan) { + if quoteID == "" || plan == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + for id, cached := range s.plans { + if now.Sub(cached.createdAt) > fillPlanTTL { + delete(s.plans, id) + } + } + s.plans[quoteID] = cachedFillPlan{plan: clonePlan(plan), createdAt: now} +} + +func (s *Strategy) cached(input types.FillInput) *types.FillPlan { + s.mu.Lock() + cached, ok := s.plans[input.QuoteID] + s.mu.Unlock() + if !ok || s.now().Sub(cached.createdAt) > fillPlanTTL { + return nil + } + plan := clonePlan(cached.plan) + if err := validateCachedPlan(input, plan); err != nil { + return nil + } + return plan +} + +func validateCachedPlan(input types.FillInput, plan *types.FillPlan) error { + if plan == nil { + return errors.New("cached fill plan is nil") + } + if plan.TokenIn != input.TokenIn || plan.TokenOut != input.TokenOut { + return errors.New("cached fill plan token mismatch") + } + if plan.AmountIn == nil || input.AmountIn == nil || plan.AmountIn.Cmp(input.AmountIn) != 0 { + return errors.New("cached fill plan amountIn mismatch") + } + if input.RequiredAmountOut != nil && + (plan.QuotedAmountOut == nil || plan.QuotedAmountOut.Cmp(input.RequiredAmountOut) < 0) { + return errors.New("cached fill plan output is below required amount out") + } + return nil +} + +func clonePlan(in *types.FillPlan) *types.FillPlan { + if in == nil { + return nil + } + out := *in + out.AmountIn = cloneBig(in.AmountIn) + out.QuotedAmountOut = cloneBig(in.QuotedAmountOut) + out.Legs = make([]types.FillLeg, len(in.Legs)) + for i, leg := range in.Legs { + out.Legs[i] = types.FillLeg{ + Adapter: leg.Adapter, + AmountIn: cloneBig(leg.AmountIn), + AmountOut: cloneBig(leg.AmountOut), + MaxRate: cloneBig(leg.MaxRate), + DiscountID: cloneHash(leg.DiscountID), + } + } + return &out +} + +func cloneBig(n *big.Int) *big.Int { + if n == nil { + return nil + } + return new(big.Int).Set(n) +} + +func cloneHash(h *common.Hash) *common.Hash { + if h == nil { + return nil + } + out := *h + return &out +} diff --git a/internal/solvers/rfq/strategies/default/strategy_test.go b/internal/solvers/rfq/strategies/default/strategy_test.go new file mode 100644 index 00000000..c69a39fa --- /dev/null +++ b/internal/solvers/rfq/strategies/default/strategy_test.go @@ -0,0 +1,189 @@ +package defaultstrategy + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" +) + +func mustBig(t *testing.T, s string) *big.Int { + t.Helper() + n, ok := new(big.Int).SetString(s, 10) + if !ok { + t.Fatalf("bad big.Int %q", s) + } + return n +} + +var ( + tIn = common.HexToAddress("0x0000000000000000000000000000000000000001") + tOut = common.HexToAddress("0x0000000000000000000000000000000000000002") + vlt = common.HexToAddress("0x0000000000000000000000000000000000000003") +) + +type fakePricing struct { + out map[common.Address]*big.Int + queries *[][]types.QuoteCandidate +} + +func (f fakePricing) TokenDecimals(context.Context, common.Address) (int, error) { + return 18, nil +} + +func (f fakePricing) AmountsOut( + _ context.Context, + _ common.Address, + candidates []types.QuoteCandidate, + _ *big.Int, +) (map[common.Address]*big.Int, error) { + if f.queries != nil { + *f.queries = append(*f.queries, append([]types.QuoteCandidate(nil), candidates...)) + } + return f.out, nil +} + +func baseInput(t *testing.T, candidates []types.QuoteCandidate) types.QuoteInput { + t.Helper() + return types.QuoteInput{ + RequestID: "r", + QuoteID: "q", + ChainID: 1, + Executor: common.HexToAddress("0x0000000000000000000000000000000000000010"), + TokenIn: tIn, + TokenOut: tOut, + AmountIn: mustBig(t, "1000000000000000000"), + Candidates: candidates, + Now: time.Unix(0, 0), + } +} + +func TestStrategyDirectFill(t *testing.T) { + input := baseInput(t, []types.QuoteCandidate{{ + ID: "c0", Adapter: vlt, Asset: tOut, AssetDecimals: 6, + MaxAssets: mustBig(t, "10000000"), + MaxRate: mustBig(t, "1000000000000000000"), + }}) + got, err := New(fakePricing{out: map[common.Address]*big.Int{tOut: mustBig(t, "1000000")}}).DecideQuote(t.Context(), input) + if err != nil { + t.Fatalf("DecideQuote: %v", err) + } + if got.Decision != types.DecisionQuote || got.QuotedAmountOut.String() != "1000000" { + t.Fatalf("output = %+v, want 1.000000 quote", got) + } + if len(got.Legs) != 1 { + t.Fatalf("legs = %d, want 1", len(got.Legs)) + } + if got.Legs[0].CandidateID != "c0" || + got.Legs[0].AmountIn.String() != "1000000000000000000" || + got.Legs[0].AmountOut.String() != "1000000" { + t.Fatalf("leg = %+v, want c0 with full input and 1000000 output", got.Legs[0]) + } +} + +func TestStrategyRejectsMaxRateBelowPrivateRate(t *testing.T) { + input := baseInput(t, []types.QuoteCandidate{{ + ID: "c0", Adapter: vlt, Asset: tOut, AssetDecimals: 6, + MaxAssets: mustBig(t, "10000000"), + MaxRate: mustBig(t, "800000000000000000"), + }}) + got, err := New(fakePricing{out: map[common.Address]*big.Int{tOut: mustBig(t, "1000000")}}).DecideQuote(t.Context(), input) + if err != nil { + t.Fatalf("DecideQuote: %v", err) + } + if got.Decision != types.DecisionDecline { + t.Fatalf("decision = %q, want decline", got.Decision) + } +} + +func TestStrategyAssetMustEqualTokenOut(t *testing.T) { + other := common.HexToAddress("0x00000000000000000000000000000000000000ff") + input := baseInput(t, []types.QuoteCandidate{{ + ID: "c0", Adapter: vlt, Asset: other, AssetDecimals: 6, + MaxAssets: mustBig(t, "10000000"), MaxRate: mustBig(t, "1000000000000000000"), + }}) + got, err := New(fakePricing{out: map[common.Address]*big.Int{tOut: mustBig(t, "1000000")}}).DecideQuote(t.Context(), input) + if err != nil { + t.Fatalf("DecideQuote: %v", err) + } + if got.Decision != types.DecisionDecline { + t.Fatalf("decision = %q, want decline", got.Decision) + } +} + +func TestStrategyPricesAndSelectsOnlyMatchingCandidates(t *testing.T) { + other := common.HexToAddress("0x00000000000000000000000000000000000000ff") + input := baseInput(t, []types.QuoteCandidate{ + { + ID: "wrong", Adapter: vlt, Asset: other, AssetDecimals: 6, + MaxAssets: mustBig(t, "10000000"), MaxRate: mustBig(t, "1000000000000000000"), + }, + { + ID: "match", Adapter: vlt, Asset: tOut, AssetDecimals: 6, + MaxAssets: mustBig(t, "10000000"), MaxRate: mustBig(t, "1000000000000000000"), + }, + }) + var queries [][]types.QuoteCandidate + got, err := New(fakePricing{ + out: map[common.Address]*big.Int{tOut: mustBig(t, "1000000")}, + queries: &queries, + }).DecideQuote(t.Context(), input) + if err != nil { + t.Fatalf("DecideQuote: %v", err) + } + if got.Decision != types.DecisionQuote || len(got.Legs) != 1 || got.Legs[0].CandidateID != "match" { + t.Fatalf("output = %+v, want quote through matching candidate", got) + } + if len(queries) != 1 || len(queries[0]) != 1 || queries[0][0].ID != "match" { + t.Fatalf("pricing candidates = %+v, want only matching candidate", queries) + } +} + +func TestStrategyNoOraclePriceSkips(t *testing.T) { + input := baseInput(t, []types.QuoteCandidate{{ + ID: "c0", Adapter: vlt, Asset: tOut, AssetDecimals: 6, + MaxAssets: mustBig(t, "10000000"), MaxRate: mustBig(t, "1000000000000000000"), + }}) + got, err := New(fakePricing{out: map[common.Address]*big.Int{}}).DecideQuote(t.Context(), input) + if err != nil { + t.Fatalf("DecideQuote: %v", err) + } + if got.Decision != types.DecisionDecline { + t.Fatalf("decision = %q, want decline", got.Decision) + } +} + +func TestStrategyDiscountLegUsesMaxRate(t *testing.T) { + h := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000ab") + input := baseInput(t, []types.QuoteCandidate{{ + ID: "c0", Adapter: vlt, Asset: tOut, AssetDecimals: 6, + MaxAssets: mustBig(t, "10000000"), + MaxRate: mustBig(t, "1000000000000000000"), + DiscountID: &h, + }}) + got, err := New(fakePricing{out: map[common.Address]*big.Int{tOut: mustBig(t, "500000")}}).DecideQuote(t.Context(), input) + if err != nil { + t.Fatalf("DecideQuote: %v", err) + } + if got.Decision != types.DecisionQuote || got.QuotedAmountOut.String() != "1000000" { + t.Fatalf("output = %+v, want discount quote at maxRate", got) + } +} + +func TestStrategyDeclinesWhenCapacityCannotCoverInput(t *testing.T) { + input := baseInput(t, []types.QuoteCandidate{{ + ID: "c0", Adapter: vlt, Asset: tOut, AssetDecimals: 6, + MaxAssets: mustBig(t, "500000"), + MaxRate: mustBig(t, "1000000000000000000"), + }}) + got, err := New(fakePricing{out: map[common.Address]*big.Int{tOut: mustBig(t, "1000000")}}).DecideQuote(t.Context(), input) + if err != nil { + t.Fatalf("DecideQuote: %v", err) + } + if got.Decision != types.DecisionDecline { + t.Fatalf("decision = %q, want decline", got.Decision) + } +} diff --git a/internal/solvers/rfq/strategies/registry.go b/internal/solvers/rfq/strategies/registry.go new file mode 100644 index 00000000..57bf38ac --- /dev/null +++ b/internal/solvers/rfq/strategies/registry.go @@ -0,0 +1,61 @@ +package strategies + +import ( + "sort" + "sync" + + "github.com/go-errors/errors" + "github.com/go-logr/logr" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/chain" +) + +type Deps struct { + Chain *chain.Client + Log logr.Logger +} + +type Factory func(raw yaml.Node, deps Deps) (types.Strategy, error) + +var ( + mu sync.RWMutex + registry = map[string]Factory{} +) + +func Register(name string, f Factory) { + mu.Lock() + defer mu.Unlock() + if name == "" { + panic("rfq strategy: Register called with empty name") + } + if f == nil { + panic("rfq strategy: Register called with nil factory for " + name) + } + if _, dup := registry[name]; dup { + panic("rfq strategy: duplicate registration for " + name) + } + registry[name] = f +} + +func New(name string, raw yaml.Node, deps Deps) (types.Strategy, error) { + mu.RLock() + f, ok := registry[name] + mu.RUnlock() + if !ok { + return nil, errors.Errorf("unknown RFQ strategy %q (registered: %v)", name, Registered()) + } + return f(raw, deps) +} + +func Registered() []string { + mu.RLock() + defer mu.RUnlock() + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/internal/solvers/rfq/strategies/types/types.go b/internal/solvers/rfq/strategies/types/types.go new file mode 100644 index 00000000..6471962e --- /dev/null +++ b/internal/solvers/rfq/strategies/types/types.go @@ -0,0 +1,109 @@ +// Package types defines the RFQ-local strategy contract. +package types + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +type Decision string + +const ( + DecisionQuote Decision = "quote" + DecisionDecline Decision = "decline" +) + +type Strategy interface { + DecideQuote(ctx context.Context, input QuoteInput) (QuoteOutput, error) + BuildFillPlan(ctx context.Context, input FillInput) (*FillPlan, error) +} + +type Pricing interface { + TokenDecimals(ctx context.Context, token common.Address) (int, error) + AmountsOut( + ctx context.Context, + tokenIn common.Address, + candidates []QuoteCandidate, + amount *big.Int, + ) (map[common.Address]*big.Int, error) +} + +// QuoteInput is the RFQ strategy decision snapshot. It is intentionally solver-local. +type QuoteInput struct { + RequestID string + QuoteID string + ChainID int64 + Executor common.Address + + TokenIn common.Address + TokenOut common.Address + AmountIn *big.Int + + RequiredAmountOut *big.Int + Candidates []QuoteCandidate + Now time.Time +} + +type QuoteCandidate struct { + ID string + + Adapter common.Address + Asset common.Address + AssetDecimals int + MaxAssets *big.Int + MaxRate *big.Int + DiscountID *common.Hash +} + +type QuoteOutput struct { + Decision Decision + Reason string + QuotedAmountOut *big.Int + Legs []QuoteLeg +} + +type QuoteLeg struct { + CandidateID string + AmountIn *big.Int + AmountOut *big.Int +} + +// FillInput is the fill-time snapshot the solver hands back to the strategy. The strategy may return +// a cached quote-time plan or rebuild one from the provided candidates. +type FillInput struct { + RequestID string + QuoteID string + ChainID int64 + Executor common.Address + + TokenIn common.Address + TokenOut common.Address + AmountIn *big.Int + RequiredAmountOut *big.Int + + Candidates []QuoteCandidate + Now time.Time +} + +// FillPlan is the execution output trusted strategies hand to the solver. The solver only translates +// this plan into Executor calldata. +type FillPlan struct { + QuoteID string + RequestID string + TokenIn common.Address + TokenOut common.Address + AmountIn *big.Int + QuotedAmountOut *big.Int + Legs []FillLeg +} + +type FillLeg struct { + Adapter common.Address + AmountIn *big.Int + AmountOut *big.Int + MaxRate *big.Int + DiscountID *common.Hash +} diff --git a/internal/solvers/rfq/strategies/types/wire_json.go b/internal/solvers/rfq/strategies/types/wire_json.go new file mode 100644 index 00000000..65363f0c --- /dev/null +++ b/internal/solvers/rfq/strategies/types/wire_json.go @@ -0,0 +1,114 @@ +package types + +import ( + "bytes" + "encoding/json" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" +) + +// RFQ webhook JSON wire contract: big integers are decimal strings, and strategy responses reject +// unknown fields so remote deciders fail closed on schema drift. +type quoteInputJSON struct { + RequestID string `json:"requestId"` + QuoteID string `json:"quoteId"` + ChainID int64 `json:"chainId"` + Executor common.Address `json:"executor"` + TokenIn common.Address `json:"tokenIn"` + TokenOut common.Address `json:"tokenOut"` + AmountIn string `json:"amountIn"` + RequiredAmountOut string `json:"requiredAmountOut,omitempty"` + Candidates []quoteCandidateJSON `json:"candidates"` + Now time.Time `json:"now"` +} + +type quoteCandidateJSON struct { + ID string `json:"id"` + Adapter common.Address `json:"adapter"` + Asset common.Address `json:"asset"` + AssetDecimals int `json:"assetDecimals"` + MaxAssets string `json:"maxAssets"` + MaxRate string `json:"maxRate"` + DiscountID *common.Hash `json:"discountId,omitempty"` +} + +type quoteOutputJSON struct { + Decision Decision `json:"decision"` + Reason string `json:"reason"` + QuotedAmountOut string `json:"quotedAmountOut"` + Legs []quoteLegJSON `json:"legs"` +} + +type quoteLegJSON struct { + CandidateID string `json:"candidateId"` + AmountIn string `json:"amountIn"` + AmountOut string `json:"amountOut"` +} + +func (in QuoteInput) MarshalJSON() ([]byte, error) { + candidates := make([]quoteCandidateJSON, 0, len(in.Candidates)) + for _, c := range in.Candidates { + candidates = append(candidates, quoteCandidateJSON{ + ID: c.ID, Adapter: c.Adapter, Asset: c.Asset, AssetDecimals: c.AssetDecimals, + MaxAssets: bigString(c.MaxAssets), MaxRate: bigString(c.MaxRate), DiscountID: c.DiscountID, + }) + } + return json.Marshal(quoteInputJSON{ + RequestID: in.RequestID, QuoteID: in.QuoteID, ChainID: in.ChainID, + Executor: in.Executor, TokenIn: in.TokenIn, TokenOut: in.TokenOut, AmountIn: bigString(in.AmountIn), + RequiredAmountOut: bigString(in.RequiredAmountOut), Candidates: candidates, Now: in.Now, + }) +} + +func (out *QuoteOutput) UnmarshalJSON(b []byte) error { + var raw quoteOutputJSON + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(&raw); err != nil { + return err + } + quoted, err := parseBigString(raw.QuotedAmountOut, "quotedAmountOut") + if err != nil { + return err + } + legs := make([]QuoteLeg, 0, len(raw.Legs)) + for i, l := range raw.Legs { + amountIn, err := parseBigString(l.AmountIn, "legs.amountIn") + if err != nil { + return errors.Errorf("leg %d: %w", i, err) + } + amountOut, err := parseBigString(l.AmountOut, "legs.amountOut") + if err != nil { + return errors.Errorf("leg %d: %w", i, err) + } + legs = append(legs, QuoteLeg{CandidateID: l.CandidateID, AmountIn: amountIn, AmountOut: amountOut}) + } + *out = QuoteOutput{ + Decision: raw.Decision, + Reason: raw.Reason, + QuotedAmountOut: quoted, + Legs: legs, + } + return nil +} + +func bigString(n *big.Int) string { + if n == nil { + return "" + } + return n.String() +} + +func parseBigString(s, field string) (*big.Int, error) { + if s == "" { + return nil, nil + } + n, ok := new(big.Int).SetString(s, 10) + if !ok || n.Sign() < 0 { + return nil, errors.Errorf("%s: invalid decimal string %q", field, s) + } + return n, nil +} diff --git a/internal/solvers/rfq/strategies/types/wire_json_test.go b/internal/solvers/rfq/strategies/types/wire_json_test.go new file mode 100644 index 00000000..d826dc6e --- /dev/null +++ b/internal/solvers/rfq/strategies/types/wire_json_test.go @@ -0,0 +1,112 @@ +package types + +import ( + "encoding/json" + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +func mustBig(t *testing.T, s string) *big.Int { + t.Helper() + n, ok := new(big.Int).SetString(s, 10) + if !ok { + t.Fatalf("bad big.Int %q", s) + } + return n +} + +func TestQuoteInputMarshalJSONWireShape(t *testing.T) { + discountID := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000ab") + input := QuoteInput{ + RequestID: "request-1", + QuoteID: "quote-1", + ChainID: 1, + Executor: common.HexToAddress("0x0000000000000000000000000000000000000010"), + TokenIn: common.HexToAddress("0x0000000000000000000000000000000000000001"), + TokenOut: common.HexToAddress("0x0000000000000000000000000000000000000002"), + AmountIn: mustBig(t, "1000000000000000000"), + Candidates: []QuoteCandidate{{ + ID: "candidate-1", + Adapter: common.HexToAddress("0x0000000000000000000000000000000000000003"), + Asset: common.HexToAddress("0x0000000000000000000000000000000000000002"), + AssetDecimals: 6, + MaxAssets: mustBig(t, "1000000"), + MaxRate: mustBig(t, "1000000000000000000"), + DiscountID: &discountID, + }}, + Now: time.Unix(1, 0).UTC(), + } + + body, err := json.Marshal(input) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if strings.Contains(string(body), "AmountIn") || !strings.Contains(string(body), `"amountIn":"1000000000000000000"`) { + t.Fatalf("JSON does not use lower-camel decimal-string amountIn: %s", body) + } + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatalf("Unmarshal raw: %v", err) + } + if _, ok := raw["requiredAmountOut"]; ok { + t.Fatalf("requiredAmountOut should be omitted when nil: %s", body) + } + if _, ok := raw["mode"]; ok { + t.Fatalf("mode should not be part of the RFQ strategy input: %s", body) + } + candidates, ok := raw["candidates"].([]any) + if !ok || len(candidates) != 1 { + t.Fatalf("candidates = %#v, want one candidate", raw["candidates"]) + } + candidate, ok := candidates[0].(map[string]any) + if !ok { + t.Fatalf("candidate = %#v, want object", candidates[0]) + } + if candidate["maxAssets"] != "1000000" || candidate["maxRate"] != "1000000000000000000" { + t.Fatalf("candidate amounts not decimal strings: %#v", candidate) + } +} + +func TestQuoteOutputUnmarshalJSONWireShape(t *testing.T) { + var out QuoteOutput + if err := json.Unmarshal([]byte(`{ + "decision": "quote", + "reason": "selected", + "quotedAmountOut": "1000000", + "legs": [{"candidateId": "candidate-1", "amountIn": "1000000000000000000", "amountOut": "1000000"}] + }`), &out); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if out.Decision != DecisionQuote || out.Reason != "selected" { + t.Fatalf("unexpected metadata: %+v", out) + } + if out.QuotedAmountOut.String() != "1000000" { + t.Fatalf("quotedAmountOut = %s, want 1000000", out.QuotedAmountOut) + } + if len(out.Legs) != 1 || + out.Legs[0].CandidateID != "candidate-1" || + out.Legs[0].AmountIn.String() != "1000000000000000000" || + out.Legs[0].AmountOut.String() != "1000000" { + t.Fatalf("unexpected legs: %+v", out.Legs) + } +} + +func TestQuoteOutputUnmarshalJSONRejectsUnknownFields(t *testing.T) { + var out QuoteOutput + err := json.Unmarshal([]byte(`{"decision":"decline","extra":1}`), &out) + if err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("Unmarshal error = %v, want unknown field rejection", err) + } +} + +func TestQuoteOutputUnmarshalJSONRejectsInvalidDecimal(t *testing.T) { + var out QuoteOutput + err := json.Unmarshal([]byte(`{"decision":"quote","quotedAmountOut":"not-a-number"}`), &out) + if err == nil || !strings.Contains(err.Error(), "quotedAmountOut") { + t.Fatalf("Unmarshal error = %v, want quotedAmountOut decimal rejection", err) + } +} diff --git a/internal/solvers/rfq/strategies/webhook/strategy.go b/internal/solvers/rfq/strategies/webhook/strategy.go new file mode 100644 index 00000000..f874cfaa --- /dev/null +++ b/internal/solvers/rfq/strategies/webhook/strategy.go @@ -0,0 +1,120 @@ +package webhookstrategy + +import ( + "context" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/webhook" +) + +const Name = "webhook" + +type Strategy struct { + client *webhook.Client +} + +//nolint:gochecknoinits // solver-local strategy self-registration mirrors solver registration. +func init() { + strategies.Register(Name, NewFromConfig) +} + +func NewFromConfig(raw yaml.Node, _ strategies.Deps) (types.Strategy, error) { + cfg, err := webhook.ParseConfig(raw) + if err != nil { + return nil, err + } + client, err := webhook.NewClient(cfg) + if err != nil { + return nil, err + } + return New(client), nil +} + +func New(client *webhook.Client) *Strategy { + return &Strategy{client: client} +} + +func (s *Strategy) DecideQuote(ctx context.Context, input types.QuoteInput) (types.QuoteOutput, error) { + var out types.QuoteOutput + if err := s.client.PostJSON(ctx, input, &out); err != nil { + return types.QuoteOutput{}, err + } + return out, nil +} + +// BuildFillPlan delegates to the external decider on every call and keeps no local cache. The remote +// implementer owns caching the quote-time decision and validating the fill against the awarded order +// (the fill request carries the order's AmountIn and RequiredAmountOut). We only assemble the returned +// candidate legs into a fill plan against the solver's trusted candidate snapshot. +func (s *Strategy) BuildFillPlan(ctx context.Context, input types.FillInput) (*types.FillPlan, error) { + quoteInput := types.QuoteInput(input) + quoteInput.AmountIn = cloneBig(input.AmountIn) + quoteInput.RequiredAmountOut = cloneBig(input.RequiredAmountOut) + out, err := s.DecideQuote(ctx, quoteInput) + if err != nil || out.Decision == types.DecisionDecline { + return nil, err + } + return fillPlanFromQuote(quoteInput, out) +} + +func fillPlanFromQuote(input types.QuoteInput, out types.QuoteOutput) (*types.FillPlan, error) { + if out.QuotedAmountOut == nil || out.QuotedAmountOut.Sign() <= 0 { + return nil, errors.New("quote output is missing a positive quotedAmountOut") + } + if len(out.Legs) == 0 { + return nil, errors.New("quote output has no legs") + } + candidates := make(map[string]types.QuoteCandidate, len(input.Candidates)) + for _, c := range input.Candidates { + candidates[c.ID] = c + } + legs := make([]types.FillLeg, 0, len(out.Legs)) + for _, leg := range out.Legs { + c, ok := candidates[leg.CandidateID] + if !ok { + return nil, errors.Errorf("unknown candidate %q", leg.CandidateID) + } + // Crash-safety only (not economic re-validation): the strategy is trusted for pricing, but a + // missing/omitted amount would be a nil *big.Int that panics downstream in directSwaps. + if leg.AmountIn == nil || leg.AmountIn.Sign() <= 0 || leg.AmountOut == nil || leg.AmountOut.Sign() <= 0 { + return nil, errors.Errorf("leg %q has non-positive amounts", leg.CandidateID) + } + legs = append(legs, types.FillLeg{ + Adapter: c.Adapter, + AmountIn: cloneBig(leg.AmountIn), + AmountOut: cloneBig(leg.AmountOut), + MaxRate: cloneBig(c.MaxRate), + DiscountID: cloneHash(c.DiscountID), + }) + } + return &types.FillPlan{ + QuoteID: input.QuoteID, + RequestID: input.RequestID, + TokenIn: input.TokenIn, + TokenOut: input.TokenOut, + AmountIn: cloneBig(input.AmountIn), + QuotedAmountOut: cloneBig(out.QuotedAmountOut), + Legs: legs, + }, nil +} + +func cloneBig(n *big.Int) *big.Int { + if n == nil { + return nil + } + return new(big.Int).Set(n) +} + +func cloneHash(h *common.Hash) *common.Hash { + if h == nil { + return nil + } + out := *h + return &out +} diff --git a/internal/solvers/rfq/strategy.go b/internal/solvers/rfq/strategy.go index 9ebc7407..f6bb1ffe 100644 --- a/internal/solvers/rfq/strategy.go +++ b/internal/solvers/rfq/strategy.go @@ -2,19 +2,30 @@ package rfq import ( "math/big" - "sort" + "strconv" "time" "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" + + "github.com/symbioticfi/vault-solver/internal/chain" ) -// rateScale is the adapter's fixed-point rate scale (1e18). -var rateScale = new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil) +func newStrategy(spec StrategyConfig, chainClient *chain.Client, log logr.Logger) (types.Strategy, error) { + name := spec.Name + if name == "" { + name = defaultStrategyName + } + return strategies.New(name, spec.Config, strategies.Deps{Chain: chainClient, Log: log}) +} // solverInventory is one candidate adapter leg, taken from the backend quote request's snapshot // (the filler does not re-read maxAssets/maxRate/decimals on-chain in the quote path). "adapter" is // the address that fills (placed in the on-chain Swap's vault slot); "asset" is the output token. type solverInventory struct { + ID string Adapter common.Address Asset common.Address AssetDecimals int @@ -23,31 +34,8 @@ type solverInventory struct { DiscountID *common.Hash // nil for a direct leg; set for a discount leg } -// strategyLeg is one filled leg of a selected strategy. -type strategyLeg struct { - Adapter common.Address - AmountIn *big.Int - AmountOut *big.Int - MaxRate *big.Int - DiscountID *common.Hash -} - -// strategyRecord is the selected execution plan for a quote, persisted by quoteId so execution can -// recover it after the backend awards the order. -type strategyRecord struct { - QuoteID string - RequestID string - TokenIn common.Address - TokenOut common.Address - AmountIn *big.Int - Asset common.Address - AssetDecimals int - AssetAmountOut *big.Int - QuotedAmountOut *big.Int - Legs []strategyLeg - CreatedAt time.Time - UpdatedAt time.Time -} +type fillLeg = types.FillLeg +type fillPlan = types.FillPlan // strategyRequest is the subset of a quote request the selector needs. type strategyRequest struct { @@ -58,229 +46,67 @@ type strategyRequest struct { Amount *big.Int } -// selectBestStrategy picks the best single-asset strategy for the request. It is a pure function: -// oracleByAsset supplies the pre-fetched adapter.getAmountOut(tokenIn, amount) for each candidate -// asset, and `now` is injected, so it is fully unit-testable. -// -// It groups inventories by asset, evaluates each group whose asset matches tokenOut, and picks the -// highest quotedAmountOut (tie-broken by assetAmountOut). Groups are iterated in sorted asset order -// for deterministic ties. -func selectBestStrategy( +func newQuoteInput( + chainID int64, + executor common.Address, req strategyRequest, - inventories []solverInventory, - tokenInDecimals int, - oracleByAsset map[common.Address]*big.Int, + inv []solverInventory, + required *big.Int, now time.Time, -) *strategyRecord { - groups := groupByAsset(inventories) - - keys := make([]common.Address, 0, len(groups)) - for k := range groups { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { return keys[i].Hex() < keys[j].Hex() }) - - var best *strategyRecord - for _, asset := range keys { - oracle := oracleByAsset[asset] - if oracle == nil { - continue // no oracle price fetched for this asset - } - cand := evaluateGroup(req, groups[asset], tokenInDecimals, oracle, now) - if cand == nil { - continue - } - if best == nil || - cand.QuotedAmountOut.Cmp(best.QuotedAmountOut) > 0 || - (cand.QuotedAmountOut.Cmp(best.QuotedAmountOut) == 0 && - cand.AssetAmountOut.Cmp(best.AssetAmountOut) > 0) { - best = cand +) types.QuoteInput { + candidates := make([]types.QuoteCandidate, 0, len(inv)) + for i, v := range inv { + id := v.ID + if id == "" { + id = "candidate-" + strconv.Itoa(i) } + candidates = append(candidates, types.QuoteCandidate{ + ID: id, + Adapter: v.Adapter, + Asset: v.Asset, + AssetDecimals: v.AssetDecimals, + MaxAssets: cloneBig(v.MaxAssets), + MaxRate: cloneBig(v.MaxRate), + DiscountID: cloneHash(v.DiscountID), + }) } - return best -} - -func groupByAsset(inventories []solverInventory) map[common.Address][]solverInventory { - groups := make(map[common.Address][]solverInventory) - for _, inv := range inventories { - groups[inv.Asset] = append(groups[inv.Asset], inv) + return types.QuoteInput{ + RequestID: req.RequestID, + QuoteID: req.QuoteID, + ChainID: chainID, + Executor: executor, + TokenIn: req.TokenIn, + TokenOut: req.TokenOut, + AmountIn: cloneBig(req.Amount), + RequiredAmountOut: cloneBig(required), + Candidates: candidates, + Now: now, } - return groups } -// eligibleLeg pairs an inventory with the effective rate it's filled at. -type eligibleLeg struct { - inv solverInventory - rate *big.Int -} - -func evaluateGroup( +func newFillInput( + chainID int64, + executor common.Address, req strategyRequest, - group []solverInventory, - tokenInDecimals int, - oracleAmountOut *big.Int, + inv []solverInventory, + required *big.Int, now time.Time, -) *strategyRecord { - asset := group[0].Asset - assetDecimals := group[0].AssetDecimals - if asset != req.TokenOut { - return nil // this filler only fills when the output token is the adapter asset - } - - // The quoted output is the adapter's oracle amountOut (no extra quote discount is applied). - privateRate := rateForAmountOut(oracleAmountOut, req.Amount, tokenInDecimals, assetDecimals) - - eligible := make([]eligibleLeg, 0, len(group)) - for _, inv := range group { - // Direct legs fill at our discounted private rate; discount legs fill at the adapter's - // advertised maxRate. A direct adapter that won't honor our private rate is dropped. - effRate := privateRate - if inv.DiscountID != nil { - effRate = inv.MaxRate - } - if effRate.Sign() <= 0 { - continue - } - if inv.DiscountID == nil && inv.MaxRate.Cmp(effRate) < 0 { - continue - } - eligible = append(eligible, eligibleLeg{inv: inv, rate: effRate}) - } - - sort.SliceStable(eligible, func(i, j int) bool { - if c := eligible[i].rate.Cmp(eligible[j].rate); c != 0 { - return c > 0 - } - if c := eligible[i].inv.MaxAssets.Cmp(eligible[j].inv.MaxAssets); c != 0 { - return c > 0 - } - return eligible[i].inv.MaxRate.Cmp(eligible[j].inv.MaxRate) > 0 - }) - eligible = dedupeByAdapter(eligible) - if len(eligible) == 0 { - return nil - } - - remainingIn := new(big.Int).Set(req.Amount) - assetAmountOut := new(big.Int) - var legs []strategyLeg - for _, e := range eligible { - if remainingIn.Sign() == 0 { - break - } - maxAmountIn := maxAmountInForRate(e.inv.MaxAssets, e.rate, tokenInDecimals, e.inv.AssetDecimals) - if maxAmountIn.Sign() == 0 { - continue - } - saturated := remainingIn.Cmp(maxAmountIn) > 0 - - var amountIn, amountOut *big.Int - if saturated { - amountIn = minAmountInForAmountOut(e.inv.MaxAssets, e.rate, tokenInDecimals, e.inv.AssetDecimals) - amountOut = new(big.Int).Set(e.inv.MaxAssets) - } else { - amountIn = new(big.Int).Set(remainingIn) - amountOut = amountOutForRate(amountIn, e.rate, tokenInDecimals, e.inv.AssetDecimals) - } - if amountOut.Sign() == 0 { - continue - } - - remainingIn.Sub(remainingIn, amountIn) - assetAmountOut.Add(assetAmountOut, amountOut) - legs = append(legs, strategyLeg{ - Adapter: e.inv.Adapter, - AmountIn: amountIn, - AmountOut: amountOut, - MaxRate: e.inv.MaxRate, - DiscountID: e.inv.DiscountID, - }) - } - if len(legs) == 0 { - return nil - } - // Any input we couldn't place at capacity is folded into the last leg (same output, more input). - if remainingIn.Sign() != 0 { - last := &legs[len(legs)-1] - last.AmountIn = new(big.Int).Add(last.AmountIn, remainingIn) - } - - return &strategyRecord{ - QuoteID: req.QuoteID, - RequestID: req.RequestID, - TokenIn: req.TokenIn, - TokenOut: req.TokenOut, - AmountIn: new(big.Int).Set(req.Amount), - Asset: asset, - AssetDecimals: assetDecimals, - AssetAmountOut: assetAmountOut, - QuotedAmountOut: assetAmountOut, - Legs: legs, - CreatedAt: now, - UpdatedAt: now, - } +) types.FillInput { + q := newQuoteInput(chainID, executor, req, inv, required, now) + return types.FillInput(q) } -// dedupeByAdapter keeps the first (highest-sorted) leg per adapter+asset pair. -func dedupeByAdapter(legs []eligibleLeg) []eligibleLeg { - seen := make(map[string]bool, len(legs)) - out := legs[:0] - for _, e := range legs { - key := e.inv.Adapter.Hex() + ":" + e.inv.Asset.Hex() - if seen[key] { - continue - } - seen[key] = true - out = append(out, e) - } - return out -} - -/* ───────── fixed-point rate math ───────── */ - -func pow10(n int) *big.Int { return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(n)), nil) } - -// amountOutForRate = amountIn * rate * 10^assetDec / (RATE_SCALE * 10^tokenInDec). -func amountOutForRate(amountIn, rate *big.Int, tokenInDec, assetDec int) *big.Int { - num := new(big.Int).Mul(amountIn, rate) - num.Mul(num, pow10(assetDec)) - den := new(big.Int).Mul(rateScale, pow10(tokenInDec)) - if den.Sign() == 0 { - return new(big.Int) - } - return num.Div(num, den) -} - -// maxAmountInForRate = maxAssets * RATE_SCALE * 10^tokenInDec / (rate * 10^assetDec). -func maxAmountInForRate(maxAssets, rate *big.Int, tokenInDec, assetDec int) *big.Int { - den := new(big.Int).Mul(rate, pow10(assetDec)) - if den.Sign() == 0 { - return new(big.Int) - } - num := new(big.Int).Mul(maxAssets, rateScale) - num.Mul(num, pow10(tokenInDec)) - return num.Div(num, den) -} - -// minAmountInForAmountOut = ceil(amountOut * RATE_SCALE * 10^tokenInDec / (rate * 10^assetDec)). -func minAmountInForAmountOut(amountOut, rate *big.Int, tokenInDec, assetDec int) *big.Int { - den := new(big.Int).Mul(rate, pow10(assetDec)) - if den.Sign() == 0 { - return new(big.Int) +func cloneBig(n *big.Int) *big.Int { + if n == nil { + return nil } - num := new(big.Int).Mul(amountOut, rateScale) - num.Mul(num, pow10(tokenInDec)) - num.Add(num, new(big.Int).Sub(den, big.NewInt(1))) // ceil - return num.Div(num, den) + return new(big.Int).Set(n) } -// rateForAmountOut = amountOut * RATE_SCALE * 10^tokenInDec / (amountIn * 10^assetDec); 0 when amountIn == 0. -func rateForAmountOut(amountOut, amountIn *big.Int, tokenInDec, assetDec int) *big.Int { - if amountIn.Sign() == 0 { - return new(big.Int) +func cloneHash(h *common.Hash) *common.Hash { + if h == nil { + return nil } - num := new(big.Int).Mul(amountOut, rateScale) - num.Mul(num, pow10(tokenInDec)) - den := new(big.Int).Mul(amountIn, pow10(assetDec)) - return num.Div(num, den) + out := *h + return &out } diff --git a/internal/solvers/rfq/strategy_test.go b/internal/solvers/rfq/strategy_test.go index 74c5d0cf..393cbaff 100644 --- a/internal/solvers/rfq/strategy_test.go +++ b/internal/solvers/rfq/strategy_test.go @@ -1,119 +1,127 @@ package rfq import ( + "io" "math/big" + "net/http" + "net/http/httptest" + "strings" "testing" "time" "github.com/ethereum/go-ethereum/common" -) - -func mustBig(t *testing.T, s string) *big.Int { - t.Helper() - n, ok := new(big.Int).SetString(s, 10) - if !ok { - t.Fatalf("bad big.Int %q", s) - } - return n -} + "github.com/go-logr/logr" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" -var ( - tIn = common.HexToAddress("0x0000000000000000000000000000000000000001") - tOut = common.HexToAddress("0x0000000000000000000000000000000000000002") - vlt = common.HexToAddress("0x0000000000000000000000000000000000000003") + defaultstrategy "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/default" + webhookstrategy "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/webhook" + "github.com/symbioticfi/vault-solver/internal/webhook" ) -// baseReq: swap 1e18 of an 18-decimal tokenIn for a 6-decimal asset (tokenOut). -func baseReq(t *testing.T) strategyRequest { +func baseQuoteInput(t *testing.T) types.QuoteInput { t.Helper() - return strategyRequest{RequestID: "r", QuoteID: "q", TokenIn: tIn, TokenOut: tOut, Amount: mustBig(t, "1000000000000000000")} + return types.QuoteInput{ + RequestID: "r", QuoteID: "q", ChainID: 1, + Executor: common.HexToAddress("0x0000000000000000000000000000000000000010"), + TokenIn: tIn, TokenOut: tOut, AmountIn: mustBig(t, "1000000000000000000"), + Candidates: []types.QuoteCandidate{{ + ID: "c0", Adapter: vlt, Asset: tOut, AssetDecimals: 6, + MaxAssets: mustBig(t, "10000000"), + MaxRate: mustBig(t, "1000000000000000000"), + }}, + Now: time.Unix(0, 0), + } } -func TestSelectBestStrategy_DirectFill(t *testing.T) { - req := baseReq(t) - inv := []solverInventory{{ - Adapter: vlt, Asset: tOut, AssetDecimals: 6, - MaxAssets: mustBig(t, "10000000"), // 10 USDC of liquidity - MaxRate: mustBig(t, "1000000000000000000"), // 1e18 ≥ our private rate - }} - oracle := map[common.Address]*big.Int{tOut: mustBig(t, "1000000")} // oracle: 1e18 tokenIn → 1.000000 USDC - - got := selectBestStrategy(req, inv, 18, oracle, time.Unix(0, 0)) - if got == nil { - t.Fatal("expected a strategy") +func TestDefaultStrategyDecideQuote(t *testing.T) { + pricing := &fakeStrategyPricing{ + decimals: 18, + out: map[common.Address]*big.Int{tOut: mustBig(t, "1000000")}, } - // 1.0 USDC oracle, no quote discount = 1.000000 USDC = 1000000 base units. - if got.QuotedAmountOut.String() != "1000000" { - t.Fatalf("quotedAmountOut = %s, want 1000000", got.QuotedAmountOut) + out, err := defaultstrategy.New(pricing).DecideQuote(t.Context(), baseQuoteInput(t)) + if err != nil { + t.Fatalf("DecideQuote: %v", err) } - if len(got.Legs) != 1 { - t.Fatalf("legs = %d, want 1", len(got.Legs)) + if out.Decision != types.DecisionQuote || out.QuotedAmountOut.String() != "1000000" { + t.Fatalf("unexpected output: %+v", out) } - if got.Legs[0].AmountIn.String() != "1000000000000000000" || got.Legs[0].AmountOut.String() != "1000000" { - t.Fatalf("leg = (in %s, out %s), want (1e18, 1000000)", got.Legs[0].AmountIn, got.Legs[0].AmountOut) + if len(out.Legs) != 1 || out.Legs[0].CandidateID != "c0" { + t.Fatalf("legs = %+v, want candidate c0", out.Legs) } - if got.Legs[0].DiscountID != nil { - t.Fatalf("direct leg should have nil discountId") + if len(pricing.queries) != 1 || len(pricing.queries[0]) != 1 || pricing.queries[0][0].Adapter != vlt { + t.Fatalf("pricing queries = %+v, want one batched query for %s", pricing.queries, vlt.Hex()) } } -func TestSelectBestStrategy_RejectsMaxRateBelowPrivateRate(t *testing.T) { - req := baseReq(t) - inv := []solverInventory{{ - Adapter: vlt, Asset: tOut, AssetDecimals: 6, - MaxAssets: mustBig(t, "10000000"), - MaxRate: mustBig(t, "800000000000000000"), // 0.8e18 < private rate (1.0e18, no discount) → ineligible - }} - oracle := map[common.Address]*big.Int{tOut: mustBig(t, "1000000")} - - if got := selectBestStrategy(req, inv, 18, oracle, time.Unix(0, 0)); got != nil { - t.Fatalf("expected nil (vault won't honor the rate), got %v", got) +func TestNewStrategyUsesRegistry(t *testing.T) { + got, err := newStrategy(StrategyConfig{Name: "default"}, nil, logr.Discard()) + if err != nil { + t.Fatalf("newStrategy default: %v", err) } -} - -func TestSelectBestStrategy_AssetMustEqualTokenOut(t *testing.T) { - req := baseReq(t) - other := common.HexToAddress("0x00000000000000000000000000000000000000ff") - inv := []solverInventory{{ - Adapter: vlt, Asset: other, AssetDecimals: 6, // asset != tokenOut - MaxAssets: mustBig(t, "10000000"), MaxRate: mustBig(t, "1000000000000000000"), - }} - oracle := map[common.Address]*big.Int{other: mustBig(t, "1000000")} - - if got := selectBestStrategy(req, inv, 18, oracle, time.Unix(0, 0)); got != nil { - t.Fatalf("expected nil (asset != tokenOut), got %v", got) + if got == nil { + t.Fatal("newStrategy default returned nil") + } + names := strategies.Registered() + if len(names) < 2 || names[0] != "default" || names[1] != "webhook" { + t.Fatalf("registered strategies = %v, want default and webhook", names) } } -func TestSelectBestStrategy_NoOraclePriceSkips(t *testing.T) { - req := baseReq(t) - inv := []solverInventory{{ - Adapter: vlt, Asset: tOut, AssetDecimals: 6, - MaxAssets: mustBig(t, "10000000"), MaxRate: mustBig(t, "1000000000000000000"), - }} - // Empty oracle map → collateral can't be priced → no strategy. - if got := selectBestStrategy(req, inv, 18, map[common.Address]*big.Int{}, time.Unix(0, 0)); got != nil { - t.Fatalf("expected nil (no oracle price), got %v", got) +func TestDefaultStrategyBuildFillPlanUsesQuoteCache(t *testing.T) { + strategy := defaultstrategy.New(&fakeStrategyPricing{ + decimals: 18, + out: map[common.Address]*big.Int{tOut: mustBig(t, "1000000")}, + }) + input := baseQuoteInput(t) + if _, err := strategy.DecideQuote(t.Context(), input); err != nil { + t.Fatalf("DecideQuote: %v", err) + } + plan, err := strategy.BuildFillPlan(t.Context(), types.FillInput{ + RequestID: input.RequestID, + QuoteID: input.QuoteID, + ChainID: input.ChainID, + Executor: input.Executor, + TokenIn: input.TokenIn, + TokenOut: input.TokenOut, + AmountIn: input.AmountIn, + Now: input.Now, + }) + if err != nil { + t.Fatalf("BuildFillPlan: %v", err) + } + if plan == nil || len(plan.Legs) != 1 || plan.Legs[0].Adapter != vlt { + t.Fatalf("cached fill plan = %+v, want vlt leg", plan) } } -func TestSelectBestStrategy_DiscountLegUsesMaxRate(t *testing.T) { - req := baseReq(t) - h := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000ab") - inv := []solverInventory{{ - Adapter: vlt, Asset: tOut, AssetDecimals: 6, - MaxAssets: mustBig(t, "10000000"), - MaxRate: mustBig(t, "1000000000000000000"), // 1.0 — the vault's advertised discount rate - DiscountID: &h, - }} - // Oracle is deliberately lower than the discount rate; a discount leg must price off maxRate, not it. - oracle := map[common.Address]*big.Int{tOut: mustBig(t, "500000")} - - got := selectBestStrategy(req, inv, 18, oracle, time.Unix(0, 0)) - if got == nil || len(got.Legs) != 1 || got.Legs[0].DiscountID == nil { - t.Fatalf("expected one discount leg, got %v", got) +func TestWebhookStrategyDecodesLowerCamelResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read request: %v", err) + } + if !strings.Contains(string(body), `"amountIn":"1000000000000000000"`) || + strings.Contains(string(body), `"AmountIn"`) { + t.Fatalf("request body does not use decimal-string lower-camel JSON: %s", string(body)) + } + _, _ = w.Write([]byte(`{ + "decision": "quote", + "quotedAmountOut": "1000000", + "legs": [{"candidateId": "c0", "amountIn": "1000000000000000000", "amountOut": "1000000"}] + }`)) + })) + defer srv.Close() + client, err := webhook.NewClient(webhook.Config{URL: srv.URL, Timeout: time.Second}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + out, err := webhookstrategy.New(client).DecideQuote(t.Context(), baseQuoteInput(t)) + if err != nil { + t.Fatalf("DecideQuote: %v", err) } - if got.QuotedAmountOut.String() != "1000000" { // 1e18 * 1.0 → 1.000000 collateral, ignoring the oracle - t.Fatalf("quotedAmountOut = %s, want 1000000 (maxRate, not discounted oracle)", got.QuotedAmountOut) + if out.Decision != types.DecisionQuote || out.QuotedAmountOut.String() != "1000000" || + len(out.Legs) != 1 || out.Legs[0].CandidateID != "c0" { + t.Fatalf("unexpected webhook output: %+v", out) } } diff --git a/internal/solvers/rfq/test_helpers_test.go b/internal/solvers/rfq/test_helpers_test.go new file mode 100644 index 00000000..2e35d66b --- /dev/null +++ b/internal/solvers/rfq/test_helpers_test.go @@ -0,0 +1,51 @@ +package rfq + +import ( + "context" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" + + defaultstrategy "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/default" +) + +func mustBig(t *testing.T, s string) *big.Int { + t.Helper() + n, ok := new(big.Int).SetString(s, 10) + if !ok { + t.Fatalf("bad big.Int %q", s) + } + return n +} + +var ( + tIn = common.HexToAddress("0x0000000000000000000000000000000000000001") + tOut = common.HexToAddress("0x0000000000000000000000000000000000000002") + vlt = common.HexToAddress("0x0000000000000000000000000000000000000003") +) + +type fakeStrategyPricing struct { + decimals int + out map[common.Address]*big.Int + queries [][]types.QuoteCandidate +} + +func (f *fakeStrategyPricing) TokenDecimals(context.Context, common.Address) (int, error) { + return f.decimals, nil +} + +func (f *fakeStrategyPricing) AmountsOut( + _ context.Context, + _ common.Address, + candidates []types.QuoteCandidate, + _ *big.Int, +) (map[common.Address]*big.Int, error) { + f.queries = append(f.queries, candidates) + return f.out, nil +} + +func newDefaultTestStrategy(decimals int, out map[common.Address]*big.Int) types.Strategy { + return defaultstrategy.New(&fakeStrategyPricing{decimals: decimals, out: out}) +} diff --git a/internal/webhook/client.go b/internal/webhook/client.go new file mode 100644 index 00000000..eb5ef117 --- /dev/null +++ b/internal/webhook/client.go @@ -0,0 +1,234 @@ +package webhook + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net" + "net/http" + "net/url" + "os" + "time" + + "github.com/go-errors/errors" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/parse" +) + +const ( + defaultWebhookTimeout = 5 * time.Second + defaultWebhookMaxBodyBytes = 1 << 20 +) + +// HeaderValue is one configured HTTP header. Value is for non-secret literals; Env names an env var +// whose value is resolved when the webhook client is built. +type HeaderValue struct { + Value string `yaml:"value"` + Env string `yaml:"env"` +} + +// Config is the shared HTTP transport config for webhook-style strategies. +type Config struct { + URL string + Timeout time.Duration + MaxRequestBytes int64 + MaxResponseBytes int64 + Headers map[string]HeaderValue +} + +type rawConfig struct { + URL string `yaml:"url"` + Timeout string `yaml:"timeout"` + MaxRequestBytes int64 `yaml:"maxRequestBytes"` + MaxResponseBytes int64 `yaml:"maxResponseBytes"` + Headers map[string]HeaderValue `yaml:"headers"` +} + +// ParseConfig decodes a strict webhook config. +func ParseConfig(node yaml.Node) (Config, error) { + var raw rawConfig + if err := parse.DecodeStrict(node, &raw); err != nil { + return Config{}, err + } + if raw.URL == "" { + return Config{}, errors.New("url is required") + } + if err := validateURL(raw.URL); err != nil { + return Config{}, err + } + timeout, err := parse.Duration(raw.Timeout, defaultWebhookTimeout, "timeout") + if err != nil { + return Config{}, err + } + for name, h := range raw.Headers { + switch { + case name == "": + return Config{}, errors.New("headers: empty header name") + case h.Value != "" && h.Env != "": + return Config{}, errors.Errorf("headers.%s: set value or env, not both", name) + case h.Value == "" && h.Env == "": + return Config{}, errors.Errorf("headers.%s: value or env is required", name) + } + } + return normalizeConfig(Config{ + URL: raw.URL, + Timeout: timeout, + MaxRequestBytes: raw.MaxRequestBytes, + MaxResponseBytes: raw.MaxResponseBytes, + Headers: raw.Headers, + }) +} + +func validateURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return errors.Errorf("url: %w", err) + } + if u.Host == "" { + return errors.Errorf("url: host is required") + } + if u.Scheme == "https" { + return nil + } + if u.Scheme == "http" && isLoopbackHost(u.Hostname()) { + return nil + } + return errors.New("url must use https, except loopback http for local development") +} + +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func parseSize(n int64, field string) (int64, error) { + if n == 0 { + return defaultWebhookMaxBodyBytes, nil + } + if n < 0 { + return 0, errors.Errorf("%s: invalid byte size %d", field, n) + } + return n, nil +} + +// Client posts JSON strategy requests to an external decider. +type Client struct { + url string + client *http.Client + maxRequestBytes int64 + maxResponseBytes int64 + headers map[string]string +} + +func normalizeConfig(cfg Config) (Config, error) { + if err := validateURL(cfg.URL); err != nil { + return Config{}, err + } + var err error + cfg.MaxRequestBytes, err = parseSize(cfg.MaxRequestBytes, "maxRequestBytes") + if err != nil { + return Config{}, err + } + cfg.MaxResponseBytes, err = parseSize(cfg.MaxResponseBytes, "maxResponseBytes") + if err != nil { + return Config{}, err + } + return cfg, nil +} + +// NewClient resolves env-backed headers and builds a client. +func NewClient(cfg Config) (*Client, error) { + cfg, err := normalizeConfig(cfg) + if err != nil { + return nil, err + } + headers := make(map[string]string, len(cfg.Headers)) + for name, h := range cfg.Headers { + v := h.Value + if h.Env != "" { + v = os.Getenv(h.Env) + if v == "" { + return nil, errors.Errorf("headers.%s: env %q is empty", name, h.Env) + } + } + headers[name] = v + } + return &Client{ + url: cfg.URL, + client: &http.Client{ + Timeout: cfg.Timeout, + // Do not follow redirects: the https/loopback guard in validateURL only vets the configured + // URL, and Go forwards custom (secret-bearing) headers on same-host redirects. A redirect to + // an internal address would defeat both. Surface the 3xx to the caller instead (SSRF guard). + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }, + maxRequestBytes: cfg.MaxRequestBytes, + maxResponseBytes: cfg.MaxResponseBytes, + headers: headers, + }, nil +} + +// PostJSON sends req as JSON and decodes a strict JSON response into resp. +func (c *Client) PostJSON(ctx context.Context, req, resp any) error { + body, err := json.Marshal(req) + if err != nil { + return errors.Errorf("webhook: encode request: %w", err) + } + if int64(len(body)) > c.maxRequestBytes { + return errors.Errorf("webhook: request body exceeds %d bytes", c.maxRequestBytes) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body)) + if err != nil { + return errors.Errorf("webhook: build request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + for k, v := range c.headers { + httpReq.Header.Set(k, v) + } + httpResp, err := c.client.Do(httpReq) + if err != nil { + return errors.Errorf("webhook: post: %w", err) + } + defer httpResp.Body.Close() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(httpResp.Body, 1024)) + return errors.Errorf("webhook: status %d: %s", httpResp.StatusCode, string(b)) + } + b, err := readLimited(httpResp.Body, c.maxResponseBytes, "response body") + if err != nil { + return errors.Errorf("webhook: read response: %w", err) + } + if len(bytes.TrimSpace(b)) == 0 { + return errors.New("webhook: empty response") + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(resp); err != nil { + return errors.Errorf("webhook: decode response: %w", err) + } + var extra json.RawMessage + if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + err = errors.New("multiple JSON values") + } + return errors.Errorf("webhook: decode response: %w", err) + } + return nil +} + +func readLimited(r io.Reader, limit int64, label string) ([]byte, error) { + b, err := io.ReadAll(io.LimitReader(r, limit+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > limit { + return nil, errors.Errorf("%s exceeds %d bytes", label, limit) + } + return b, nil +} diff --git a/internal/webhook/client_test.go b/internal/webhook/client_test.go new file mode 100644 index 00000000..589efa19 --- /dev/null +++ b/internal/webhook/client_test.go @@ -0,0 +1,215 @@ +package webhook + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gopkg.in/yaml.v3" +) + +func testYAMLNode(t *testing.T, body string) yaml.Node { + t.Helper() + var doc yaml.Node + if err := yaml.Unmarshal([]byte(body), &doc); err != nil { + t.Fatalf("unmarshal yaml: %v", err) + } + return *doc.Content[0] +} + +func TestParseConfigAndResolveHeaders(t *testing.T) { + t.Setenv("TEST_AUTH_HEADER", "Bearer test") + _, err := ParseConfig(testYAMLNode(t, ` +url: http://strategy.example +timeout: 250ms +headers: + x-client: + value: vault-solver + authorization: + env: TEST_AUTH_HEADER +`)) + if err == nil { + t.Fatal("expected non-loopback http url to be rejected") + } + cfg, err := ParseConfig(testYAMLNode(t, ` +url: https://strategy.example +timeout: 250ms +maxRequestBytes: 2048 +maxResponseBytes: 4096 +headers: + x-client: + value: vault-solver + authorization: + env: TEST_AUTH_HEADER +`)) + if err != nil { + t.Fatalf("ParseConfig https: %v", err) + } + if cfg.URL != "https://strategy.example" || cfg.Timeout != 250*time.Millisecond { + t.Fatalf("unexpected config: %+v", cfg) + } + if cfg.MaxRequestBytes != 2048 || cfg.MaxResponseBytes != 4096 { + t.Fatalf("unexpected byte limits: %+v", cfg) + } + client, err := NewClient(cfg) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if client.headers["x-client"] != "vault-solver" || client.headers["authorization"] != "Bearer test" { + t.Fatalf("unexpected headers: %+v", client.headers) + } +} + +func TestParseConfigRejectsUnknownFields(t *testing.T) { + _, err := ParseConfig(testYAMLNode(t, ` +url: https://strategy.example +retries: 3 +`)) + if err == nil { + t.Fatal("expected unknown field error") + } +} + +func TestParseConfigRejectsInvalidByteLimits(t *testing.T) { + for _, field := range []string{"maxRequestBytes", "maxResponseBytes"} { + t.Run(field, func(t *testing.T) { + _, err := ParseConfig(testYAMLNode(t, "url: https://strategy.example\n"+field+": -1\n")) + if err == nil { + t.Fatalf("expected %s to be rejected", field) + } + }) + } +} + +func TestParseConfigAllowsLoopbackHTTP(t *testing.T) { + for _, rawURL := range []string{ + "http://localhost:8080/strategy", + "http://127.0.0.1:8080/strategy", + "http://[::1]:8080/strategy", + } { + t.Run(rawURL, func(t *testing.T) { + _, err := ParseConfig(testYAMLNode(t, "url: "+rawURL+"\n")) + if err != nil { + t.Fatalf("ParseConfig: %v", err) + } + }) + } +} + +func TestWebhookClientPostJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("content-type = %q, want application/json", got) + } + if got := r.Header.Get("x-client"); got != "vault-solver" { + t.Fatalf("x-client = %q, want vault-solver", got) + } + var req struct { + ID string `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.ID != "q1" { + t.Fatalf("request id = %q, want q1", req.ID) + } + _, _ = w.Write([]byte(`{"decision":"quote"}`)) + })) + defer srv.Close() + + client, err := NewClient(Config{ + URL: srv.URL, + Timeout: time.Second, + Headers: map[string]HeaderValue{ + "x-client": {Value: "vault-solver"}, + }, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + var resp struct { + Decision string `json:"decision"` + } + if err := client.PostJSON(t.Context(), struct { + ID string `json:"id"` + }{ID: "q1"}, &resp); err != nil { + t.Fatalf("PostJSON: %v", err) + } + if resp.Decision != "quote" { + t.Fatalf("decision = %q, want quote", resp.Decision) + } +} + +func TestWebhookClientPostJSONFailures(t *testing.T) { + cases := map[string]struct { + status int + body string + want string + }{ + "non-2xx": {status: http.StatusInternalServerError, body: "boom", want: "status 500"}, + "empty body": {status: http.StatusOK, body: " \n", want: "empty response"}, + "unknown field": {status: http.StatusOK, body: `{"decision":"quote","extra":1}`, want: "unknown field"}, + "trailing json": {status: http.StatusOK, body: `{"decision":"quote"}{"decision":"decline"}`, want: "multiple JSON values"}, + "too large": { + status: http.StatusOK, + body: strings.Repeat("x", defaultWebhookMaxBodyBytes+1), + want: "response body exceeds", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + client, err := NewClient(Config{URL: srv.URL, Timeout: time.Second}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + var resp struct { + Decision string `json:"decision"` + } + err = client.PostJSON(t.Context(), struct{}{}, &resp) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("PostJSON error = %v, want contains %q", err, tc.want) + } + }) + } +} + +func TestWebhookClientRejectsOversizedRequest(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + _, _ = w.Write([]byte(`{"decision":"quote"}`)) + })) + defer srv.Close() + + client, err := NewClient(Config{ + URL: srv.URL, + Timeout: time.Second, + MaxRequestBytes: 8, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + var resp struct { + Decision string `json:"decision"` + } + err = client.PostJSON(t.Context(), struct { + Payload string `json:"payload"` + }{Payload: "larger-than-limit"}, &resp) + if err == nil || !strings.Contains(err.Error(), "request body exceeds") { + t.Fatalf("PostJSON error = %v, want request body exceeds", err) + } + if called { + t.Fatal("server was called for an oversized request") + } +} diff --git a/openapi/3f-bf.openapi.json b/openapi/3f-bf.openapi.json index 4ca0a318..f337e847 100644 --- a/openapi/3f-bf.openapi.json +++ b/openapi/3f-bf.openapi.json @@ -146,7 +146,7 @@ }, "/v1/offer": { "post": { - "description": "Creates an offer for an auction, or updates the existing mutable offer for the same `auctionId`, `maker`, and `nonce`. If `signature` is provided, it is verified as an EIP-712 signature and the `maker` must be a registered facilitator. Contract wallets are supported via EIP-1271. If `signature` is omitted, a valid facilitator `x-api-key` header is required; when that facilitator has a configured offer address, that offer address is used as the stored `maker`.\n\n`expectedReturn` is the expected yield, not the total repayment. Total repayment is `amount + expectedReturn`.\n\n`expiration` is a Unix timestamp in seconds. Offers are expired only when `expiration` is lower than the current Unix second.\n\nSigned offer requests resolve their EIP-712 domain from the auction request contract on-chain. Set `domain.verifyingContract` to the request contract address for the selected auction. If the contract exposes a `salt`, include it; otherwise omit that field.\n\nExact typed data to sign with `viem`:\n\n```ts\nconst signature = await walletClient.signTypedData(\n{\n domain: {\n name: 'SuperstateRequest',\n version: '1',\n chainId: 11155111,\n verifyingContract: '0x1234567890abcdef1234567890abcdef12345678',\n salt: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',\n },\n types: {\n Offer: [\n {\n name: 'maker',\n type: 'address',\n },\n {\n name: 'amount',\n type: 'uint256',\n },\n {\n name: 'expectedReturn',\n type: 'uint256',\n },\n {\n name: 'nonce',\n type: 'uint256',\n },\n {\n name: 'expiration',\n type: 'uint256',\n },\n {\n name: 'useCallback',\n type: 'bool',\n },\n ],\n },\n primaryType: 'Offer',\n message: {\n maker: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD38',\n amount: 1000000000n,\n expectedReturn: 5000000n,\n nonce: 1n,\n expiration: 4102444800n,\n useCallback: false,\n },\n }\n)\n```\n\nSubmit the resulting signature in the request body `signature` field. All `uint256` request fields stay decimal strings in the HTTP payload.", + "description": "Creates an offer for an auction, or updates the existing mutable offer for the same `auctionId`, `maker`, and `nonce`. If `signature` is provided, the `maker` must be a registered facilitator address or that facilitator's configured offer address; signature executability is checked by the relayer before on-chain `consume`, so ERC-1271 approvals may become valid asynchronously. If `signature` is omitted, a valid facilitator `x-api-key` header is required; when that facilitator has a configured offer address, that offer address is used as the stored `maker`.\n\n`expectedReturn` is the expected yield, not the total repayment. Total repayment is `amount + expectedReturn`.\n\n`expiration` is a Unix timestamp in seconds. Offers are expired only when `expiration` is lower than the current Unix second.\n\nSigned offer requests resolve their EIP-712 domain from the auction request contract on-chain. Set `domain.verifyingContract` to the request contract address for the selected auction. If the contract exposes a `salt`, include it; otherwise omit that field.\n\nExact typed data to sign with `viem`:\n\n```ts\nconst signature = await walletClient.signTypedData(\n{\n domain: {\n name: 'SuperstateRequest',\n version: '1',\n chainId: 11155111,\n verifyingContract: '0x1234567890abcdef1234567890abcdef12345678',\n salt: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',\n },\n types: {\n Offer: [\n {\n name: 'maker',\n type: 'address',\n },\n {\n name: 'amount',\n type: 'uint256',\n },\n {\n name: 'expectedReturn',\n type: 'uint256',\n },\n {\n name: 'nonce',\n type: 'uint256',\n },\n {\n name: 'expiration',\n type: 'uint256',\n },\n {\n name: 'useCallback',\n type: 'bool',\n },\n ],\n },\n primaryType: 'Offer',\n message: {\n maker: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD38',\n amount: 1000000000n,\n expectedReturn: 5000000n,\n nonce: 1n,\n expiration: 4102444800n,\n useCallback: false,\n },\n }\n)\n```\n\nSubmit the signature bytes in the request body `signature` field. For deferred ERC-1271 approval, submit `0x` while the contract approval transaction is pending. All `uint256` request fields stay decimal strings in the HTTP payload.", "operationId": "OfferController_create_v1", "parameters": [ { @@ -720,7 +720,7 @@ "properties": { "chainId": { "type": "number", - "description": "Chain ID for signature verification", + "description": "Chain ID for resolving the request EIP-712 domain", "example": 1 }, "auctionId": { @@ -761,9 +761,8 @@ }, "signature": { "type": "string", - "pattern": "^0x[a-fA-F0-9]{130}$", - "description": "EIP-712 signature (required if chainId is provided)", - "example": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12" + "description": "EIP-712/EIP-1271 signature bytes. Use `0x` while deferred EIP-1271 approval is pending. Required if chainId is provided.", + "example": "0x" } }, "required": [ @@ -815,7 +814,6 @@ }, "signature": { "type": "string", - "pattern": "^0x[a-fA-F0-9]{130}$", "description": "EIP-712 signature (required if chainId is provided)", "example": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12" } @@ -1159,14 +1157,14 @@ ] }, "direction": { - "type": "string", + "nullable": true, "enum": [ "subscription", "redemption" ], + "type": "string", "description": "Auction direction derived from the first facility-intent operation, or null if no operation has been recorded yet", - "example": "subscription", - "nullable": true + "example": "subscription" }, "eip712Domain": { "nullable": true, diff --git a/openapi/redstone-oev-ws.zod.ts b/openapi/redstone-oev-ws.zod.ts new file mode 100644 index 00000000..069ec29d --- /dev/null +++ b/openapi/redstone-oev-ws.zod.ts @@ -0,0 +1,68 @@ +// Vendored verbatim from RedStone (received via direct chat, 2026-06-12). +// Contract-of-record for the RedStone Atom OEV WebSocket messages, per the repo's +// vendor-the-source rule (CLAUDE.md "Code generation"). Not built or executed here — +// the Go structs in internal/solvers/redstoneoev/ are pinned to this file by tests. +// +// KNOWN GAP: RedStone has not (yet) shared the schema of the inbound auction broadcast +// (`op: "auction"`, incl. the liquidations-mode positions/prices payload). Until they do, +// that frame's contract-of-record is the docs example + live frames captured in P0 +// (see docs/OEV-PLAN.md §6.1, §6.3) — formalized in openapi/redstone-oev.asyncapi.yaml. +// +// Fields RedStone's schema adds beyond the public docs: +// solve.data.borrowers?: string[] — semantics unconfirmed (asked; likely telemetry/validation) +// solve.data.profit?: string — semantics unconfirmed (asked) +// liquidation-result.data.error?: string + +const WsMessageSubSchema = z.object({ + op: z.literal('subscribe'), + topic: z.string(), +}); + +const WsMessageUnSubSchema = z.object({ + op: z.literal('unsubscribe'), + topic: z.string(), +}); + +const WsMessageSolveSchema = z.object({ + op: z.literal('solve'), + id: z.string(), + data: z.object({ + bid: z.string(), + nonce: z.string(), + operationCallback: z.string(), + operationData: z.string(), + liquidationSig: z.string(), + maxTxGasPrice: z.string(), + borrowers: z.array(z.string()).optional(), + profit: z.string().optional(), + }), +}); + +const WsMessageAuctionResult = z.object({ + op: z.literal('auction-result'), + id: z.string(), + data: z.object({ + bid: z.string(), + liquidator: z.string(), + }), +}); + +const WsMessageLiquidationResult = z.object({ + op: z.literal('liquidation-result'), + id: z.string(), + data: z.object({ + success: z.boolean(), + txHash: z.string(), + liquidator: z.string(), + error: z.string().optional(), + }), +}); + +const WsMessageBlacklist = z.object({ + op: z.literal('blacklisted'), + id: z.string(), + data: z.object({ + liquidator: z.string(), + msg: z.string(), + }), +}); diff --git a/scripts/oev/addresses.sepolia.json b/scripts/oev/addresses.sepolia.json new file mode 100644 index 00000000..cd4370ce --- /dev/null +++ b/scripts/oev/addresses.sepolia.json @@ -0,0 +1,25 @@ +{ + "_comment": "RedStone OEV Sepolia testbed addresses - the manifest scripts/oev/oev-balance.sh reads. Single-adapter deploy.", + "chainId": 11155111, + "owner": "0x812492C36b003837C30cB0B63960b86eC9B27309", + "instance": { + "vault": "0xb99F1FeA50f40Bb7C5E568c2De6D79dd0b61EB3A", + "adapter": "0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b", + "account": "0xE86974B0B302C389f746AC088E42732A754A941C", + "callback": "0x065B612a182f360D4428cD00a8094049B3c92168" + }, + "external": { + "redstoneExecutor": "0xFdFB1862a53a974b166d1f0D012f524Ebd2e0EbD", + "morpho": "0xd011EE229E7459ba1ddd22631eF7bF528d424A14", + "morphoMarket": "0x6209dbd022c20923c071d7183d7a9729a75596136540d474a27d08ef31f440a5", + "morphoOracle": "0xfED5bC312C7139743bc3ab21Ef92f5AeB353339D", + "collateralFeed": "0x6beE2D4dC04afb93b8117849138aA4fCa300c788", + "tloan": "0x468BB3245BF520a0CD030BDE029c98aCEAF84C9d", + "tcol": "0x17e892d4E802B01d7DA49Ca3542560f6851AA4D3", + "testPositions": [ + "0x629d764eC8563AFA701709B52c1a215e865632dE", + "0x378A49C640fD9EeA888A6a553CAae441E2fdebC6", + "0xa42B7e0819DC251445841D1476F30841Fda310E9" + ] + } +} diff --git a/scripts/oev/oev-balance.sh b/scripts/oev/oev-balance.sh new file mode 100755 index 00000000..ed38eaa0 --- /dev/null +++ b/scripts/oev/oev-balance.sh @@ -0,0 +1,272 @@ +#!/usr/bin/env bash +# scripts/oev/oev-balance.sh — full "where is the money" balance sheet + rebalance for the RedStone OEV +# Sepolia testbed, so an e2e run is repeatable: see every pool, then restore the ones a liquidation +# drains. Complements scripts/oev/oev-testrun.sh (which drives the bot) and RedStone's harness (positions/feed). +# +# Why this exists: on the testnet the LiquidLane Account is a STUB — it values seized RWA but never +# redeems it. So each liquidation +# • drains the vault's freeAssets (TLOAN fronted to repay Morpho — never replenished by redemption), +# • drains the callback's native ETH (payBid, 0.0005/bid), +# • drains the Executor deposit (gas liability; below the floor the bot self-stops and won't bid), +# • grows the callback's TLOAN (retained profit) and the Account's TCOL (seized, unredeemed). +# `rebalance` recycles the retained profit back into the vault (simulating the missing redemption) and +# tops the ETH pools back up — all signed by the owner key — then defers positions to RedStone's reset. +# +# Reads need only an RPC. Writes need the owner key (OEV_SIGNER_PRIVATE_KEY == manifest `owner`). +# Default action is the read-only sheet; every write is an explicit subcommand. Addresses come from the +# committed manifest (scripts/oev/addresses.sepolia.json) — the single source of truth, no hardcoding. +# +# Usage: +# ETH_RPC_URL_SEPOLIA=https://… scripts/oev/oev-balance.sh [sheet] # read-only balance sheet (default) +# … scripts/oev/oev-balance.sh topup-callback [ETH] # send ETH to the callback (payBid fuel) +# … scripts/oev/oev-balance.sh recycle [TLOAN] # sweep callback profit → vault freeAssets +# … scripts/oev/oev-balance.sh topup-deposit [ETH] # top up the Executor gas deposit (guarded) +# … scripts/oev/oev-balance.sh setup-callback # authorize + fund a new no-preview callback +# … scripts/oev/oev-balance.sh reset # re-arm positions (delegates to RedStone harness) +# … scripts/oev/oev-balance.sh rebalance # recycle + topup-callback (+deposit, +RESET=1 reset) +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MANIFEST="${OEV_MANIFEST:-$(dirname "$0")/addresses.sepolia.json}" +CONFIG="${OEV_CONFIG:-$ROOT/config/redstone-oev.example.yaml}" +HARNESS="${OEV_HARNESS:-/tmp/symbiotic/symbiotic}" +RPC="${ETH_RPC_URL_SEPOLIA:-${OEV_LIVE_RPC:-${RPC:-}}}" + +# Rebalance targets (override via env). Deposit default is kept above the bot's pre-bid floor. +TARGET_CALLBACK_ETH="${TARGET_CALLBACK_ETH:-0.05}" +TARGET_DEPOSIT_ETH="${TARGET_DEPOSIT_ETH:-0.06}" +KEEP_PROFIT_TLOAN="${KEEP_PROFIT_TLOAN:-0}" # TLOAN to leave in the callback when recycling + +command -v cast >/dev/null || { echo "need foundry 'cast' on PATH (https://getfoundry.sh)" >&2; exit 1; } +command -v jq >/dev/null || { echo "need 'jq' on PATH" >&2; exit 1; } +[ -f "$MANIFEST" ] || { echo "manifest not found: $MANIFEST" >&2; exit 1; } +[ -n "$RPC" ] || { echo "set ETH_RPC_URL_SEPOLIA (Sepolia RPC URL)" >&2; exit 1; } + +m() { jq -r "$1" "$MANIFEST"; } +OWNER=$(m .owner) +EXECUTOR=$(m .external.redstoneExecutor) +MORPHO=$(m .external.morpho) +MARKET=$(m .external.morphoMarket) +ORACLE=$(m .external.morphoOracle) +FEED=$(m .external.collateralFeed) +TLOAN=$(m .external.tloan) +TCOL=$(m .external.tcol) +VAULT=$(m .instance.vault) +ADAPTER=$(m .instance.adapter) +ACCOUNT=$(m .instance.account) +CALLBACK=$(m .instance.callback) +# shellcheck disable=SC2207 # addresses are whitespace-free; word-split into an array (bash 3.2: no mapfile) +POSITIONS=( $(m '.external.testPositions[]') ) + +# Read a numeric scalar (int or decimal) from the bot config — e.g. `ynum bidEth`. Tolerant: a missing key +# yields empty (not a grep exit-1 that would abort the whole sheet under set -e + pipefail). +ynum() { grep -oE "$1:[[:space:]]*\"?[0-9]+(\.[0-9]+)?" "$CONFIG" | grep -oE '[0-9.]+' | tail -1 || true; } + +# --- chain read helpers (tolerant: empty on revert, never abort the sheet) ---------------------- +call() { cast call "$@" --rpc-url "$RPC" 2>/dev/null | awk 'NR==1{print $1}' || true; } +bal() { cast balance "$1" --rpc-url "$RPC" 2>/dev/null | awk '{print $1}' || true; } +# Pipe-processed reads as named functions, so bg() can fan them out like call/bal. +read_feed() { cast call "$FEED" 'latestRoundData()(uint80,int256,uint256,uint256,uint80)' --rpc-url "$RPC" 2>/dev/null | awk 'NR==2{print $1}' || true; } +read_pos() { cast call "$MORPHO" 'position(bytes32,address)(uint256,uint128,uint128)' "$MARKET" "$1" --rpc-url "$RPC" 2>/dev/null | awk '{print $1}' || true; } +# bg — run a read concurrently; its stdout is captured to $RD/ (RD set by sheet). +bg() { local k="$1"; shift; ( "$@" >"$RD/$k" ) & } +# fmt — the one numeric formatter behind the named units (n/a on empty). +fmt() { awk -v w="${1:-}" -v d="$2" -v p="$3" 'BEGIN{ if(w=="")print "n/a"; else printf "%.*f", p, w/d }'; } +eth() { fmt "${1:-}" 1e18 6; } +t6() { fmt "${1:-}" 1e6 4; } +t18() { fmt "${1:-}" 1e18 4; } +usd() { fmt "${1:-}" 1e24 2; } +row() { printf " %-22s %s\n" "$1" "$2"; } + +sheet() { + # config-derived thresholds the warnings compare against (only the sheet needs them). + local MIN_DEPOSIT BID_ETH BID_WEI + # The bot bids when the Executor deposit ≥ MIN_DEPOSIT (solver.go minDeposit=1e13). Gas is debited from + # the deposit post-settlement, independent of the auction, and not pre-reserved — so there is no gas floor. + MIN_DEPOSIT=10000000000000 + BID_ETH=$(ynum bidEth); BID_ETH=${BID_ETH:-0.0005} + BID_WEI=$(cast to-wei "$BID_ETH" ether) + + local RD i eoaEth depWei nonce locked cbEth cbLoan vFree vTotal rate maxAssets acTcol acAssets price feed + RD=$(mktemp -d) + # Fan out the independent reads concurrently — one wave instead of ~15 serial RPC round-trips. + bg eoaEth bal "$OWNER" + bg depWei call "$EXECUTOR" 'deposits(address)(uint256)' "$OWNER" + bg nonce call "$EXECUTOR" 'nonces(address)(uint256)' "$OWNER" + bg locked call "$EXECUTOR" 'locked(address)(bool)' "$OWNER" + bg cbEth bal "$CALLBACK" + bg cbLoan call "$TLOAN" 'balanceOf(address)(uint256)' "$CALLBACK" + bg vFree call "$VAULT" 'freeAssets()(uint256)' + bg vTotal call "$VAULT" 'totalAssets()(uint256)' + bg rate call "$ADAPTER" 'getMaxRate(address)(uint256)' "$TCOL" + bg maxAssets call "$ADAPTER" 'getMaxAssets(address)(uint256)' "$TCOL" + bg acTcol call "$TCOL" 'balanceOf(address)(uint256)' "$ACCOUNT" + bg acAssets call "$ACCOUNT" 'totalAssets()(uint256)' + bg price call "$ORACLE" 'price()(uint256)' + bg feed read_feed + for i in "${!POSITIONS[@]}"; do bg "pos$i" read_pos "${POSITIONS[$i]}"; done + wait + eoaEth=$(cat "$RD/eoaEth"); depWei=$(cat "$RD/depWei"); nonce=$(cat "$RD/nonce"); locked=$(cat "$RD/locked") + cbEth=$(cat "$RD/cbEth"); cbLoan=$(cat "$RD/cbLoan"); vFree=$(cat "$RD/vFree"); vTotal=$(cat "$RD/vTotal") + rate=$(cat "$RD/rate"); maxAssets=$(cat "$RD/maxAssets"); acTcol=$(cat "$RD/acTcol"); acAssets=$(cat "$RD/acAssets") + price=$(cat "$RD/price"); feed=$(cat "$RD/feed") + + echo "════════════════════════ OEV money balance sheet (Sepolia) ════════════════════════" + echo " market price (oracle): \$$(usd "$price") feed: \$$(fmt "${feed:-}" 1e8 2)" + echo "── SIGNER / EXECUTOR ($OWNER)" + row "EOA balance:" "$(eth "$eoaEth") ETH" + row "Executor deposit:" "$(eth "$depWei") ETH (MIN_DEPOSIT $(eth "$MIN_DEPOSIT"))" + row "Executor nonce:" "${nonce:-n/a} locked: ${locked:-n/a}" + echo "── CALLBACK ($CALLBACK)" + row "native (payBid):" "$(eth "$cbEth") ETH (~$(awk -v c="${cbEth:-0}" -v b="$BID_WEI" 'BEGIN{printf "%d", (b>0)?c/b:0}') bids at $BID_ETH ETH)" + row "TLOAN (profit):" "$(t6 "$cbLoan") TLOAN ← recyclable into the vault" + echo "── VAULT ($VAULT)" + row "freeAssets:" "$(t6 "$vFree") TLOAN (deployable liquidity)" + row "totalAssets:" "$(t6 "$vTotal") TLOAN" + echo "── ADAPTER ($ADAPTER)" + row "getMaxRate(TCOL):" "$(t18 "$rate") TLOAN/TCOL (RWA sell price, net discount)" + row "getMaxAssets:" "$(t6 "$maxAssets") TLOAN (per-swap liquidity cap)" + echo "── ACCOUNT ($ACCOUNT) [stub: values, does NOT redeem]" + row "TCOL held (seized):" "$(t18 "$acTcol") TCOL (accumulates unredeemed)" + row "totalAssets (valued):" "$(t6 "$acAssets") TLOAN" + echo "── POSITIONS (market $MARKET)" + local b pos coll bshares + for i in "${!POSITIONS[@]}"; do + b="${POSITIONS[$i]}" + # position() → (supplyShares, borrowShares, collateral), one field per line (read above); take 2nd, 3rd. + # shellcheck disable=SC2207 # whitespace-free fields → array + pos=( $(cat "$RD/pos$i") ) + bshares="${pos[1]:-}"; coll="${pos[2]:-}" + row "${b:0:10}…" "collateral $(t18 "$coll") TCOL borrowShares ${bshares:-n/a}" + done + + # --- warnings: what blocks the next e2e run --- + echo "──────────────────────────────────────────────────────────────────────────────────" + # Exact integer comparisons (all values are sub-ETH/uint128 wei, well within bash's 64-bit ints — no + # awk float rounding). A failed read comes back empty; report THAT distinctly rather than treating it + # as a passing threshold (a flaky read must never print the green "ready" banner). + local warned=0 + if [ -z "$depWei" ]; then + echo " ⚠ could not read Executor deposit (RPC error) — cannot confirm the bot will bid"; warned=1 + elif [ "$depWei" -lt "$MIN_DEPOSIT" ]; then + echo " ⚠ Executor deposit < MIN_DEPOSIT ($(eth "$MIN_DEPOSIT") ETH) — the bot will NOT bid. Fix: topup-deposit"; warned=1 + fi + if [ -z "$cbEth" ]; then + echo " ⚠ could not read callback native balance (RPC error)"; warned=1 + elif [ "$cbEth" -lt "$BID_WEI" ]; then + echo " ⚠ callback native < one bid ($BID_ETH ETH) — payBid would revert. Fix: topup-callback"; warned=1 + fi + if [ -z "$vFree" ]; then + echo " ⚠ could not read vault freeAssets (RPC error)"; warned=1 + elif [ "$vFree" -eq 0 ]; then + echo " ⚠ vault freeAssets = 0 — no liquidity to front a swap. Fix: recycle (or RedStone mint+deposit)"; warned=1 + fi + [ "$warned" = 0 ] && echo " ✓ all pools above their thresholds — ready for an e2e run" + echo "════════════════════════════════════════════════════════════════════════════════════" + rm -rf "$RD" +} + +# --- writes (owner key required) ---------------------------------------------------------------- +# CAVEAT: writes must go through a RELAYING RPC. The public Alchemy Sepolia endpoint accepts txs into a +# private pool without relaying them (they silently never land); point ETH_RPC_URL_SEPOLIA at a public +# relay for writes, e.g. https://ethereum-sepolia-rpc.publicnode.com (docs/OEV-PLAN.md §6.7). +need_key() { + : "${OEV_SIGNER_PRIVATE_KEY:?set OEV_SIGNER_PRIVATE_KEY (the owner key) for write ops}" + local from lc_from lc_owner + from=$(cast wallet address --private-key "$OEV_SIGNER_PRIVATE_KEY") + lc_from=$(printf '%s' "$from" | tr 'A-Z' 'a-z') + lc_owner=$(printf '%s' "$OWNER" | tr 'A-Z' 'a-z') + if [ "$lc_from" != "$lc_owner" ]; then + echo "key address $from != manifest owner $OWNER — refusing to send" >&2; exit 1 + fi + SEND=(cast send --private-key "$OEV_SIGNER_PRIVATE_KEY" --rpc-url "$RPC") +} + +# topup_delta