diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b3d0c547..57159844 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -25,7 +25,7 @@ jobs: - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # pin@v3 - - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # pin@v4 + - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # pin@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} diff --git a/Makefile b/Makefile index 092dab9d..c6b10d3c 100644 --- a/Makefile +++ b/Makefile @@ -32,15 +32,19 @@ RFQ_OPENAPI_URL ?= https://backend-production-a0ca.up.railway.app/api/v1/openapi # JSON endpoint — the spec is embedded inline in the page, so refresh-lifi-openapi pulls the HTML and # extracts it via hack/scalar-openapi-extract.py (see that target). LIFI_OPENAPI_URL ?= https://order-dev.li.fi/docs +UNISWAPX_OPENAPI_URL ?= https://raw.githubusercontent.com/Uniswap/uniswapx-service/main/swagger.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 (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 +# CORE_MIRROR_ABIS (the 3F ThreeFAdapter, LiquidLane adapter, adapter factory, universal delegator, +# and vault/ERC4626 interfaces) come from the core-mirror build, since they aren't in rfq/out. +ABIS := IRequest IVaultController IWhitelist Executor Reactor LiquidLaneLifiExecutor LiquidLaneUniswapXExecutor +CORE_MIRROR_ABIS := ThreeFAdapter LiquidLaneAdapter IAdapterFactory IVaultV2 IERC4626 # api/abi/UniversalDelegator.json is hand-vendored to a minimal {limitOf} ABI (the full contract has # an overloaded deallocateAll that abigen rejects, and the solver only reads limitOf) — like Multicall3. +# api/abi/FrontendLiquidityLens.json is likewise hand-vendored to the two overloaded getMaxAssets views +# (getMaxAssets(adapter) for 3F, getMaxAssets(adapter,tokenToRedeem) for LiquidLane) — the core lens that +# replaces each adapter's own getMaxAssets with a cross-adapter deallocation-cascade estimate. # 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. @@ -54,16 +58,20 @@ CORE_MIRROR_ABIS := ThreeFAdapter LiquidLaneAdapter IVaultV2 IERC4626 BINDINGS_V2 := ThreeFAdapter:3f/adapter IRequest:3f/request \ IVaultController:3f/vaultcontroller IWhitelist:3f/whitelist \ LiquidLaneAdapter:liquidlane/adapter Executor:rfq/executor Reactor:rfq/reactor \ - UniversalDelegator:delegator IVaultV2:vaultv2 IERC4626:erc4626 \ + LiquidLaneLifiExecutor:lifi/executor LiquidLaneUniswapXExecutor:uniswapx/executor \ + ILifiInputSettler:lifi/inputsettler \ + IAdapterFactory:adapterfactory UniversalDelegator:delegator IVaultV2:vaultv2 IERC4626:erc4626 \ SymbioticOevSolver:oev/callback RedStoneExecutor:oev/executor Morpho:oev/morpho \ AdaptiveCurveIrm:oev/irm MorphoOracle:oev/oracle \ - AggregatorV3:oev/aggregator \ + AggregatorV3:chainlink/aggregator \ + FrontendLiquidityLens:lens \ ERC20:erc20 Multicall3:multicall3 # The OEV contracts (Morpho + its AdaptiveCurve IRM + market oracle, RedStone -# Executor, SymbioticOevSolver) plus a minimal ERC20 (decimals() only) aren't in our Foundry build, so their -# ABIs are hand-vendored under api/abi/ (not in ABIS/CORE_MIRROR_ABIS/refresh-abi). RedStoneExecutor avoids -# the rfq Executor name clash; solver ERC-20 reads (asset/balanceOf) reuse erc4626, the generic -# chain.Decimals reader uses erc20. +# Executor, SymbioticOevSolver), the LI.FI input settler ABI, plus a minimal ERC20 +# (decimals() only) aren't in our default Foundry build, so their ABIs are hand-vendored under +# api/abi/ (not in ABIS/CORE_MIRROR_ABIS/refresh-abi). RedStoneExecutor avoids the rfq Executor +# name clash; solver ERC-20 reads (asset/balanceOf) reuse erc4626, the generic chain.Decimals reader +# uses erc20. # Multicall3 is v2 like everything else — api/abi/Multicall3.json is hand-vendored (not a Foundry contract), # so it's in BINDINGS_V2 but not ABIS. The chain.Multicall transport packs/unpacks aggregate3 and does its # own eth_call. @@ -123,6 +131,12 @@ refresh-lifi-openapi: ## Re-pull the LI.FI order-server OpenAPI spec (LIFI_OPENA curl -fsSL "$(LIFI_OPENAPI_URL)" | python3 hack/scalar-openapi-extract.py > openapi/lifi-order.openapi.json @echo "vendored openapi/lifi-order.openapi.json (extracted from the Scalar /docs page)" +.PHONY: refresh-uniswapx-openapi +refresh-uniswapx-openapi: ## Re-pull the UniswapX order-pool OpenAPI spec + @mkdir -p openapi + curl -fsSL "$(UNISWAPX_OPENAPI_URL)" | jq . > openapi/uniswapx-service.openapi.json + @echo "vendored openapi/uniswapx-service.openapi.json" + .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 @@ -132,7 +146,7 @@ refresh-morpho-graphql-schema: ## Re-pull the live Morpho GraphQL schema SDL (MO .PHONY: bindings bindings: ## Generate Go bindings from vendored ABIs (grouped per integration; package = leaf dir) - @for pair in $(BINDINGS_V2); do \ + @set -e; for pair in $(BINDINGS_V2); do \ c="$${pair%%:*}"; rel="$${pair##*:}"; pkg="$${rel##*/}"; \ abi="api/abi/$$c.json"; \ if [[ ! -f "$$abi" ]]; then echo "missing $$abi (run make refresh-abi)"; exit 1; fi; \ @@ -141,15 +155,12 @@ bindings: ## Generate Go bindings from vendored ABIs (grouped per integration; p echo "generated api/bindings/$$rel/$$c.go (v2)"; \ done -# All three OpenAPI clients are generated with the Java openapi-generator (via hack/openapi-generator-cli.sh, +# All 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 # backend's OpenAPI 3.1 spec; we use it for the 3F (3.0) and LI.FI order-server specs too for one toolchain. # $(OPENAPI_GENERATOR_VERSION) is the floor — 5.4.0/7.0.1 fail on the 3.1 spec. The generated package is # stdlib-only (no go.mod change); the recipes strip the generator's non-package cruft, keeping just the Go -# client. $(4) is optional extra generator flags — used only by the LI.FI recipe to pass -# --skip-validate-spec (its spec is labelled OpenAPI 3.0.0 but uses 3.1 JSON-Schema constructs — prefixItems / -# propertyNames — and has dangling oneOf $refs; the generator handles them fine but its strict validator -# rejects them). 3f/rfq keep validation on. +# client. $(4) is available for source-specific generator flags; current specs generate with validation on. define gen_openapi_client GO_POST_PROCESS_FILE='gofmt -w' OPENAPI_GENERATOR_VERSION=$(OPENAPI_GENERATOR_VERSION) bash ./hack/openapi-generator-cli.sh \ generate --enable-post-process-file $(4) -i ./$(1) -g go -o ./$(2) --package-name $(3) @@ -169,18 +180,19 @@ refresh-rfq-client: ## Generate the RFQ backend client (openapi-generator, Go) f .PHONY: refresh-lifi-client refresh-lifi-client: ## Generate the LI.FI order-server client (openapi-generator, Go) from the vendored spec @rm -f api/lifiorder/*.go - @# The raw vendored spec has two upstream defects that make the generated Go uncompilable (dangling - @# oneOf $refs in QuoteDto.order; multi-tag operations that duplicate request structs). We keep the - @# vendored file raw (contract of record) and generate from a normalized temp copy produced by - @# hack/lifi-openapi-normalize.py (see that script for the exact, documented fixes). Inlined rather than - @# using gen_openapi_client so the normalization + temp-file plumbing lives in one shell block; - @# --skip-validate-spec is still needed (the spec is labelled 3.0.0 but uses 3.1 JSON-Schema constructs). - tmp="$$(mktemp -p . --suffix=.lifi-normalized.json)"; \ - trap 'rm -f "$$tmp"' EXIT; \ - python3 hack/lifi-openapi-normalize.py < openapi/lifi-order.openapi.json > "$$tmp"; \ + $(call gen_openapi_client,openapi/lifi-order.openapi.json,api/lifiorder,lifiorder) + +.PHONY: refresh-uniswapx-client +refresh-uniswapx-client: ## Generate the UniswapX order-pool client from the vendored spec + @rm -f api/uniswapxservice/*.go + @tmpdir="$$(mktemp -d)"; \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + tmp="$$tmpdir/uniswapx-normalized.json"; \ + python3 hack/uniswapx-openapi-normalize.py < openapi/uniswapx-service.openapi.json > "$$tmp"; \ GO_POST_PROCESS_FILE='gofmt -w' OPENAPI_GENERATOR_VERSION=$(OPENAPI_GENERATOR_VERSION) bash ./hack/openapi-generator-cli.sh \ - generate --enable-post-process-file --skip-validate-spec -i "$$tmp" -g go -o ./api/lifiorder --package-name lifiorder - cd api/lifiorder && rm -rf go.mod go.sum .gitignore .openapi-generator-ignore .travis.yml git_push.sh README.md api docs test .openapi-generator + generate --enable-post-process-file -i "$$tmp" -g go -o ./api/uniswapxservice --package-name uniswapxservice \ + --additional-properties=useOneOfDiscriminatorLookup=true + cd api/uniswapxservice && rm -rf go.mod go.sum .gitignore .openapi-generator-ignore .travis.yml git_push.sh README.md api docs test .openapi-generator .PHONY: refresh-morpho-graphql-client refresh-morpho-graphql-client: ## Generate the Morpho GraphQL client (genqlient) from the vendored schema + operations @@ -194,7 +206,7 @@ refresh-morpho-graphql-client: ## Generate the Morpho GraphQL client (genqlient) @gofmt -w api/morphographql/generated.go .PHONY: openapi-client -openapi-client: refresh-3f-client refresh-rfq-client refresh-lifi-client ## Generate all OpenAPI clients +openapi-client: refresh-3f-client refresh-rfq-client refresh-lifi-client refresh-uniswapx-client ## Generate all OpenAPI clients .PHONY: graphql-client graphql-client: refresh-morpho-graphql-client ## Generate GraphQL clients @@ -216,6 +228,10 @@ test: ## Run tests with race detector + coverage (hermetic only; fork/live suite test-oev-live: ## OEV live checks — Morpho API discovery plus optional Sepolia fork payload dump go test -tags live -run TestLive -v ./internal/solvers/redstoneoev/... +.PHONY: test-txmanager-anvil +test-txmanager-anvil: ## Exercise replacement/cancellation against an Anvil mempool with automine disabled + go test -race -tags integration -run TestAnvilTxManagerPendingLifecycle -v ./internal/txmanager + .PHONY: format format: ## Run golangci-lint with autofix golangci-lint run --fix diff --git a/README.md b/README.md index f23a04f3..5288a250 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ are listed under [Solvers](#solvers). - **`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. + Multicall3 reads, a pluggable signer, and a nonce-serialized transaction broadcaster with independent + receipt waits, shared across solvers. - **`api/`** — committed codegen: contract `bindings/` (abigen) and protocol API clients, each refreshable from upstream. @@ -39,8 +40,10 @@ and validated by its own solver. Adding a solver touches **no** framework code | `3f-bridge-facilitator` | 3F (Grunt) bridge-loan auctions | [plan](docs/3F-PLAN.md) | [yaml](config/3f.example.yaml) | | `rfq-filler` | Symbiotic RFQ quoting + order filling | [plan](docs/RFQ-PLAN.md) | [yaml](config/rfq.example.yaml) | | `redstone-oev` | RedStone OEV liquidations | [plan](docs/OEV-PLAN.md) | [yaml](config/redstone-oev.example.yaml) | +| `lifi-samechain` | LI.FI same-chain intents over LiquidLane | [plan](docs/LIFI-PLAN.md) | [yaml](config/lifi.example.yaml) | +| `uniswapx-filler` | UniswapX V2 RFQ quoting and LiquidLane filling | [plan](docs/UNISWAPX-PLAN.md) | [yaml](config/uniswapx.example.yaml) | -The `3f-bridge-facilitator`, `rfq-filler`, and `redstone-oev` solvers expose a pluggable +All solvers expose a pluggable **strategy** — the built-in `default` or an external `webhook` you run; see [Strategies](#strategies). @@ -51,9 +54,15 @@ or more Symbiotic `BridgeFacilitatorAdapter`s. 3F auctions the right to front a 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 +It holds no API key: each adapter is registered with 3F by its vault creator, who authorizes this +solver's signer as the adapter's offer signer — directly (an EOA) or via an EIP-1271 contract signer — +so offers are authorized by signature alone. Design, config, +and roadmap: [`docs/3F-PLAN.md`](docs/3F-PLAN.md). When `adapters` is present, the solver operates only +on that explicit list. Otherwise it discovers all entries of the configured on-chain `IAdapterFactory`, +refreshing before each auction-discovery pass with a hard 2,000-entity safety limit; a larger reported +count is an error. Either source is filtered to non-zero vault/asset targets that authorize this +solver's signer (validated via the adapter's ERC-1271 `isValidSignature`). An empty factory is valid and +is polled until eligible adapters appear. Example: [`config/3f.example.yaml`](config/3f.example.yaml). ### RFQ Filler — `rfq-filler` @@ -65,9 +74,18 @@ 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). +External mode also fails startup unless that executor has direct `owner`/`marketMaker`/`isFiller` +authorization on every configured adapter; the fatal startup log includes the executor, configured adapters, +and underlying authorization error. +When `tokensToQuote: permissioned`, admitted inputs are never aggregated: the selected strategy must +use one candidate route. Other scopes keep the existing multi-route behavior. +`minAmountsIn` adds an optional per-input-token floor on request size (base units, decimal strings): +a request below its token's minimum is not quoted (HTTP 204), while an amount equal to the minimum +still quotes; unlisted tokens have no floor. When an exact-input request exceeds the advertised adapter capacity, the default strategy caps the -quoted output at the available `maxAssets` instead of declining; the excess input is reflected as -worse execution price and price impact. +quoted output at the available `maxAssets` instead of declining in every token scope; the excess input +is reflected as worse execution price and price impact. Awarded orders are planned again from current +LiquidLane state at fill time; the solver does not retain quote-time route plans. Design, config, and roadmap: [`docs/RFQ-PLAN.md`](docs/RFQ-PLAN.md) · example [`config/rfq.example.yaml`](config/rfq.example.yaml). @@ -89,6 +107,126 @@ and roadmap: [`docs/OEV-PLAN.md`](docs/OEV-PLAN.md) · example [`config/redstone-oev.example.yaml`](config/redstone-oev.example.yaml). +### LI.FI Same-Chain Intents — `lifi-samechain` + +A same-chain LI.FI Intents solver for LiquidLane-backed RWA → underlying routes. It publishes gas-aware +standing quotes from current adapter liquidity and receives matched, already-opened escrow orders over the +LI.FI WebSocket feed. Before each fill it rechecks the canonical order status, adapter state, gas cost, and +strategy decision, then atomically claims the input, redeems it through LiquidLane, and fills the output via +`LiquidLaneLifiExecutor`. Capacity reserved by already-submitted fills is deducted from both later fill +decisions and standing quotes until those transactions complete. The published quote ladder is not replayed +at fill time: the solver greedily rebuilds the best current route plan, and redeemed output above the order +requirement remains executor surplus. The default strategy prices every standing range by running the shared +LiquidLane exact-input quote solver at both endpoints. It publishes the lower endpoint rate capped by a +linear conservative floor for interior route transitions, worst-case route gas, and rounding. +`strategy.config.rangeCount` sets the geometric curve resolution (default `8`, maximum `16`). + +The executor contract is the registered LI.FI solver account. It is registered once through EIP-1271 using +a caller signature bound to the executor's EIP-712 domain, appears as `exclusiveFor` in quotes, and calls the +settler's direct finalise path. The framework signer is an authorized executor caller and transaction sender; +fills do not carry a per-order `AllowOpen` signature. +The owner manages callers, while ERC-1271 validates domain-separated registration signatures against the +current callers. + +Our deployment convention is one LI.FI API key per registered executor contract. LI.FI can register +multiple accounts under one key, but this deployment deliberately does not share a key across executors. +All processes using one executor therefore share its API key and LI.FI reputation; active/active operation +also requires external order coordination. The API key, executor owner key, and caller transaction key are +distinct credentials. + +Only on-chain escrow orders are supported; gasless Compact, Permit2/3009, Dutch auctions, and future-order +scheduling are out of scope. Dutch (`0x01`) and exclusive Dutch (`0xe1`) orders are ignored at WebSocket +admission and logged as unsupported. `solverMode: external` serves direct filler-authorized adapters. +`solverMode: internal` also enables signed private discounts through the shared backend. `tokensToQuote` uses the same `all`, +`permissioned`, and `permissionless` scopes as RFQ; permissioned inputs must execute through one physical +route. The order-server REST/WS endpoints are explicit required config, and each Chainlink gas feed has +its own required max age. The default strategy evaluates bounded geometric exact-input ranges across +available capacity; `rangeCount` sets their target number. See the +plan for settlement, pricing, concurrency, and onboarding details: +[`docs/LIFI-PLAN.md`](docs/LIFI-PLAN.md) · example +[`config/lifi.example.yaml`](config/lifi.example.yaml). + +The opened-order settler must report `governanceFee() == 0`. The solver checks this at startup and again for +every admitted order. Startup fails closed; at runtime an unreadable or non-zero fee skips the order with an +error log before planning or submission. + +The implementation is ready for the opened-order path. The next live E2E requires deploying the current +executor build, registering it with LI.FI, and granting it filler authorization on the target adapter. + +### UniswapX Quoter + Filler — `uniswapx-filler` + +An Ethereum-mainnet UniswapX solver backed by LiquidLane routes. It serves the RFQ `POST /quote` +webhook, polls the Uniswap order API for exclusive and public V2 orders, resolves +their Dutch amounts from current chain time, and fills profitable orders through a configured +`LiquidLaneUniswapXExecutor`. The executor uses the same owner-managed caller list as the RFQ executor and +remains the Reactor-facing filler. Before serving traffic, the solver validates executor bytecode, finds the +tx-sending EOA in the executor's indexed `callers` list, and, in external mode, checks every configured +route's direct authorization. Failures log the relevant executor, caller, or adapters and the underlying +reason before startup returns. The executor ABI has no Reactor getter, so matching the configured Reactor to +the deployed immutable remains a deployment assertion. `solverMode: external` is the default, requires a +non-empty `adapters` list plus direct authorization, and forbids the discounts block. `solverMode: internal` +requires that block; direct routes are authorization-filtered from each snapshot while valid signed-discount +routes remain usable. In internal +mode `adapters` is optional: a non-empty list scopes quotes and direct fills, while fill-time signed-discount +recovery may use any adapter advertised by the backend. Without a list the solver quotes and fills +discount-only. Every fill is simulated again immediately before submission. + +The quote path is stateless and uses a refreshed on-chain inventory snapshot so it stays within Uniswap's +response deadline. Each request is priced once for its concrete amount: the strategy returns one +`amountIn`/`amountOut` pair after price buffer and, when configured, estimated fill gas, with no precomputed +ladders, amount ranges, or quote-time route reservation. Omitting the entire `gas:` block disables gas +accounting in both quote and fill decisions and skips gas-state and Chainlink reads. The tx manager still +prices and pays actual transaction gas, so that cost is then subsidized by the solver. Uniswap deliberately +makes indicative and hard RFQ requests +indistinguishable, so the solver echoes `quoteId` but does not guess the phase. Capacity is reserved only +after a fill transaction is accepted for submission; every posted order gets a fresh route plan from the +current chain state and is simulated before sending. The reservation remains effective while txmanager waits +for the configured confirmations. On completion the quote snapshot is invalidated before capacity is +released, and that capacity is not advertised again until a fresh post-fill chain snapshot is published. +A quote is returned only if its snapshot epoch and every blocking condition are unchanged after the strategy +finishes. Quoting fails closed during startup warmup, stale or unknown exclusive-order delivery, fill +planning, an active Uniswap `blockUntilTimestamp`, or the configured local fade breaker. `GET /ready` +exposes that state and also returns not-ready when the latest snapshot has no quotable inventory; +`GET /health` and its probe-friendly alias `GET /healthz` remain liveness-only. + +Every valid exclusive order assigned to the executor is tracked through `decayStartTime`. After that +deadline, tracked hashes are reconciled in batches against the order API and canonical transaction receipts. +A successful on-chain fill at or before the deadline clears the obligation, including another filler's soft +override. A fill by any filler only after the deadline—including our executor—or any known non-filled +terminal state opens the separate local fade breaker, matching Uniswap's +[fade definition](https://developers.uniswap.org/docs/liquidity/uniswapx/filling/faq#fade-mechanics). +If terminal status or receipt time cannot be established, quoting stops without opening the breaker until +reconciliation succeeds. + +In internal mode, advertised LiquidLane routes are resolved on-chain and checked against their advertised +asset and decimals, current physical capacity/rate, adapter minimum discount, token policy, and configured +gas feeds. Configured adapters scope quoting when present; fill-time discount recovery remains unrestricted, +matching RFQ solver-mode semantics. A selected discount is resolved again immediately before simulation and +encoded as a typed `discountSwap`; its adapter, token, output floor, signatures, and expiry window are +checked fail-closed. + +The order API key is required and read indirectly through `orderServer.apiKeyEnv`. Uniswap's public quote +contract specifies source-IP allowlisting rather than an application header, so restrict the quote endpoint +to the published Beta/production source IPs at the ingress. The order API URL must use HTTPS except for +loopback development servers. Each V2 order carries its swapper-authorized cosigner; the solver verifies its +cosignature directly, so there is no static cosigner setting to rotate. Exclusive V2 polling is mandatory +while the quote server is enabled; public V2 filling remains independently opt-in. Legacy V1 limit orders +are not supported. The generated order client follows upstream order-service spec version 2.0.0 and decodes +the current `DutchV2OrderEntity`, including nested `cosignerData`, `cosignature`, and `createdAt`. +Native-asset outputs are currently declined because the supported LiquidLane routes settle ERC-20 vault +assets. +Exact-input and exact-output Dutch auctions are supported. Exact-output quotes directly size enough input +for the requested output, buffer, and gas; rounding or execution output above that requirement remains +executor surplus. If a Dutch exact-output input grows between planning and execution, the executor consumes +the planned route input and retains the positive input difference as filler surplus. The Reactor atomically +enforces the order's aggregate outputs. Multiple outputs are supported when every output uses the same +ERC-20; mixed-token outputs fail closed because one +LiquidLane route produces one vault asset. Quote webhook protocols `v1` and `v2` are accepted, while V3 +orders and secondary-DEX routes are not supported. Design, +config, onboarding, and deployment prerequisites: +[`docs/UNISWAPX-PLAN.md`](docs/UNISWAPX-PLAN.md) · example +[`config/uniswapx.example.yaml`](config/uniswapx.example.yaml). + ### Strategies The solvers split protocol plumbing (reads, signing, submission — fixed) from the @@ -96,11 +234,19 @@ The solvers split protocol plumbing (reads, signing, submission — fixed) from - **`default`** — the built-in in-process strategy for that solver. - **`webhook`** — delegates each decision to an **external HTTP service you run**: the solver sends it - the raw facts as JSON and executes the plan it returns, so your service owns the logic. + the raw facts as JSON and executes the validated plan it returns, so your service owns the logic. + LI.FI and UniswapX own separate strategy contracts and independently reject returned fills that exceed + current capacity or do not cover the order plus gas. UniswapX delegates each concrete quote to + `POST /decide-quote` and each current fill plan to `POST /decide-fill` under the configured webhook URL. This is the seam for customizing a solver without forking. Contract and trust model: [`docs/strategy-plan.md`](docs/strategy-plan.md). +The shared `txManager` fee-bumps pending transactions on `replacementIntervalMs`. After +`pendingTimeoutMs`, it cancels only the lowest unresolved nonce before allowing later queued nonces +to proceed. The required `maxFeeGwei` is the absolute ceiling; normal sends reserve one fee bump +inside that ceiling so cancellation still has headroom. + ## Requirements - Go (toolchain version pinned in [`go.mod`](./go.mod); auto-fetched by recent Go releases). @@ -114,6 +260,7 @@ This is the seam for customizing a solver without forking. Contract and trust mo make build # build ./bin/vault-solver ./bin/vault-solver version make test # go test -race -cover ./... +make test-txmanager-anvil # real pending replacement/cancellation against local Anvil make lint # golangci-lint ./bin/vault-solver run --config config/3f.example.yaml ``` @@ -133,7 +280,8 @@ implementation and hands the opaque `solver.config` block to that solver to type own fully annotated example under `config/` (see the *Example config* column above) — every field, including the shared `chain`/`signer`/`txManager`/`observability` blocks, is documented inline there. The `chain` block takes a primary `rpcUrl` plus optional `rpcFallbackUrls` — HTTP(S) endpoints tried -in order when the primary is unavailable. **Never commit a real key or live config** — keys are +in order when the primary is unavailable. LiquidLane state reads always use RPC `latest`; an archive +node is not required. **Never commit a real key or live config** — keys are supplied via env/file behind the `Signer` interface; `*.local.*` and `.env` are gitignored. ## Code generation diff --git a/api/abi/FrontendLiquidityLens.json b/api/abi/FrontendLiquidityLens.json new file mode 100644 index 00000000..371e4978 --- /dev/null +++ b/api/abi/FrontendLiquidityLens.json @@ -0,0 +1,25 @@ +[ + { + "type": "function", + "name": "getMaxAssets", + "inputs": [ + { "internalType": "address", "name": "adapter", "type": "address" } + ], + "outputs": [ + { "internalType": "uint256", "name": "", "type": "uint256" } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "getMaxAssets", + "inputs": [ + { "internalType": "address", "name": "adapter", "type": "address" }, + { "internalType": "address", "name": "tokenToRedeem", "type": "address" } + ], + "outputs": [ + { "internalType": "uint256", "name": "", "type": "uint256" } + ], + "stateMutability": "nonpayable" + } +] diff --git a/api/abi/IAdapterFactory.json b/api/abi/IAdapterFactory.json new file mode 100644 index 00000000..2509fc2a --- /dev/null +++ b/api/abi/IAdapterFactory.json @@ -0,0 +1,275 @@ +[ + { + "type": "function", + "name": "blacklist", + "inputs": [ + { + "name": "version", + "type": "uint64", + "internalType": "uint64" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "blacklisted", + "inputs": [ + { + "name": "version", + "type": "uint64", + "internalType": "uint64" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "create", + "inputs": [ + { + "name": "version", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "entity", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "implementation", + "inputs": [ + { + "name": "version", + "type": "uint64", + "internalType": "uint64" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isEntity", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lastVersion", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "migrate", + "inputs": [ + { + "name": "entity", + "type": "address", + "internalType": "address" + }, + { + "name": "newVersion", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "totalEntities", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "whitelist", + "inputs": [ + { + "name": "implementation", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "AddEntity", + "inputs": [ + { + "name": "entity", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Blacklist", + "inputs": [ + { + "name": "version", + "type": "uint64", + "indexed": true, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Migrate", + "inputs": [ + { + "name": "entity", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newVersion", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Whitelist", + "inputs": [ + { + "name": "implementation", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AlreadyBlacklisted", + "inputs": [] + }, + { + "type": "error", + "name": "AlreadyWhitelisted", + "inputs": [] + }, + { + "type": "error", + "name": "EntityNotExist", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidImplementation", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidVersion", + "inputs": [] + }, + { + "type": "error", + "name": "NotOwner", + "inputs": [] + }, + { + "type": "error", + "name": "OldVersion", + "inputs": [] + } +] diff --git a/api/abi/ILifiInputSettler.json b/api/abi/ILifiInputSettler.json new file mode 100644 index 00000000..0811c354 --- /dev/null +++ b/api/abi/ILifiInputSettler.json @@ -0,0 +1,1658 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "initialOwner", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "AlreadyInitialized", + "type": "error" + }, + { + "inputs": [], + "name": "AlreadyPurchased", + "type": "error" + }, + { + "inputs": [], + "name": "CallOutOfRange", + "type": "error" + }, + { + "inputs": [], + "name": "CodeSize0", + "type": "error" + }, + { + "inputs": [], + "name": "ContextOutOfRange", + "type": "error" + }, + { + "inputs": [], + "name": "Expired", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint32", + "name": "fillDeadline", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "expires", + "type": "uint32" + } + ], + "name": "FillDeadlineAfterExpiry", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint32", + "name": "expected", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "actual", + "type": "uint32" + } + ], + "name": "FilledTooLate", + "type": "error" + }, + { + "inputs": [], + "name": "GovernanceFeeChangeNotReady", + "type": "error" + }, + { + "inputs": [], + "name": "GovernanceFeeTooHigh", + "type": "error" + }, + { + "inputs": [], + "name": "HasDirtyBits", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidOrderStatus", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidPurchaser", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidShortString", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidSigner", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidTimestampLength", + "type": "error" + }, + { + "inputs": [], + "name": "NewOwnerIsZeroAddress", + "type": "error" + }, + { + "inputs": [], + "name": "NoDestination", + "type": "error" + }, + { + "inputs": [], + "name": "NoHandoverRequest", + "type": "error" + }, + { + "inputs": [], + "name": "NotOrderOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "provided", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "computed", + "type": "bytes32" + } + ], + "name": "OrderIdMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "ReentrancyDetected", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "SafeERC20FailedOperation", + "type": "error" + }, + { + "inputs": [], + "name": "SignatureAndInputsNotEqual", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes1", + "name": "", + "type": "bytes1" + } + ], + "name": "SignatureNotSupported", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "str", + "type": "string" + } + ], + "name": "StringTooLong", + "type": "error" + }, + { + "inputs": [], + "name": "TimestampNotPassed", + "type": "error" + }, + { + "inputs": [], + "name": "TimestampPassed", + "type": "error" + }, + { + "inputs": [], + "name": "Unauthorized", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "expected", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "actual", + "type": "uint256" + } + ], + "name": "WrongChain", + "type": "error" + }, + { + "anonymous": false, + "inputs": [], + "name": "EIP712DomainChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "solver", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "destination", + "type": "bytes32" + } + ], + "name": "Finalised", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "oldGovernanceFee", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "newGovernanceFee", + "type": "uint64" + } + ], + "name": "GovernanceFeeChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "nextGovernanceFee", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "nextGovernanceFeeTime", + "type": "uint64" + } + ], + "name": "NextGovernanceFee", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + } + ], + "name": "Open", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "components": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "originChainId", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "expires", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "fillDeadline", + "type": "uint32" + }, + { + "internalType": "address", + "name": "inputOracle", + "type": "address" + }, + { + "internalType": "uint256[2][]", + "name": "inputs", + "type": "uint256[2][]" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "oracle", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "settler", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "token", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "recipient", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "callbackData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "context", + "type": "bytes" + } + ], + "internalType": "struct MandateOutput[]", + "name": "outputs", + "type": "tuple[]" + } + ], + "indexed": false, + "internalType": "struct StandardOrder", + "name": "order", + "type": "tuple" + } + ], + "name": "Open", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "solver", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "purchaser", + "type": "bytes32" + } + ], + "name": "OrderPurchased", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "pendingOwner", + "type": "address" + } + ], + "name": "OwnershipHandoverCanceled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "pendingOwner", + "type": "address" + } + ], + "name": "OwnershipHandoverRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "oldOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + } + ], + "name": "Refunded", + "type": "event" + }, + { + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "applyGovernanceFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "cancelOwnershipHandover", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "pendingOwner", + "type": "address" + } + ], + "name": "completeOwnershipHandover", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "eip712Domain", + "outputs": [ + { + "internalType": "bytes1", + "name": "fields", + "type": "bytes1" + }, + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "string", + "name": "version", + "type": "string" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "verifyingContract", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "salt", + "type": "bytes32" + }, + { + "internalType": "uint256[]", + "name": "extensions", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "originChainId", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "expires", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "fillDeadline", + "type": "uint32" + }, + { + "internalType": "address", + "name": "inputOracle", + "type": "address" + }, + { + "internalType": "uint256[2][]", + "name": "inputs", + "type": "uint256[2][]" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "oracle", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "settler", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "token", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "recipient", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "callbackData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "context", + "type": "bytes" + } + ], + "internalType": "struct MandateOutput[]", + "name": "outputs", + "type": "tuple[]" + } + ], + "internalType": "struct StandardOrder", + "name": "order", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint32", + "name": "timestamp", + "type": "uint32" + }, + { + "internalType": "bytes32", + "name": "solver", + "type": "bytes32" + } + ], + "internalType": "struct InputSettlerBase.SolveParams[]", + "name": "solveParams", + "type": "tuple[]" + }, + { + "internalType": "bytes32", + "name": "destination", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "call", + "type": "bytes" + } + ], + "name": "finalise", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "originChainId", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "expires", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "fillDeadline", + "type": "uint32" + }, + { + "internalType": "address", + "name": "inputOracle", + "type": "address" + }, + { + "internalType": "uint256[2][]", + "name": "inputs", + "type": "uint256[2][]" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "oracle", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "settler", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "token", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "recipient", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "callbackData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "context", + "type": "bytes" + } + ], + "internalType": "struct MandateOutput[]", + "name": "outputs", + "type": "tuple[]" + } + ], + "internalType": "struct StandardOrder", + "name": "order", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint32", + "name": "timestamp", + "type": "uint32" + }, + { + "internalType": "bytes32", + "name": "solver", + "type": "bytes32" + } + ], + "internalType": "struct InputSettlerBase.SolveParams[]", + "name": "solveParams", + "type": "tuple[]" + }, + { + "internalType": "bytes32", + "name": "destination", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "call", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "orderOwnerSignature", + "type": "bytes" + } + ], + "name": "finaliseWithSignature", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "governanceFee", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "nextGovernanceFee", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "nextGovernanceFeeTime", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "originChainId", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "expires", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "fillDeadline", + "type": "uint32" + }, + { + "internalType": "address", + "name": "inputOracle", + "type": "address" + }, + { + "internalType": "uint256[2][]", + "name": "inputs", + "type": "uint256[2][]" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "oracle", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "settler", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "token", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "recipient", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "callbackData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "context", + "type": "bytes" + } + ], + "internalType": "struct MandateOutput[]", + "name": "outputs", + "type": "tuple[]" + } + ], + "internalType": "struct StandardOrder", + "name": "order", + "type": "tuple" + } + ], + "name": "open", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "originChainId", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "expires", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "fillDeadline", + "type": "uint32" + }, + { + "internalType": "address", + "name": "inputOracle", + "type": "address" + }, + { + "internalType": "uint256[2][]", + "name": "inputs", + "type": "uint256[2][]" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "oracle", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "settler", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "token", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "recipient", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "callbackData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "context", + "type": "bytes" + } + ], + "internalType": "struct MandateOutput[]", + "name": "outputs", + "type": "tuple[]" + } + ], + "internalType": "struct StandardOrder", + "name": "order", + "type": "tuple" + }, + { + "internalType": "address", + "name": "sponsor", + "type": "address" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + } + ], + "name": "openFor", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "originChainId", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "expires", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "fillDeadline", + "type": "uint32" + }, + { + "internalType": "address", + "name": "inputOracle", + "type": "address" + }, + { + "internalType": "uint256[2][]", + "name": "inputs", + "type": "uint256[2][]" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "oracle", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "settler", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "token", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "recipient", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "callbackData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "context", + "type": "bytes" + } + ], + "internalType": "struct MandateOutput[]", + "name": "outputs", + "type": "tuple[]" + } + ], + "internalType": "struct StandardOrder", + "name": "order", + "type": "tuple" + }, + { + "internalType": "address", + "name": "sponsor", + "type": "address" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + }, + { + "internalType": "address", + "name": "destination", + "type": "address" + }, + { + "internalType": "bytes", + "name": "call", + "type": "bytes" + } + ], + "name": "openForAndFinalise", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "originChainId", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "expires", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "fillDeadline", + "type": "uint32" + }, + { + "internalType": "address", + "name": "inputOracle", + "type": "address" + }, + { + "internalType": "uint256[2][]", + "name": "inputs", + "type": "uint256[2][]" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "oracle", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "settler", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "token", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "recipient", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "callbackData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "context", + "type": "bytes" + } + ], + "internalType": "struct MandateOutput[]", + "name": "outputs", + "type": "tuple[]" + } + ], + "internalType": "struct StandardOrder", + "name": "order", + "type": "tuple" + } + ], + "name": "orderIdentifier", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + } + ], + "name": "orderStatus", + "outputs": [ + { + "internalType": "enum InputSettlerEscrow.OrderStatus", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "result", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "pendingOwner", + "type": "address" + } + ], + "name": "ownershipHandoverExpiresAt", + "outputs": [ + { + "internalType": "uint256", + "name": "result", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "destination", + "type": "address" + }, + { + "internalType": "bytes", + "name": "callData", + "type": "bytes" + }, + { + "internalType": "uint64", + "name": "discount", + "type": "uint64" + }, + { + "internalType": "uint32", + "name": "timeToBuy", + "type": "uint32" + } + ], + "internalType": "struct OrderPurchase", + "name": "orderPurchase", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "originChainId", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "expires", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "fillDeadline", + "type": "uint32" + }, + { + "internalType": "address", + "name": "inputOracle", + "type": "address" + }, + { + "internalType": "uint256[2][]", + "name": "inputs", + "type": "uint256[2][]" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "oracle", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "settler", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "token", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "recipient", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "callbackData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "context", + "type": "bytes" + } + ], + "internalType": "struct MandateOutput[]", + "name": "outputs", + "type": "tuple[]" + } + ], + "internalType": "struct StandardOrder", + "name": "order", + "type": "tuple" + }, + { + "internalType": "bytes32", + "name": "orderSolvedByIdentifier", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "purchaser", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "expiryTimestamp", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "solverSignature", + "type": "bytes" + } + ], + "name": "purchaseOrder", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "solver", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "orderId", + "type": "bytes32" + } + ], + "name": "purchasedOrders", + "outputs": [ + { + "internalType": "uint32", + "name": "lastOrderTimestamp", + "type": "uint32" + }, + { + "internalType": "bytes32", + "name": "purchaser", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "originChainId", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "expires", + "type": "uint32" + }, + { + "internalType": "uint32", + "name": "fillDeadline", + "type": "uint32" + }, + { + "internalType": "address", + "name": "inputOracle", + "type": "address" + }, + { + "internalType": "uint256[2][]", + "name": "inputs", + "type": "uint256[2][]" + }, + { + "components": [ + { + "internalType": "bytes32", + "name": "oracle", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "settler", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "token", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "recipient", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "callbackData", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "context", + "type": "bytes" + } + ], + "internalType": "struct MandateOutput[]", + "name": "outputs", + "type": "tuple[]" + } + ], + "internalType": "struct StandardOrder", + "name": "order", + "type": "tuple" + } + ], + "name": "refund", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "requestOwnershipHandover", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint64", + "name": "_nextGovernanceFee", + "type": "uint64" + } + ], + "name": "setGovernanceFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "payable", + "type": "function" + } +] diff --git a/api/abi/LiquidLaneLifiExecutor.json b/api/abi/LiquidLaneLifiExecutor.json new file mode 100644 index 00000000..7d0d0a25 --- /dev/null +++ b/api/abi/LiquidLaneLifiExecutor.json @@ -0,0 +1,557 @@ +[ + { + "type": "constructor", + "inputs": [ + { + "name": "inputSettler", + "type": "address", + "internalType": "address" + }, + { + "name": "outputSettler", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "INPUT_SETTLER", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "LIFI_REGISTRATION_TYPEHASH", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "OUTPUT_SETTLER", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "callers", + "inputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "eip712Domain", + "inputs": [], + "outputs": [ + { + "name": "fields", + "type": "bytes1", + "internalType": "bytes1" + }, + { + "name": "name", + "type": "string", + "internalType": "string" + }, + { + "name": "version", + "type": "string", + "internalType": "string" + }, + { + "name": "chainId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "verifyingContract", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "extensions", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "finaliseWithCurrentTimestamp", + "inputs": [ + { + "name": "order", + "type": "tuple", + "internalType": "struct IInputSettler.StandardOrder", + "components": [ + { + "name": "user", + "type": "address", + "internalType": "address" + }, + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "originChainId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "expires", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "fillDeadline", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "inputOracle", + "type": "address", + "internalType": "address" + }, + { + "name": "inputs", + "type": "uint256[2][]", + "internalType": "uint256[2][]" + }, + { + "name": "outputs", + "type": "tuple[]", + "internalType": "struct MandateOutput[]", + "components": [ + { + "name": "oracle", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "settler", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "chainId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "token", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "callbackData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "context", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + }, + { + "name": "routes", + "type": "tuple[]", + "internalType": "struct ILiquidLaneLifiExecutor.FillRoute[]", + "components": [ + { + "name": "adapter", + "type": "address", + "internalType": "address" + }, + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "amountOut", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "discount", + "type": "tuple", + "internalType": "struct ILiquidLaneLifiExecutor.FillDiscount", + "components": [ + { + "name": "discountId", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "discountSwap", + "type": "tuple", + "internalType": "struct ILiquidLaneAdapter.DiscountSwap", + "components": [ + { + "name": "discount", + "type": "tuple", + "internalType": "struct ILiquidLaneAdapter.Discount", + "components": [ + { + "name": "tokenToRedeem", + "type": "address", + "internalType": "address" + }, + { + "name": "discount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "signer", + "type": "address", + "internalType": "address" + }, + { + "name": "protocol", + "type": "address", + "internalType": "address" + }, + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "name": "signerSignature", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "protocolDeadline", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "name": "protocolSignature", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "owner_", + "type": "address", + "internalType": "address" + }, + { + "name": "initCallers", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isCaller", + "inputs": [ + { + "name": "caller", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "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": "lifiRegistrationDigest", + "inputs": [ + { + "name": "messageHash", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "orderFinalised", + "inputs": [ + { + "name": "inputs", + "type": "uint256[2][]", + "internalType": "uint256[2][]" + }, + { + "name": "executionData", + "type": "bytes", + "internalType": "bytes" + } + ], + "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": "setCallers", + "inputs": [ + { + "name": "newCallers", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "EIP712DomainChanged", + "inputs": [], + "anonymous": false + }, + { + "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": "SetCallers", + "inputs": [ + { + "name": "newCallers", + "type": "address[]", + "indexed": false, + "internalType": "address[]" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "NotCaller", + "inputs": [] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "NotInputSettler", + "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": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + } +] diff --git a/api/abi/LiquidLaneUniswapXExecutor.json b/api/abi/LiquidLaneUniswapXExecutor.json new file mode 100644 index 00000000..06384367 --- /dev/null +++ b/api/abi/LiquidLaneUniswapXExecutor.json @@ -0,0 +1,460 @@ +[ + { + "type": "constructor", + "inputs": [ + { + "name": "reactor", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "receive", + "stateMutability": "payable" + }, + { + "type": "function", + "name": "callers", + "inputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "execute", + "inputs": [ + { + "name": "order", + "type": "tuple", + "internalType": "struct UniswapXSignedOrder", + "components": [ + { + "name": "order", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "sig", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "fillCall", + "type": "tuple", + "internalType": "struct ILiquidLaneUniswapXExecutor.FillCall", + "components": [ + { + "name": "routes", + "type": "tuple[]", + "internalType": "struct ILiquidLaneUniswapXExecutor.FillRoute[]", + "components": [ + { + "name": "adapter", + "type": "address", + "internalType": "address" + }, + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "amountOut", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "discountRoutes", + "type": "tuple[]", + "internalType": "struct ILiquidLaneUniswapXExecutor.DiscountRoute[]", + "components": [ + { + "name": "adapter", + "type": "address", + "internalType": "address" + }, + { + "name": "amountIn", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "discountSwap", + "type": "tuple", + "internalType": "struct ILiquidLaneAdapter.DiscountSwap", + "components": [ + { + "name": "discount", + "type": "tuple", + "internalType": "struct ILiquidLaneAdapter.Discount", + "components": [ + { + "name": "tokenToRedeem", + "type": "address", + "internalType": "address" + }, + { + "name": "discount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "signer", + "type": "address", + "internalType": "address" + }, + { + "name": "protocol", + "type": "address", + "internalType": "address" + }, + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "name": "signerSignature", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "protocolDeadline", + "type": "uint48", + "internalType": "uint48" + } + ] + }, + { + "name": "protocolSignature", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "initCallers", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "reactorCallback", + "inputs": [ + { + "name": "resolvedOrders", + "type": "tuple[]", + "internalType": "struct UniswapXResolvedOrder[]", + "components": [ + { + "name": "info", + "type": "tuple", + "internalType": "struct UniswapXOrderInfo", + "components": [ + { + "name": "reactor", + "type": "address", + "internalType": "address" + }, + { + "name": "swapper", + "type": "address", + "internalType": "address" + }, + { + "name": "nonce", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "additionalValidationContract", + "type": "address", + "internalType": "address" + }, + { + "name": "additionalValidationData", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "name": "input", + "type": "tuple", + "internalType": "struct UniswapXInputToken", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "maxAmount", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "outputs", + "type": "tuple[]", + "internalType": "struct UniswapXOutputToken[]", + "components": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "recipient", + "type": "address", + "internalType": "address" + } + ] + }, + { + "name": "sig", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "hash", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "name": "callbackData", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setCallers", + "inputs": [ + { + "name": "newCallers", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "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": "SetCallers", + "inputs": [ + { + "name": "newCallers", + "type": "address[]", + "indexed": false, + "internalType": "address[]" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "FailedCall", + "inputs": [] + }, + { + "type": "error", + "name": "InsufficientBalance", + "inputs": [ + { + "name": "balance", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "NotCaller", + "inputs": [] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "NotReactor", + "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": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + } +] diff --git a/api/bindings/adapterfactory/IAdapterFactory.go b/api/bindings/adapterfactory/IAdapterFactory.go new file mode 100644 index 00000000..bfdfd26e --- /dev/null +++ b/api/bindings/adapterfactory/IAdapterFactory.go @@ -0,0 +1,715 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package adapterfactory + +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 +) + +// IAdapterFactoryMetaData contains all meta data concerning the IAdapterFactory contract. +var IAdapterFactoryMetaData = bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"blacklist\",\"inputs\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"blacklisted\",\"inputs\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"create\",\"inputs\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"owner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"entity\",\"inputs\":[{\"name\":\"index\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"implementation\",\"inputs\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isEntity\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"lastVersion\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"migrate\",\"inputs\":[{\"name\":\"entity\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"newVersion\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"totalEntities\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"whitelist\",\"inputs\":[{\"name\":\"implementation\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"AddEntity\",\"inputs\":[{\"name\":\"entity\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Blacklist\",\"inputs\":[{\"name\":\"version\",\"type\":\"uint64\",\"indexed\":true,\"internalType\":\"uint64\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Migrate\",\"inputs\":[{\"name\":\"entity\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"newVersion\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Whitelist\",\"inputs\":[{\"name\":\"implementation\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AlreadyBlacklisted\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AlreadyWhitelisted\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"EntityNotExist\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidImplementation\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidVersion\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotOwner\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"OldVersion\",\"inputs\":[]}]", + ID: "IAdapterFactory", +} + +// IAdapterFactory is an auto generated Go binding around an Ethereum contract. +type IAdapterFactory struct { + abi abi.ABI +} + +// NewIAdapterFactory creates a new instance of IAdapterFactory. +func NewIAdapterFactory() *IAdapterFactory { + parsed, err := IAdapterFactoryMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &IAdapterFactory{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 *IAdapterFactory) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackBlacklist is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xb572a966. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function blacklist(uint64 version) returns() +func (iAdapterFactory *IAdapterFactory) PackBlacklist(version uint64) []byte { + enc, err := iAdapterFactory.abi.Pack("blacklist", version) + if err != nil { + panic(err) + } + return enc +} + +// TryPackBlacklist is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xb572a966. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function blacklist(uint64 version) returns() +func (iAdapterFactory *IAdapterFactory) TryPackBlacklist(version uint64) ([]byte, error) { + return iAdapterFactory.abi.Pack("blacklist", version) +} + +// PackBlacklisted is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xb6caa119. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function blacklisted(uint64 version) view returns(bool) +func (iAdapterFactory *IAdapterFactory) PackBlacklisted(version uint64) []byte { + enc, err := iAdapterFactory.abi.Pack("blacklisted", version) + if err != nil { + panic(err) + } + return enc +} + +// TryPackBlacklisted is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xb6caa119. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function blacklisted(uint64 version) view returns(bool) +func (iAdapterFactory *IAdapterFactory) TryPackBlacklisted(version uint64) ([]byte, error) { + return iAdapterFactory.abi.Pack("blacklisted", version) +} + +// UnpackBlacklisted is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xb6caa119. +// +// Solidity: function blacklisted(uint64 version) view returns(bool) +func (iAdapterFactory *IAdapterFactory) UnpackBlacklisted(data []byte) (bool, error) { + out, err := iAdapterFactory.abi.Unpack("blacklisted", data) + if err != nil { + return *new(bool), err + } + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + return out0, nil +} + +// PackCreate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x3ac04911. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function create(uint64 version, address owner, bytes data) returns(address) +func (iAdapterFactory *IAdapterFactory) PackCreate(version uint64, owner common.Address, data []byte) []byte { + enc, err := iAdapterFactory.abi.Pack("create", version, owner, data) + if err != nil { + panic(err) + } + return enc +} + +// TryPackCreate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x3ac04911. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function create(uint64 version, address owner, bytes data) returns(address) +func (iAdapterFactory *IAdapterFactory) TryPackCreate(version uint64, owner common.Address, data []byte) ([]byte, error) { + return iAdapterFactory.abi.Pack("create", version, owner, data) +} + +// UnpackCreate is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x3ac04911. +// +// Solidity: function create(uint64 version, address owner, bytes data) returns(address) +func (iAdapterFactory *IAdapterFactory) UnpackCreate(data []byte) (common.Address, error) { + out, err := iAdapterFactory.abi.Unpack("create", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackEntity is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xb42ba2a2. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function entity(uint256 index) view returns(address) +func (iAdapterFactory *IAdapterFactory) PackEntity(index *big.Int) []byte { + enc, err := iAdapterFactory.abi.Pack("entity", index) + if err != nil { + panic(err) + } + return enc +} + +// TryPackEntity is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xb42ba2a2. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function entity(uint256 index) view returns(address) +func (iAdapterFactory *IAdapterFactory) TryPackEntity(index *big.Int) ([]byte, error) { + return iAdapterFactory.abi.Pack("entity", index) +} + +// UnpackEntity is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xb42ba2a2. +// +// Solidity: function entity(uint256 index) view returns(address) +func (iAdapterFactory *IAdapterFactory) UnpackEntity(data []byte) (common.Address, error) { + out, err := iAdapterFactory.abi.Unpack("entity", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackImplementation is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf9661602. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function implementation(uint64 version) view returns(address) +func (iAdapterFactory *IAdapterFactory) PackImplementation(version uint64) []byte { + enc, err := iAdapterFactory.abi.Pack("implementation", version) + if err != nil { + panic(err) + } + return enc +} + +// TryPackImplementation is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf9661602. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function implementation(uint64 version) view returns(address) +func (iAdapterFactory *IAdapterFactory) TryPackImplementation(version uint64) ([]byte, error) { + return iAdapterFactory.abi.Pack("implementation", version) +} + +// UnpackImplementation is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xf9661602. +// +// Solidity: function implementation(uint64 version) view returns(address) +func (iAdapterFactory *IAdapterFactory) UnpackImplementation(data []byte) (common.Address, error) { + out, err := iAdapterFactory.abi.Unpack("implementation", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackIsEntity is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x14887c58. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function isEntity(address account) view returns(bool) +func (iAdapterFactory *IAdapterFactory) PackIsEntity(account common.Address) []byte { + enc, err := iAdapterFactory.abi.Pack("isEntity", account) + if err != nil { + panic(err) + } + return enc +} + +// TryPackIsEntity is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x14887c58. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function isEntity(address account) view returns(bool) +func (iAdapterFactory *IAdapterFactory) TryPackIsEntity(account common.Address) ([]byte, error) { + return iAdapterFactory.abi.Pack("isEntity", account) +} + +// UnpackIsEntity is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x14887c58. +// +// Solidity: function isEntity(address account) view returns(bool) +func (iAdapterFactory *IAdapterFactory) UnpackIsEntity(data []byte) (bool, error) { + out, err := iAdapterFactory.abi.Unpack("isEntity", data) + if err != nil { + return *new(bool), err + } + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + return out0, nil +} + +// PackLastVersion is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x64dfea06. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function lastVersion() view returns(uint64) +func (iAdapterFactory *IAdapterFactory) PackLastVersion() []byte { + enc, err := iAdapterFactory.abi.Pack("lastVersion") + if err != nil { + panic(err) + } + return enc +} + +// TryPackLastVersion is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x64dfea06. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function lastVersion() view returns(uint64) +func (iAdapterFactory *IAdapterFactory) TryPackLastVersion() ([]byte, error) { + return iAdapterFactory.abi.Pack("lastVersion") +} + +// UnpackLastVersion is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x64dfea06. +// +// Solidity: function lastVersion() view returns(uint64) +func (iAdapterFactory *IAdapterFactory) UnpackLastVersion(data []byte) (uint64, error) { + out, err := iAdapterFactory.abi.Unpack("lastVersion", data) + if err != nil { + return *new(uint64), err + } + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + return out0, nil +} + +// PackMigrate is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x58336662. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function migrate(address entity, uint64 newVersion, bytes data) returns() +func (iAdapterFactory *IAdapterFactory) PackMigrate(entity common.Address, newVersion uint64, data []byte) []byte { + enc, err := iAdapterFactory.abi.Pack("migrate", entity, 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 0x58336662. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function migrate(address entity, uint64 newVersion, bytes data) returns() +func (iAdapterFactory *IAdapterFactory) TryPackMigrate(entity common.Address, newVersion uint64, data []byte) ([]byte, error) { + return iAdapterFactory.abi.Pack("migrate", entity, newVersion, data) +} + +// PackTotalEntities is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x5cd8b15e. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function totalEntities() view returns(uint256) +func (iAdapterFactory *IAdapterFactory) PackTotalEntities() []byte { + enc, err := iAdapterFactory.abi.Pack("totalEntities") + if err != nil { + panic(err) + } + return enc +} + +// TryPackTotalEntities is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x5cd8b15e. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function totalEntities() view returns(uint256) +func (iAdapterFactory *IAdapterFactory) TryPackTotalEntities() ([]byte, error) { + return iAdapterFactory.abi.Pack("totalEntities") +} + +// UnpackTotalEntities is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x5cd8b15e. +// +// Solidity: function totalEntities() view returns(uint256) +func (iAdapterFactory *IAdapterFactory) UnpackTotalEntities(data []byte) (*big.Int, error) { + out, err := iAdapterFactory.abi.Unpack("totalEntities", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackWhitelist is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x9b19251a. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function whitelist(address implementation) returns() +func (iAdapterFactory *IAdapterFactory) PackWhitelist(implementation common.Address) []byte { + enc, err := iAdapterFactory.abi.Pack("whitelist", implementation) + if err != nil { + panic(err) + } + return enc +} + +// TryPackWhitelist is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x9b19251a. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function whitelist(address implementation) returns() +func (iAdapterFactory *IAdapterFactory) TryPackWhitelist(implementation common.Address) ([]byte, error) { + return iAdapterFactory.abi.Pack("whitelist", implementation) +} + +// IAdapterFactoryAddEntity represents a AddEntity event raised by the IAdapterFactory contract. +type IAdapterFactoryAddEntity struct { + Entity common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const IAdapterFactoryAddEntityEventName = "AddEntity" + +// ContractEventName returns the user-defined event name. +func (IAdapterFactoryAddEntity) ContractEventName() string { + return IAdapterFactoryAddEntityEventName +} + +// UnpackAddEntityEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event AddEntity(address indexed entity) +func (iAdapterFactory *IAdapterFactory) UnpackAddEntityEvent(log *types.Log) (*IAdapterFactoryAddEntity, error) { + event := "AddEntity" + if log.Topics[0] != iAdapterFactory.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(IAdapterFactoryAddEntity) + if len(log.Data) > 0 { + if err := iAdapterFactory.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iAdapterFactory.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 +} + +// IAdapterFactoryBlacklist represents a Blacklist event raised by the IAdapterFactory contract. +type IAdapterFactoryBlacklist struct { + Version uint64 + Raw *types.Log // Blockchain specific contextual infos +} + +const IAdapterFactoryBlacklistEventName = "Blacklist" + +// ContractEventName returns the user-defined event name. +func (IAdapterFactoryBlacklist) ContractEventName() string { + return IAdapterFactoryBlacklistEventName +} + +// UnpackBlacklistEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event Blacklist(uint64 indexed version) +func (iAdapterFactory *IAdapterFactory) UnpackBlacklistEvent(log *types.Log) (*IAdapterFactoryBlacklist, error) { + event := "Blacklist" + if log.Topics[0] != iAdapterFactory.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(IAdapterFactoryBlacklist) + if len(log.Data) > 0 { + if err := iAdapterFactory.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iAdapterFactory.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 +} + +// IAdapterFactoryMigrate represents a Migrate event raised by the IAdapterFactory contract. +type IAdapterFactoryMigrate struct { + Entity common.Address + NewVersion uint64 + Raw *types.Log // Blockchain specific contextual infos +} + +const IAdapterFactoryMigrateEventName = "Migrate" + +// ContractEventName returns the user-defined event name. +func (IAdapterFactoryMigrate) ContractEventName() string { + return IAdapterFactoryMigrateEventName +} + +// UnpackMigrateEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event Migrate(address indexed entity, uint64 newVersion) +func (iAdapterFactory *IAdapterFactory) UnpackMigrateEvent(log *types.Log) (*IAdapterFactoryMigrate, error) { + event := "Migrate" + if log.Topics[0] != iAdapterFactory.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(IAdapterFactoryMigrate) + if len(log.Data) > 0 { + if err := iAdapterFactory.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iAdapterFactory.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 +} + +// IAdapterFactoryWhitelist represents a Whitelist event raised by the IAdapterFactory contract. +type IAdapterFactoryWhitelist struct { + Implementation common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const IAdapterFactoryWhitelistEventName = "Whitelist" + +// ContractEventName returns the user-defined event name. +func (IAdapterFactoryWhitelist) ContractEventName() string { + return IAdapterFactoryWhitelistEventName +} + +// UnpackWhitelistEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event Whitelist(address indexed implementation) +func (iAdapterFactory *IAdapterFactory) UnpackWhitelistEvent(log *types.Log) (*IAdapterFactoryWhitelist, error) { + event := "Whitelist" + if log.Topics[0] != iAdapterFactory.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(IAdapterFactoryWhitelist) + if len(log.Data) > 0 { + if err := iAdapterFactory.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iAdapterFactory.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 (iAdapterFactory *IAdapterFactory) UnpackError(raw []byte) (any, error) { + if bytes.Equal(raw[:4], iAdapterFactory.abi.Errors["AlreadyBlacklisted"].ID.Bytes()[:4]) { + return iAdapterFactory.UnpackAlreadyBlacklistedError(raw[4:]) + } + if bytes.Equal(raw[:4], iAdapterFactory.abi.Errors["AlreadyWhitelisted"].ID.Bytes()[:4]) { + return iAdapterFactory.UnpackAlreadyWhitelistedError(raw[4:]) + } + if bytes.Equal(raw[:4], iAdapterFactory.abi.Errors["EntityNotExist"].ID.Bytes()[:4]) { + return iAdapterFactory.UnpackEntityNotExistError(raw[4:]) + } + if bytes.Equal(raw[:4], iAdapterFactory.abi.Errors["InvalidImplementation"].ID.Bytes()[:4]) { + return iAdapterFactory.UnpackInvalidImplementationError(raw[4:]) + } + if bytes.Equal(raw[:4], iAdapterFactory.abi.Errors["InvalidVersion"].ID.Bytes()[:4]) { + return iAdapterFactory.UnpackInvalidVersionError(raw[4:]) + } + if bytes.Equal(raw[:4], iAdapterFactory.abi.Errors["NotOwner"].ID.Bytes()[:4]) { + return iAdapterFactory.UnpackNotOwnerError(raw[4:]) + } + if bytes.Equal(raw[:4], iAdapterFactory.abi.Errors["OldVersion"].ID.Bytes()[:4]) { + return iAdapterFactory.UnpackOldVersionError(raw[4:]) + } + return nil, errors.New("Unknown error") +} + +// IAdapterFactoryAlreadyBlacklisted represents a AlreadyBlacklisted error raised by the IAdapterFactory contract. +type IAdapterFactoryAlreadyBlacklisted struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error AlreadyBlacklisted() +func IAdapterFactoryAlreadyBlacklistedErrorID() common.Hash { + return common.HexToHash("0xf53de75f1e31621ad6a944a755bdeb0c9e6ce21f9741928443fc729611349ad0") +} + +// UnpackAlreadyBlacklistedError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error AlreadyBlacklisted() +func (iAdapterFactory *IAdapterFactory) UnpackAlreadyBlacklistedError(raw []byte) (*IAdapterFactoryAlreadyBlacklisted, error) { + out := new(IAdapterFactoryAlreadyBlacklisted) + if err := iAdapterFactory.abi.UnpackIntoInterface(out, "AlreadyBlacklisted", raw); err != nil { + return nil, err + } + return out, nil +} + +// IAdapterFactoryAlreadyWhitelisted represents a AlreadyWhitelisted error raised by the IAdapterFactory contract. +type IAdapterFactoryAlreadyWhitelisted struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error AlreadyWhitelisted() +func IAdapterFactoryAlreadyWhitelistedErrorID() common.Hash { + return common.HexToHash("0xb73e95e172f49f27697561bc619185e51aa120c14e4c6bef832c9e9277e4cdcc") +} + +// UnpackAlreadyWhitelistedError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error AlreadyWhitelisted() +func (iAdapterFactory *IAdapterFactory) UnpackAlreadyWhitelistedError(raw []byte) (*IAdapterFactoryAlreadyWhitelisted, error) { + out := new(IAdapterFactoryAlreadyWhitelisted) + if err := iAdapterFactory.abi.UnpackIntoInterface(out, "AlreadyWhitelisted", raw); err != nil { + return nil, err + } + return out, nil +} + +// IAdapterFactoryEntityNotExist represents a EntityNotExist error raised by the IAdapterFactory contract. +type IAdapterFactoryEntityNotExist struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error EntityNotExist() +func IAdapterFactoryEntityNotExistErrorID() common.Hash { + return common.HexToHash("0xe3fd10ffa8201bd89f8a91d79dc11e821a5e146bffbcccfb4306a569bb46eae2") +} + +// UnpackEntityNotExistError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error EntityNotExist() +func (iAdapterFactory *IAdapterFactory) UnpackEntityNotExistError(raw []byte) (*IAdapterFactoryEntityNotExist, error) { + out := new(IAdapterFactoryEntityNotExist) + if err := iAdapterFactory.abi.UnpackIntoInterface(out, "EntityNotExist", raw); err != nil { + return nil, err + } + return out, nil +} + +// IAdapterFactoryInvalidImplementation represents a InvalidImplementation error raised by the IAdapterFactory contract. +type IAdapterFactoryInvalidImplementation struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InvalidImplementation() +func IAdapterFactoryInvalidImplementationErrorID() common.Hash { + return common.HexToHash("0x68155f9a907e5d62f79efc98cfae07e66c5c497ee19d258a092eaffa242b7f65") +} + +// UnpackInvalidImplementationError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InvalidImplementation() +func (iAdapterFactory *IAdapterFactory) UnpackInvalidImplementationError(raw []byte) (*IAdapterFactoryInvalidImplementation, error) { + out := new(IAdapterFactoryInvalidImplementation) + if err := iAdapterFactory.abi.UnpackIntoInterface(out, "InvalidImplementation", raw); err != nil { + return nil, err + } + return out, nil +} + +// IAdapterFactoryInvalidVersion represents a InvalidVersion error raised by the IAdapterFactory contract. +type IAdapterFactoryInvalidVersion struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InvalidVersion() +func IAdapterFactoryInvalidVersionErrorID() common.Hash { + return common.HexToHash("0xa9146eebce4eb0a5304713148983d3e5e6237160b32fa1cb60ab806d5c36c5ca") +} + +// UnpackInvalidVersionError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InvalidVersion() +func (iAdapterFactory *IAdapterFactory) UnpackInvalidVersionError(raw []byte) (*IAdapterFactoryInvalidVersion, error) { + out := new(IAdapterFactoryInvalidVersion) + if err := iAdapterFactory.abi.UnpackIntoInterface(out, "InvalidVersion", raw); err != nil { + return nil, err + } + return out, nil +} + +// IAdapterFactoryNotOwner represents a NotOwner error raised by the IAdapterFactory contract. +type IAdapterFactoryNotOwner struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotOwner() +func IAdapterFactoryNotOwnerErrorID() 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 (iAdapterFactory *IAdapterFactory) UnpackNotOwnerError(raw []byte) (*IAdapterFactoryNotOwner, error) { + out := new(IAdapterFactoryNotOwner) + if err := iAdapterFactory.abi.UnpackIntoInterface(out, "NotOwner", raw); err != nil { + return nil, err + } + return out, nil +} + +// IAdapterFactoryOldVersion represents a OldVersion error raised by the IAdapterFactory contract. +type IAdapterFactoryOldVersion struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error OldVersion() +func IAdapterFactoryOldVersionErrorID() common.Hash { + return common.HexToHash("0x384ebd90a535625f7ad4cce3a0801a073ae6623b808733454b45d176f6722fe2") +} + +// UnpackOldVersionError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error OldVersion() +func (iAdapterFactory *IAdapterFactory) UnpackOldVersionError(raw []byte) (*IAdapterFactoryOldVersion, error) { + out := new(IAdapterFactoryOldVersion) + if err := iAdapterFactory.abi.UnpackIntoInterface(out, "OldVersion", raw); err != nil { + return nil, err + } + return out, nil +} diff --git a/api/bindings/oev/aggregator/AggregatorV3.go b/api/bindings/chainlink/aggregator/AggregatorV3.go similarity index 100% rename from api/bindings/oev/aggregator/AggregatorV3.go rename to api/bindings/chainlink/aggregator/AggregatorV3.go diff --git a/api/bindings/lens/FrontendLiquidityLens.go b/api/bindings/lens/FrontendLiquidityLens.go new file mode 100644 index 00000000..4cf5469e --- /dev/null +++ b/api/bindings/lens/FrontendLiquidityLens.go @@ -0,0 +1,121 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package lens + +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 +) + +// FrontendLiquidityLensMetaData contains all meta data concerning the FrontendLiquidityLens contract. +var FrontendLiquidityLensMetaData = bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"getMaxAssets\",\"inputs\":[{\"internalType\":\"address\",\"name\":\"adapter\",\"type\":\"address\"}],\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getMaxAssets\",\"inputs\":[{\"internalType\":\"address\",\"name\":\"adapter\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"tokenToRedeem\",\"type\":\"address\"}],\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"nonpayable\"}]", + ID: "FrontendLiquidityLens", +} + +// FrontendLiquidityLens is an auto generated Go binding around an Ethereum contract. +type FrontendLiquidityLens struct { + abi abi.ABI +} + +// NewFrontendLiquidityLens creates a new instance of FrontendLiquidityLens. +func NewFrontendLiquidityLens() *FrontendLiquidityLens { + parsed, err := FrontendLiquidityLensMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &FrontendLiquidityLens{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 *FrontendLiquidityLens) Instance(backend bind.ContractBackend, addr common.Address) *bind.BoundContract { + return bind.NewBoundContract(addr, c.abi, backend, backend, backend) +} + +// PackGetMaxAssets is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x22135549. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function getMaxAssets(address adapter) returns(uint256) +func (frontendLiquidityLens *FrontendLiquidityLens) PackGetMaxAssets(adapter common.Address) []byte { + enc, err := frontendLiquidityLens.abi.Pack("getMaxAssets", adapter) + 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 0x22135549. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function getMaxAssets(address adapter) returns(uint256) +func (frontendLiquidityLens *FrontendLiquidityLens) TryPackGetMaxAssets(adapter common.Address) ([]byte, error) { + return frontendLiquidityLens.abi.Pack("getMaxAssets", adapter) +} + +// UnpackGetMaxAssets is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x22135549. +// +// Solidity: function getMaxAssets(address adapter) returns(uint256) +func (frontendLiquidityLens *FrontendLiquidityLens) UnpackGetMaxAssets(data []byte) (*big.Int, error) { + out, err := frontendLiquidityLens.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 +} + +// PackGetMaxAssets0 is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x291a304c. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function getMaxAssets(address adapter, address tokenToRedeem) returns(uint256) +func (frontendLiquidityLens *FrontendLiquidityLens) PackGetMaxAssets0(adapter common.Address, tokenToRedeem common.Address) []byte { + enc, err := frontendLiquidityLens.abi.Pack("getMaxAssets0", adapter, tokenToRedeem) + if err != nil { + panic(err) + } + return enc +} + +// TryPackGetMaxAssets0 is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x291a304c. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function getMaxAssets(address adapter, address tokenToRedeem) returns(uint256) +func (frontendLiquidityLens *FrontendLiquidityLens) TryPackGetMaxAssets0(adapter common.Address, tokenToRedeem common.Address) ([]byte, error) { + return frontendLiquidityLens.abi.Pack("getMaxAssets0", adapter, tokenToRedeem) +} + +// UnpackGetMaxAssets0 is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x291a304c. +// +// Solidity: function getMaxAssets(address adapter, address tokenToRedeem) returns(uint256) +func (frontendLiquidityLens *FrontendLiquidityLens) UnpackGetMaxAssets0(data []byte) (*big.Int, error) { + out, err := frontendLiquidityLens.abi.Unpack("getMaxAssets0", 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/lifi/executor/LiquidLaneLifiExecutor.go b/api/bindings/lifi/executor/LiquidLaneLifiExecutor.go new file mode 100644 index 00000000..402a4843 --- /dev/null +++ b/api/bindings/lifi/executor/LiquidLaneLifiExecutor.go @@ -0,0 +1,940 @@ +// 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 +) + +// IInputSettlerStandardOrder is an auto generated low-level Go binding around an user-defined struct. +type IInputSettlerStandardOrder struct { + User common.Address + Nonce *big.Int + OriginChainId *big.Int + Expires uint32 + FillDeadline uint32 + InputOracle common.Address + Inputs [][2]*big.Int + Outputs []MandateOutput +} + +// ILiquidLaneAdapterDiscount is an auto generated low-level Go binding around an user-defined struct. +type ILiquidLaneAdapterDiscount struct { + TokenToRedeem common.Address + Discount *big.Int + Signer common.Address + Protocol common.Address + Nonce *big.Int + Deadline *big.Int +} + +// ILiquidLaneAdapterDiscountSwap is an auto generated low-level Go binding around an user-defined struct. +type ILiquidLaneAdapterDiscountSwap struct { + Discount ILiquidLaneAdapterDiscount + SignerSignature []byte + ProtocolDeadline *big.Int +} + +// ILiquidLaneLifiExecutorFillDiscount is an auto generated low-level Go binding around an user-defined struct. +type ILiquidLaneLifiExecutorFillDiscount struct { + DiscountId [32]byte + DiscountSwap ILiquidLaneAdapterDiscountSwap + ProtocolSignature []byte +} + +// ILiquidLaneLifiExecutorFillRoute is an auto generated low-level Go binding around an user-defined struct. +type ILiquidLaneLifiExecutorFillRoute struct { + Adapter common.Address + AmountIn *big.Int + AmountOut *big.Int + Discount ILiquidLaneLifiExecutorFillDiscount +} + +// MandateOutput is an auto generated low-level Go binding around an user-defined struct. +type MandateOutput struct { + Oracle [32]byte + Settler [32]byte + ChainId *big.Int + Token [32]byte + Amount *big.Int + Recipient [32]byte + CallbackData []byte + Context []byte +} + +// LiquidLaneLifiExecutorMetaData contains all meta data concerning the LiquidLaneLifiExecutor contract. +var LiquidLaneLifiExecutorMetaData = bind.MetaData{ + ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"inputSettler\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"outputSettler\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"INPUT_SETTLER\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"LIFI_REGISTRATION_TYPEHASH\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"OUTPUT_SETTLER\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"callers\",\"inputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"eip712Domain\",\"inputs\":[],\"outputs\":[{\"name\":\"fields\",\"type\":\"bytes1\",\"internalType\":\"bytes1\"},{\"name\":\"name\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"version\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"salt\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"extensions\",\"type\":\"uint256[]\",\"internalType\":\"uint256[]\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"finaliseWithCurrentTimestamp\",\"inputs\":[{\"name\":\"order\",\"type\":\"tuple\",\"internalType\":\"structIInputSettler.StandardOrder\",\"components\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"originChainId\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"expires\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"fillDeadline\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"inputOracle\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"inputs\",\"type\":\"uint256[2][]\",\"internalType\":\"uint256[2][]\"},{\"name\":\"outputs\",\"type\":\"tuple[]\",\"internalType\":\"structMandateOutput[]\",\"components\":[{\"name\":\"oracle\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"settler\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"chainId\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"token\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"recipient\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"callbackData\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"context\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}]},{\"name\":\"routes\",\"type\":\"tuple[]\",\"internalType\":\"structILiquidLaneLifiExecutor.FillRoute[]\",\"components\":[{\"name\":\"adapter\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amountIn\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"amountOut\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"discount\",\"type\":\"tuple\",\"internalType\":\"structILiquidLaneLifiExecutor.FillDiscount\",\"components\":[{\"name\":\"discountId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"discountSwap\",\"type\":\"tuple\",\"internalType\":\"structILiquidLaneAdapter.DiscountSwap\",\"components\":[{\"name\":\"discount\",\"type\":\"tuple\",\"internalType\":\"structILiquidLaneAdapter.Discount\",\"components\":[{\"name\":\"tokenToRedeem\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"discount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"signer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"protocol\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"deadline\",\"type\":\"uint48\",\"internalType\":\"uint48\"}]},{\"name\":\"signerSignature\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"protocolDeadline\",\"type\":\"uint48\",\"internalType\":\"uint48\"}]},{\"name\":\"protocolSignature\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"initialize\",\"inputs\":[{\"name\":\"owner_\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"initCallers\",\"type\":\"address[]\",\"internalType\":\"address[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"isCaller\",\"inputs\":[{\"name\":\"caller\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"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\":\"lifiRegistrationDigest\",\"inputs\":[{\"name\":\"messageHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"orderFinalised\",\"inputs\":[{\"name\":\"inputs\",\"type\":\"uint256[2][]\",\"internalType\":\"uint256[2][]\"},{\"name\":\"executionData\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"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\":\"setCallers\",\"inputs\":[{\"name\":\"newCallers\",\"type\":\"address[]\",\"internalType\":\"address[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"EIP712DomainChanged\",\"inputs\":[],\"anonymous\":false},{\"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\":\"SetCallers\",\"inputs\":[{\"name\":\"newCallers\",\"type\":\"address[]\",\"indexed\":false,\"internalType\":\"address[]\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"InvalidInitialization\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotCaller\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotInitializing\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotInputSettler\",\"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\":\"SafeERC20FailedOperation\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}]}]", + ID: "LiquidLaneLifiExecutor", +} + +// LiquidLaneLifiExecutor is an auto generated Go binding around an Ethereum contract. +type LiquidLaneLifiExecutor struct { + abi abi.ABI +} + +// NewLiquidLaneLifiExecutor creates a new instance of LiquidLaneLifiExecutor. +func NewLiquidLaneLifiExecutor() *LiquidLaneLifiExecutor { + parsed, err := LiquidLaneLifiExecutorMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &LiquidLaneLifiExecutor{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 *LiquidLaneLifiExecutor) 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 inputSettler, address outputSettler) returns() +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackConstructor(inputSettler common.Address, outputSettler common.Address) []byte { + enc, err := liquidLaneLifiExecutor.abi.Pack("", inputSettler, outputSettler) + if err != nil { + panic(err) + } + return enc +} + +// PackINPUTSETTLER is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xb627707d. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function INPUT_SETTLER() view returns(address) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackINPUTSETTLER() []byte { + enc, err := liquidLaneLifiExecutor.abi.Pack("INPUT_SETTLER") + if err != nil { + panic(err) + } + return enc +} + +// TryPackINPUTSETTLER is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xb627707d. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function INPUT_SETTLER() view returns(address) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackINPUTSETTLER() ([]byte, error) { + return liquidLaneLifiExecutor.abi.Pack("INPUT_SETTLER") +} + +// UnpackINPUTSETTLER is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xb627707d. +// +// Solidity: function INPUT_SETTLER() view returns(address) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackINPUTSETTLER(data []byte) (common.Address, error) { + out, err := liquidLaneLifiExecutor.abi.Unpack("INPUT_SETTLER", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackLIFIREGISTRATIONTYPEHASH is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xd0c83dad. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function LIFI_REGISTRATION_TYPEHASH() view returns(bytes32) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackLIFIREGISTRATIONTYPEHASH() []byte { + enc, err := liquidLaneLifiExecutor.abi.Pack("LIFI_REGISTRATION_TYPEHASH") + if err != nil { + panic(err) + } + return enc +} + +// TryPackLIFIREGISTRATIONTYPEHASH is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xd0c83dad. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function LIFI_REGISTRATION_TYPEHASH() view returns(bytes32) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackLIFIREGISTRATIONTYPEHASH() ([]byte, error) { + return liquidLaneLifiExecutor.abi.Pack("LIFI_REGISTRATION_TYPEHASH") +} + +// UnpackLIFIREGISTRATIONTYPEHASH is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xd0c83dad. +// +// Solidity: function LIFI_REGISTRATION_TYPEHASH() view returns(bytes32) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackLIFIREGISTRATIONTYPEHASH(data []byte) ([32]byte, error) { + out, err := liquidLaneLifiExecutor.abi.Unpack("LIFI_REGISTRATION_TYPEHASH", data) + if err != nil { + return *new([32]byte), err + } + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + return out0, nil +} + +// PackOUTPUTSETTLER is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xc6d9d466. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function OUTPUT_SETTLER() view returns(address) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackOUTPUTSETTLER() []byte { + enc, err := liquidLaneLifiExecutor.abi.Pack("OUTPUT_SETTLER") + if err != nil { + panic(err) + } + return enc +} + +// TryPackOUTPUTSETTLER is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xc6d9d466. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function OUTPUT_SETTLER() view returns(address) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackOUTPUTSETTLER() ([]byte, error) { + return liquidLaneLifiExecutor.abi.Pack("OUTPUT_SETTLER") +} + +// UnpackOUTPUTSETTLER is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xc6d9d466. +// +// Solidity: function OUTPUT_SETTLER() view returns(address) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackOUTPUTSETTLER(data []byte) (common.Address, error) { + out, err := liquidLaneLifiExecutor.abi.Unpack("OUTPUT_SETTLER", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackCallers is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xaa03fa3d. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function callers(uint256 ) view returns(address) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackCallers(arg0 *big.Int) []byte { + enc, err := liquidLaneLifiExecutor.abi.Pack("callers", arg0) + if err != nil { + panic(err) + } + return enc +} + +// TryPackCallers is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xaa03fa3d. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function callers(uint256 ) view returns(address) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackCallers(arg0 *big.Int) ([]byte, error) { + return liquidLaneLifiExecutor.abi.Pack("callers", arg0) +} + +// UnpackCallers is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xaa03fa3d. +// +// Solidity: function callers(uint256 ) view returns(address) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackCallers(data []byte) (common.Address, error) { + out, err := liquidLaneLifiExecutor.abi.Unpack("callers", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackEip712Domain is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x84b0196e. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function eip712Domain() view returns(bytes1 fields, string name, string version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] extensions) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackEip712Domain() []byte { + enc, err := liquidLaneLifiExecutor.abi.Pack("eip712Domain") + if err != nil { + panic(err) + } + return enc +} + +// TryPackEip712Domain is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x84b0196e. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function eip712Domain() view returns(bytes1 fields, string name, string version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] extensions) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackEip712Domain() ([]byte, error) { + return liquidLaneLifiExecutor.abi.Pack("eip712Domain") +} + +// Eip712DomainOutput serves as a container for the return parameters of contract +// method Eip712Domain. +type Eip712DomainOutput struct { + Fields [1]byte + Name string + Version string + ChainId *big.Int + VerifyingContract common.Address + Salt [32]byte + Extensions []*big.Int +} + +// UnpackEip712Domain is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x84b0196e. +// +// Solidity: function eip712Domain() view returns(bytes1 fields, string name, string version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] extensions) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackEip712Domain(data []byte) (Eip712DomainOutput, error) { + out, err := liquidLaneLifiExecutor.abi.Unpack("eip712Domain", data) + outstruct := new(Eip712DomainOutput) + if err != nil { + return *outstruct, err + } + outstruct.Fields = *abi.ConvertType(out[0], new([1]byte)).(*[1]byte) + outstruct.Name = *abi.ConvertType(out[1], new(string)).(*string) + outstruct.Version = *abi.ConvertType(out[2], new(string)).(*string) + outstruct.ChainId = abi.ConvertType(out[3], new(big.Int)).(*big.Int) + outstruct.VerifyingContract = *abi.ConvertType(out[4], new(common.Address)).(*common.Address) + outstruct.Salt = *abi.ConvertType(out[5], new([32]byte)).(*[32]byte) + outstruct.Extensions = *abi.ConvertType(out[6], new([]*big.Int)).(*[]*big.Int) + return *outstruct, nil +} + +// PackFinaliseWithCurrentTimestamp is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xcdfb25e0. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function finaliseWithCurrentTimestamp((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order, (address,uint256,uint256,(bytes32,((address,uint256,address,address,uint256,uint48),bytes,uint48),bytes))[] routes) returns() +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackFinaliseWithCurrentTimestamp(order IInputSettlerStandardOrder, routes []ILiquidLaneLifiExecutorFillRoute) []byte { + enc, err := liquidLaneLifiExecutor.abi.Pack("finaliseWithCurrentTimestamp", order, routes) + if err != nil { + panic(err) + } + return enc +} + +// TryPackFinaliseWithCurrentTimestamp is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xcdfb25e0. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function finaliseWithCurrentTimestamp((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order, (address,uint256,uint256,(bytes32,((address,uint256,address,address,uint256,uint48),bytes,uint48),bytes))[] routes) returns() +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackFinaliseWithCurrentTimestamp(order IInputSettlerStandardOrder, routes []ILiquidLaneLifiExecutorFillRoute) ([]byte, error) { + return liquidLaneLifiExecutor.abi.Pack("finaliseWithCurrentTimestamp", order, routes) +} + +// PackInitialize is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x946d9204. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function initialize(address owner_, address[] initCallers) returns() +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackInitialize(owner common.Address, initCallers []common.Address) []byte { + enc, err := liquidLaneLifiExecutor.abi.Pack("initialize", owner, initCallers) + 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 0x946d9204. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function initialize(address owner_, address[] initCallers) returns() +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackInitialize(owner common.Address, initCallers []common.Address) ([]byte, error) { + return liquidLaneLifiExecutor.abi.Pack("initialize", owner, initCallers) +} + +// PackIsCaller is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x7ac07dcc. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function isCaller(address caller) view returns(bool) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackIsCaller(caller common.Address) []byte { + enc, err := liquidLaneLifiExecutor.abi.Pack("isCaller", caller) + if err != nil { + panic(err) + } + return enc +} + +// TryPackIsCaller is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x7ac07dcc. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function isCaller(address caller) view returns(bool) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackIsCaller(caller common.Address) ([]byte, error) { + return liquidLaneLifiExecutor.abi.Pack("isCaller", caller) +} + +// UnpackIsCaller is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x7ac07dcc. +// +// Solidity: function isCaller(address caller) view returns(bool) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackIsCaller(data []byte) (bool, error) { + out, err := liquidLaneLifiExecutor.abi.Unpack("isCaller", data) + if err != nil { + return *new(bool), err + } + out0 := *abi.ConvertType(out[0], new(bool)).(*bool) + return out0, nil +} + +// 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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackIsValidSignature(hash [32]byte, signature []byte) []byte { + enc, err := liquidLaneLifiExecutor.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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackIsValidSignature(hash [32]byte, signature []byte) ([]byte, error) { + return liquidLaneLifiExecutor.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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackIsValidSignature(data []byte) ([4]byte, error) { + out, err := liquidLaneLifiExecutor.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 +} + +// PackLifiRegistrationDigest is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x1ce5298e. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function lifiRegistrationDigest(bytes32 messageHash) view returns(bytes32) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackLifiRegistrationDigest(messageHash [32]byte) []byte { + enc, err := liquidLaneLifiExecutor.abi.Pack("lifiRegistrationDigest", messageHash) + if err != nil { + panic(err) + } + return enc +} + +// TryPackLifiRegistrationDigest is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x1ce5298e. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function lifiRegistrationDigest(bytes32 messageHash) view returns(bytes32) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackLifiRegistrationDigest(messageHash [32]byte) ([]byte, error) { + return liquidLaneLifiExecutor.abi.Pack("lifiRegistrationDigest", messageHash) +} + +// UnpackLifiRegistrationDigest is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x1ce5298e. +// +// Solidity: function lifiRegistrationDigest(bytes32 messageHash) view returns(bytes32) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackLifiRegistrationDigest(data []byte) ([32]byte, error) { + out, err := liquidLaneLifiExecutor.abi.Unpack("lifiRegistrationDigest", data) + if err != nil { + return *new([32]byte), err + } + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + return out0, nil +} + +// PackOrderFinalised is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x73e57c27. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function orderFinalised(uint256[2][] inputs, bytes executionData) returns() +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackOrderFinalised(inputs [][2]*big.Int, executionData []byte) []byte { + enc, err := liquidLaneLifiExecutor.abi.Pack("orderFinalised", inputs, executionData) + if err != nil { + panic(err) + } + return enc +} + +// TryPackOrderFinalised is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x73e57c27. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function orderFinalised(uint256[2][] inputs, bytes executionData) returns() +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackOrderFinalised(inputs [][2]*big.Int, executionData []byte) ([]byte, error) { + return liquidLaneLifiExecutor.abi.Pack("orderFinalised", inputs, executionData) +} + +// 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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackOwner() []byte { + enc, err := liquidLaneLifiExecutor.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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackOwner() ([]byte, error) { + return liquidLaneLifiExecutor.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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackOwner(data []byte) (common.Address, error) { + out, err := liquidLaneLifiExecutor.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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackRenounceOwnership() []byte { + enc, err := liquidLaneLifiExecutor.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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackRenounceOwnership() ([]byte, error) { + return liquidLaneLifiExecutor.abi.Pack("renounceOwnership") +} + +// PackSetCallers is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x43ded848. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function setCallers(address[] newCallers) returns() +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackSetCallers(newCallers []common.Address) []byte { + enc, err := liquidLaneLifiExecutor.abi.Pack("setCallers", newCallers) + if err != nil { + panic(err) + } + return enc +} + +// TryPackSetCallers is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x43ded848. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function setCallers(address[] newCallers) returns() +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackSetCallers(newCallers []common.Address) ([]byte, error) { + return liquidLaneLifiExecutor.abi.Pack("setCallers", newCallers) +} + +// 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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) PackTransferOwnership(newOwner common.Address) []byte { + enc, err := liquidLaneLifiExecutor.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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) TryPackTransferOwnership(newOwner common.Address) ([]byte, error) { + return liquidLaneLifiExecutor.abi.Pack("transferOwnership", newOwner) +} + +// LiquidLaneLifiExecutorEIP712DomainChanged represents a EIP712DomainChanged event raised by the LiquidLaneLifiExecutor contract. +type LiquidLaneLifiExecutorEIP712DomainChanged struct { + Raw *types.Log // Blockchain specific contextual infos +} + +const LiquidLaneLifiExecutorEIP712DomainChangedEventName = "EIP712DomainChanged" + +// ContractEventName returns the user-defined event name. +func (LiquidLaneLifiExecutorEIP712DomainChanged) ContractEventName() string { + return LiquidLaneLifiExecutorEIP712DomainChangedEventName +} + +// UnpackEIP712DomainChangedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event EIP712DomainChanged() +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackEIP712DomainChangedEvent(log *types.Log) (*LiquidLaneLifiExecutorEIP712DomainChanged, error) { + event := "EIP712DomainChanged" + if log.Topics[0] != liquidLaneLifiExecutor.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(LiquidLaneLifiExecutorEIP712DomainChanged) + if len(log.Data) > 0 { + if err := liquidLaneLifiExecutor.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range liquidLaneLifiExecutor.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 +} + +// LiquidLaneLifiExecutorInitialized represents a Initialized event raised by the LiquidLaneLifiExecutor contract. +type LiquidLaneLifiExecutorInitialized struct { + Version uint64 + Raw *types.Log // Blockchain specific contextual infos +} + +const LiquidLaneLifiExecutorInitializedEventName = "Initialized" + +// ContractEventName returns the user-defined event name. +func (LiquidLaneLifiExecutorInitialized) ContractEventName() string { + return LiquidLaneLifiExecutorInitializedEventName +} + +// UnpackInitializedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event Initialized(uint64 version) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackInitializedEvent(log *types.Log) (*LiquidLaneLifiExecutorInitialized, error) { + event := "Initialized" + if log.Topics[0] != liquidLaneLifiExecutor.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(LiquidLaneLifiExecutorInitialized) + if len(log.Data) > 0 { + if err := liquidLaneLifiExecutor.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range liquidLaneLifiExecutor.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 +} + +// LiquidLaneLifiExecutorOwnershipTransferred represents a OwnershipTransferred event raised by the LiquidLaneLifiExecutor contract. +type LiquidLaneLifiExecutorOwnershipTransferred struct { + PreviousOwner common.Address + NewOwner common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const LiquidLaneLifiExecutorOwnershipTransferredEventName = "OwnershipTransferred" + +// ContractEventName returns the user-defined event name. +func (LiquidLaneLifiExecutorOwnershipTransferred) ContractEventName() string { + return LiquidLaneLifiExecutorOwnershipTransferredEventName +} + +// UnpackOwnershipTransferredEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackOwnershipTransferredEvent(log *types.Log) (*LiquidLaneLifiExecutorOwnershipTransferred, error) { + event := "OwnershipTransferred" + if log.Topics[0] != liquidLaneLifiExecutor.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(LiquidLaneLifiExecutorOwnershipTransferred) + if len(log.Data) > 0 { + if err := liquidLaneLifiExecutor.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range liquidLaneLifiExecutor.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 +} + +// LiquidLaneLifiExecutorSetCallers represents a SetCallers event raised by the LiquidLaneLifiExecutor contract. +type LiquidLaneLifiExecutorSetCallers struct { + NewCallers []common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const LiquidLaneLifiExecutorSetCallersEventName = "SetCallers" + +// ContractEventName returns the user-defined event name. +func (LiquidLaneLifiExecutorSetCallers) ContractEventName() string { + return LiquidLaneLifiExecutorSetCallersEventName +} + +// UnpackSetCallersEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event SetCallers(address[] newCallers) +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackSetCallersEvent(log *types.Log) (*LiquidLaneLifiExecutorSetCallers, error) { + event := "SetCallers" + if log.Topics[0] != liquidLaneLifiExecutor.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(LiquidLaneLifiExecutorSetCallers) + if len(log.Data) > 0 { + if err := liquidLaneLifiExecutor.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range liquidLaneLifiExecutor.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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackError(raw []byte) (any, error) { + if bytes.Equal(raw[:4], liquidLaneLifiExecutor.abi.Errors["InvalidInitialization"].ID.Bytes()[:4]) { + return liquidLaneLifiExecutor.UnpackInvalidInitializationError(raw[4:]) + } + if bytes.Equal(raw[:4], liquidLaneLifiExecutor.abi.Errors["NotCaller"].ID.Bytes()[:4]) { + return liquidLaneLifiExecutor.UnpackNotCallerError(raw[4:]) + } + if bytes.Equal(raw[:4], liquidLaneLifiExecutor.abi.Errors["NotInitializing"].ID.Bytes()[:4]) { + return liquidLaneLifiExecutor.UnpackNotInitializingError(raw[4:]) + } + if bytes.Equal(raw[:4], liquidLaneLifiExecutor.abi.Errors["NotInputSettler"].ID.Bytes()[:4]) { + return liquidLaneLifiExecutor.UnpackNotInputSettlerError(raw[4:]) + } + if bytes.Equal(raw[:4], liquidLaneLifiExecutor.abi.Errors["OwnableInvalidOwner"].ID.Bytes()[:4]) { + return liquidLaneLifiExecutor.UnpackOwnableInvalidOwnerError(raw[4:]) + } + if bytes.Equal(raw[:4], liquidLaneLifiExecutor.abi.Errors["OwnableUnauthorizedAccount"].ID.Bytes()[:4]) { + return liquidLaneLifiExecutor.UnpackOwnableUnauthorizedAccountError(raw[4:]) + } + if bytes.Equal(raw[:4], liquidLaneLifiExecutor.abi.Errors["SafeERC20FailedOperation"].ID.Bytes()[:4]) { + return liquidLaneLifiExecutor.UnpackSafeERC20FailedOperationError(raw[4:]) + } + return nil, errors.New("Unknown error") +} + +// LiquidLaneLifiExecutorInvalidInitialization represents a InvalidInitialization error raised by the LiquidLaneLifiExecutor contract. +type LiquidLaneLifiExecutorInvalidInitialization struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InvalidInitialization() +func LiquidLaneLifiExecutorInvalidInitializationErrorID() 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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackInvalidInitializationError(raw []byte) (*LiquidLaneLifiExecutorInvalidInitialization, error) { + out := new(LiquidLaneLifiExecutorInvalidInitialization) + if err := liquidLaneLifiExecutor.abi.UnpackIntoInterface(out, "InvalidInitialization", raw); err != nil { + return nil, err + } + return out, nil +} + +// LiquidLaneLifiExecutorNotCaller represents a NotCaller error raised by the LiquidLaneLifiExecutor contract. +type LiquidLaneLifiExecutorNotCaller struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotCaller() +func LiquidLaneLifiExecutorNotCallerErrorID() common.Hash { + return common.HexToHash("0x16c618d80989492b64dbf0ed90935e3959f670b9b9d57385b45d00c0d1cdedf9") +} + +// UnpackNotCallerError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotCaller() +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackNotCallerError(raw []byte) (*LiquidLaneLifiExecutorNotCaller, error) { + out := new(LiquidLaneLifiExecutorNotCaller) + if err := liquidLaneLifiExecutor.abi.UnpackIntoInterface(out, "NotCaller", raw); err != nil { + return nil, err + } + return out, nil +} + +// LiquidLaneLifiExecutorNotInitializing represents a NotInitializing error raised by the LiquidLaneLifiExecutor contract. +type LiquidLaneLifiExecutorNotInitializing struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotInitializing() +func LiquidLaneLifiExecutorNotInitializingErrorID() 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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackNotInitializingError(raw []byte) (*LiquidLaneLifiExecutorNotInitializing, error) { + out := new(LiquidLaneLifiExecutorNotInitializing) + if err := liquidLaneLifiExecutor.abi.UnpackIntoInterface(out, "NotInitializing", raw); err != nil { + return nil, err + } + return out, nil +} + +// LiquidLaneLifiExecutorNotInputSettler represents a NotInputSettler error raised by the LiquidLaneLifiExecutor contract. +type LiquidLaneLifiExecutorNotInputSettler struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotInputSettler() +func LiquidLaneLifiExecutorNotInputSettlerErrorID() common.Hash { + return common.HexToHash("0xde89f63ea338ef13c2e1dd13cfee098f9c2ac145dbd7f1e315fcaffdc099d30a") +} + +// UnpackNotInputSettlerError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotInputSettler() +func (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackNotInputSettlerError(raw []byte) (*LiquidLaneLifiExecutorNotInputSettler, error) { + out := new(LiquidLaneLifiExecutorNotInputSettler) + if err := liquidLaneLifiExecutor.abi.UnpackIntoInterface(out, "NotInputSettler", raw); err != nil { + return nil, err + } + return out, nil +} + +// LiquidLaneLifiExecutorOwnableInvalidOwner represents a OwnableInvalidOwner error raised by the LiquidLaneLifiExecutor contract. +type LiquidLaneLifiExecutorOwnableInvalidOwner struct { + Owner common.Address +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error OwnableInvalidOwner(address owner) +func LiquidLaneLifiExecutorOwnableInvalidOwnerErrorID() 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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackOwnableInvalidOwnerError(raw []byte) (*LiquidLaneLifiExecutorOwnableInvalidOwner, error) { + out := new(LiquidLaneLifiExecutorOwnableInvalidOwner) + if err := liquidLaneLifiExecutor.abi.UnpackIntoInterface(out, "OwnableInvalidOwner", raw); err != nil { + return nil, err + } + return out, nil +} + +// LiquidLaneLifiExecutorOwnableUnauthorizedAccount represents a OwnableUnauthorizedAccount error raised by the LiquidLaneLifiExecutor contract. +type LiquidLaneLifiExecutorOwnableUnauthorizedAccount struct { + Account common.Address +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error OwnableUnauthorizedAccount(address account) +func LiquidLaneLifiExecutorOwnableUnauthorizedAccountErrorID() 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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackOwnableUnauthorizedAccountError(raw []byte) (*LiquidLaneLifiExecutorOwnableUnauthorizedAccount, error) { + out := new(LiquidLaneLifiExecutorOwnableUnauthorizedAccount) + if err := liquidLaneLifiExecutor.abi.UnpackIntoInterface(out, "OwnableUnauthorizedAccount", raw); err != nil { + return nil, err + } + return out, nil +} + +// LiquidLaneLifiExecutorSafeERC20FailedOperation represents a SafeERC20FailedOperation error raised by the LiquidLaneLifiExecutor contract. +type LiquidLaneLifiExecutorSafeERC20FailedOperation struct { + Token common.Address +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error SafeERC20FailedOperation(address token) +func LiquidLaneLifiExecutorSafeERC20FailedOperationErrorID() 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 (liquidLaneLifiExecutor *LiquidLaneLifiExecutor) UnpackSafeERC20FailedOperationError(raw []byte) (*LiquidLaneLifiExecutorSafeERC20FailedOperation, error) { + out := new(LiquidLaneLifiExecutorSafeERC20FailedOperation) + if err := liquidLaneLifiExecutor.abi.UnpackIntoInterface(out, "SafeERC20FailedOperation", raw); err != nil { + return nil, err + } + return out, nil +} diff --git a/api/bindings/lifi/inputsettler/ILifiInputSettler.go b/api/bindings/lifi/inputsettler/ILifiInputSettler.go new file mode 100644 index 00000000..013fb0a5 --- /dev/null +++ b/api/bindings/lifi/inputsettler/ILifiInputSettler.go @@ -0,0 +1,2043 @@ +// Code generated via abigen V2 - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package inputsettler + +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 +) + +// InputSettlerBaseSolveParams is an auto generated low-level Go binding around an user-defined struct. +type InputSettlerBaseSolveParams struct { + Timestamp uint32 + Solver [32]byte +} + +// MandateOutput is an auto generated low-level Go binding around an user-defined struct. +type MandateOutput struct { + Oracle [32]byte + Settler [32]byte + ChainId *big.Int + Token [32]byte + Amount *big.Int + Recipient [32]byte + CallbackData []byte + Context []byte +} + +// OrderPurchase is an auto generated low-level Go binding around an user-defined struct. +type OrderPurchase struct { + OrderId [32]byte + Destination common.Address + CallData []byte + Discount uint64 + TimeToBuy uint32 +} + +// StandardOrder is an auto generated low-level Go binding around an user-defined struct. +type StandardOrder struct { + User common.Address + Nonce *big.Int + OriginChainId *big.Int + Expires uint32 + FillDeadline uint32 + InputOracle common.Address + Inputs [][2]*big.Int + Outputs []MandateOutput +} + +// ILifiInputSettlerMetaData contains all meta data concerning the ILifiInputSettler contract. +var ILifiInputSettlerMetaData = bind.MetaData{ + ABI: "[{\"inputs\":[{\"internalType\":\"address\",\"name\":\"initialOwner\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[],\"name\":\"AlreadyInitialized\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"AlreadyPurchased\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"CallOutOfRange\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"CodeSize0\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"ContextOutOfRange\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"Expired\",\"type\":\"error\"},{\"inputs\":[{\"internalType\":\"uint32\",\"name\":\"fillDeadline\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"expires\",\"type\":\"uint32\"}],\"name\":\"FillDeadlineAfterExpiry\",\"type\":\"error\"},{\"inputs\":[{\"internalType\":\"uint32\",\"name\":\"expected\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"actual\",\"type\":\"uint32\"}],\"name\":\"FilledTooLate\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"GovernanceFeeChangeNotReady\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"GovernanceFeeTooHigh\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"HasDirtyBits\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"InvalidOrderStatus\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"InvalidPurchaser\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"InvalidShortString\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"InvalidSigner\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"InvalidTimestampLength\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"NewOwnerIsZeroAddress\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"NoDestination\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"NoHandoverRequest\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"NotOrderOwner\",\"type\":\"error\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"provided\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"computed\",\"type\":\"bytes32\"}],\"name\":\"OrderIdMismatch\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"ReentrancyDetected\",\"type\":\"error\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"token\",\"type\":\"address\"}],\"name\":\"SafeERC20FailedOperation\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"SignatureAndInputsNotEqual\",\"type\":\"error\"},{\"inputs\":[{\"internalType\":\"bytes1\",\"name\":\"\",\"type\":\"bytes1\"}],\"name\":\"SignatureNotSupported\",\"type\":\"error\"},{\"inputs\":[{\"internalType\":\"string\",\"name\":\"str\",\"type\":\"string\"}],\"name\":\"StringTooLong\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"TimestampNotPassed\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"TimestampPassed\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"Unauthorized\",\"type\":\"error\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"expected\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"actual\",\"type\":\"uint256\"}],\"name\":\"WrongChain\",\"type\":\"error\"},{\"anonymous\":false,\"inputs\":[],\"name\":\"EIP712DomainChanged\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"orderId\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"solver\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"destination\",\"type\":\"bytes32\"}],\"name\":\"Finalised\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"oldGovernanceFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"newGovernanceFee\",\"type\":\"uint64\"}],\"name\":\"GovernanceFeeChanged\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"nextGovernanceFee\",\"type\":\"uint64\"},{\"indexed\":false,\"internalType\":\"uint64\",\"name\":\"nextGovernanceFeeTime\",\"type\":\"uint64\"}],\"name\":\"NextGovernanceFee\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"orderId\",\"type\":\"bytes32\"}],\"name\":\"Open\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"orderId\",\"type\":\"bytes32\"},{\"components\":[{\"internalType\":\"address\",\"name\":\"user\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"nonce\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"originChainId\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"expires\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"fillDeadline\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"inputOracle\",\"type\":\"address\"},{\"internalType\":\"uint256[2][]\",\"name\":\"inputs\",\"type\":\"uint256[2][]\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"oracle\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"settler\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"chainId\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"token\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"recipient\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"callbackData\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"context\",\"type\":\"bytes\"}],\"internalType\":\"structMandateOutput[]\",\"name\":\"outputs\",\"type\":\"tuple[]\"}],\"indexed\":false,\"internalType\":\"structStandardOrder\",\"name\":\"order\",\"type\":\"tuple\"}],\"name\":\"Open\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"orderId\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"solver\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"bytes32\",\"name\":\"purchaser\",\"type\":\"bytes32\"}],\"name\":\"OrderPurchased\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"pendingOwner\",\"type\":\"address\"}],\"name\":\"OwnershipHandoverCanceled\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"pendingOwner\",\"type\":\"address\"}],\"name\":\"OwnershipHandoverRequested\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"oldOwner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"OwnershipTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"orderId\",\"type\":\"bytes32\"}],\"name\":\"Refunded\",\"type\":\"event\"},{\"inputs\":[],\"name\":\"DOMAIN_SEPARATOR\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"applyGovernanceFee\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"cancelOwnershipHandover\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"pendingOwner\",\"type\":\"address\"}],\"name\":\"completeOwnershipHandover\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"eip712Domain\",\"outputs\":[{\"internalType\":\"bytes1\",\"name\":\"fields\",\"type\":\"bytes1\"},{\"internalType\":\"string\",\"name\":\"name\",\"type\":\"string\"},{\"internalType\":\"string\",\"name\":\"version\",\"type\":\"string\"},{\"internalType\":\"uint256\",\"name\":\"chainId\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"verifyingContract\",\"type\":\"address\"},{\"internalType\":\"bytes32\",\"name\":\"salt\",\"type\":\"bytes32\"},{\"internalType\":\"uint256[]\",\"name\":\"extensions\",\"type\":\"uint256[]\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"user\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"nonce\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"originChainId\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"expires\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"fillDeadline\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"inputOracle\",\"type\":\"address\"},{\"internalType\":\"uint256[2][]\",\"name\":\"inputs\",\"type\":\"uint256[2][]\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"oracle\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"settler\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"chainId\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"token\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"recipient\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"callbackData\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"context\",\"type\":\"bytes\"}],\"internalType\":\"structMandateOutput[]\",\"name\":\"outputs\",\"type\":\"tuple[]\"}],\"internalType\":\"structStandardOrder\",\"name\":\"order\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"timestamp\",\"type\":\"uint32\"},{\"internalType\":\"bytes32\",\"name\":\"solver\",\"type\":\"bytes32\"}],\"internalType\":\"structInputSettlerBase.SolveParams[]\",\"name\":\"solveParams\",\"type\":\"tuple[]\"},{\"internalType\":\"bytes32\",\"name\":\"destination\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"call\",\"type\":\"bytes\"}],\"name\":\"finalise\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"user\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"nonce\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"originChainId\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"expires\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"fillDeadline\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"inputOracle\",\"type\":\"address\"},{\"internalType\":\"uint256[2][]\",\"name\":\"inputs\",\"type\":\"uint256[2][]\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"oracle\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"settler\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"chainId\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"token\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"recipient\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"callbackData\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"context\",\"type\":\"bytes\"}],\"internalType\":\"structMandateOutput[]\",\"name\":\"outputs\",\"type\":\"tuple[]\"}],\"internalType\":\"structStandardOrder\",\"name\":\"order\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"uint32\",\"name\":\"timestamp\",\"type\":\"uint32\"},{\"internalType\":\"bytes32\",\"name\":\"solver\",\"type\":\"bytes32\"}],\"internalType\":\"structInputSettlerBase.SolveParams[]\",\"name\":\"solveParams\",\"type\":\"tuple[]\"},{\"internalType\":\"bytes32\",\"name\":\"destination\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"call\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"orderOwnerSignature\",\"type\":\"bytes\"}],\"name\":\"finaliseWithSignature\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"governanceFee\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"nextGovernanceFee\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"nextGovernanceFeeTime\",\"outputs\":[{\"internalType\":\"uint64\",\"name\":\"\",\"type\":\"uint64\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"user\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"nonce\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"originChainId\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"expires\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"fillDeadline\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"inputOracle\",\"type\":\"address\"},{\"internalType\":\"uint256[2][]\",\"name\":\"inputs\",\"type\":\"uint256[2][]\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"oracle\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"settler\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"chainId\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"token\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"recipient\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"callbackData\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"context\",\"type\":\"bytes\"}],\"internalType\":\"structMandateOutput[]\",\"name\":\"outputs\",\"type\":\"tuple[]\"}],\"internalType\":\"structStandardOrder\",\"name\":\"order\",\"type\":\"tuple\"}],\"name\":\"open\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"user\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"nonce\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"originChainId\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"expires\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"fillDeadline\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"inputOracle\",\"type\":\"address\"},{\"internalType\":\"uint256[2][]\",\"name\":\"inputs\",\"type\":\"uint256[2][]\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"oracle\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"settler\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"chainId\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"token\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"recipient\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"callbackData\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"context\",\"type\":\"bytes\"}],\"internalType\":\"structMandateOutput[]\",\"name\":\"outputs\",\"type\":\"tuple[]\"}],\"internalType\":\"structStandardOrder\",\"name\":\"order\",\"type\":\"tuple\"},{\"internalType\":\"address\",\"name\":\"sponsor\",\"type\":\"address\"},{\"internalType\":\"bytes\",\"name\":\"signature\",\"type\":\"bytes\"}],\"name\":\"openFor\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"user\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"nonce\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"originChainId\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"expires\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"fillDeadline\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"inputOracle\",\"type\":\"address\"},{\"internalType\":\"uint256[2][]\",\"name\":\"inputs\",\"type\":\"uint256[2][]\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"oracle\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"settler\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"chainId\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"token\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"recipient\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"callbackData\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"context\",\"type\":\"bytes\"}],\"internalType\":\"structMandateOutput[]\",\"name\":\"outputs\",\"type\":\"tuple[]\"}],\"internalType\":\"structStandardOrder\",\"name\":\"order\",\"type\":\"tuple\"},{\"internalType\":\"address\",\"name\":\"sponsor\",\"type\":\"address\"},{\"internalType\":\"bytes\",\"name\":\"signature\",\"type\":\"bytes\"},{\"internalType\":\"address\",\"name\":\"destination\",\"type\":\"address\"},{\"internalType\":\"bytes\",\"name\":\"call\",\"type\":\"bytes\"}],\"name\":\"openForAndFinalise\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"user\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"nonce\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"originChainId\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"expires\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"fillDeadline\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"inputOracle\",\"type\":\"address\"},{\"internalType\":\"uint256[2][]\",\"name\":\"inputs\",\"type\":\"uint256[2][]\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"oracle\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"settler\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"chainId\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"token\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"recipient\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"callbackData\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"context\",\"type\":\"bytes\"}],\"internalType\":\"structMandateOutput[]\",\"name\":\"outputs\",\"type\":\"tuple[]\"}],\"internalType\":\"structStandardOrder\",\"name\":\"order\",\"type\":\"tuple\"}],\"name\":\"orderIdentifier\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"orderId\",\"type\":\"bytes32\"}],\"name\":\"orderStatus\",\"outputs\":[{\"internalType\":\"enumInputSettlerEscrow.OrderStatus\",\"name\":\"\",\"type\":\"uint8\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"owner\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"result\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"pendingOwner\",\"type\":\"address\"}],\"name\":\"ownershipHandoverExpiresAt\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"result\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"orderId\",\"type\":\"bytes32\"},{\"internalType\":\"address\",\"name\":\"destination\",\"type\":\"address\"},{\"internalType\":\"bytes\",\"name\":\"callData\",\"type\":\"bytes\"},{\"internalType\":\"uint64\",\"name\":\"discount\",\"type\":\"uint64\"},{\"internalType\":\"uint32\",\"name\":\"timeToBuy\",\"type\":\"uint32\"}],\"internalType\":\"structOrderPurchase\",\"name\":\"orderPurchase\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"address\",\"name\":\"user\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"nonce\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"originChainId\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"expires\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"fillDeadline\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"inputOracle\",\"type\":\"address\"},{\"internalType\":\"uint256[2][]\",\"name\":\"inputs\",\"type\":\"uint256[2][]\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"oracle\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"settler\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"chainId\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"token\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"recipient\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"callbackData\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"context\",\"type\":\"bytes\"}],\"internalType\":\"structMandateOutput[]\",\"name\":\"outputs\",\"type\":\"tuple[]\"}],\"internalType\":\"structStandardOrder\",\"name\":\"order\",\"type\":\"tuple\"},{\"internalType\":\"bytes32\",\"name\":\"orderSolvedByIdentifier\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"purchaser\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"expiryTimestamp\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"solverSignature\",\"type\":\"bytes\"}],\"name\":\"purchaseOrder\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"bytes32\",\"name\":\"solver\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"orderId\",\"type\":\"bytes32\"}],\"name\":\"purchasedOrders\",\"outputs\":[{\"internalType\":\"uint32\",\"name\":\"lastOrderTimestamp\",\"type\":\"uint32\"},{\"internalType\":\"bytes32\",\"name\":\"purchaser\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"address\",\"name\":\"user\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"nonce\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"originChainId\",\"type\":\"uint256\"},{\"internalType\":\"uint32\",\"name\":\"expires\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"fillDeadline\",\"type\":\"uint32\"},{\"internalType\":\"address\",\"name\":\"inputOracle\",\"type\":\"address\"},{\"internalType\":\"uint256[2][]\",\"name\":\"inputs\",\"type\":\"uint256[2][]\"},{\"components\":[{\"internalType\":\"bytes32\",\"name\":\"oracle\",\"type\":\"bytes32\"},{\"internalType\":\"bytes32\",\"name\":\"settler\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"chainId\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"token\",\"type\":\"bytes32\"},{\"internalType\":\"uint256\",\"name\":\"amount\",\"type\":\"uint256\"},{\"internalType\":\"bytes32\",\"name\":\"recipient\",\"type\":\"bytes32\"},{\"internalType\":\"bytes\",\"name\":\"callbackData\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"context\",\"type\":\"bytes\"}],\"internalType\":\"structMandateOutput[]\",\"name\":\"outputs\",\"type\":\"tuple[]\"}],\"internalType\":\"structStandardOrder\",\"name\":\"order\",\"type\":\"tuple\"}],\"name\":\"refund\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"renounceOwnership\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"requestOwnershipHandover\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint64\",\"name\":\"_nextGovernanceFee\",\"type\":\"uint64\"}],\"name\":\"setGovernanceFee\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"transferOwnership\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"}]", + ID: "ILifiInputSettler", +} + +// ILifiInputSettler is an auto generated Go binding around an Ethereum contract. +type ILifiInputSettler struct { + abi abi.ABI +} + +// NewILifiInputSettler creates a new instance of ILifiInputSettler. +func NewILifiInputSettler() *ILifiInputSettler { + parsed, err := ILifiInputSettlerMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &ILifiInputSettler{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 *ILifiInputSettler) 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 initialOwner) returns() +func (iLifiInputSettler *ILifiInputSettler) PackConstructor(initialOwner common.Address) []byte { + enc, err := iLifiInputSettler.abi.Pack("", initialOwner) + if err != nil { + panic(err) + } + return enc +} + +// PackDOMAINSEPARATOR is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x3644e515. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function DOMAIN_SEPARATOR() view returns(bytes32) +func (iLifiInputSettler *ILifiInputSettler) PackDOMAINSEPARATOR() []byte { + enc, err := iLifiInputSettler.abi.Pack("DOMAIN_SEPARATOR") + if err != nil { + panic(err) + } + return enc +} + +// TryPackDOMAINSEPARATOR is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x3644e515. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function DOMAIN_SEPARATOR() view returns(bytes32) +func (iLifiInputSettler *ILifiInputSettler) TryPackDOMAINSEPARATOR() ([]byte, error) { + return iLifiInputSettler.abi.Pack("DOMAIN_SEPARATOR") +} + +// UnpackDOMAINSEPARATOR is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x3644e515. +// +// Solidity: function DOMAIN_SEPARATOR() view returns(bytes32) +func (iLifiInputSettler *ILifiInputSettler) UnpackDOMAINSEPARATOR(data []byte) ([32]byte, error) { + out, err := iLifiInputSettler.abi.Unpack("DOMAIN_SEPARATOR", data) + if err != nil { + return *new([32]byte), err + } + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + return out0, nil +} + +// PackApplyGovernanceFee is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8198db87. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function applyGovernanceFee() returns() +func (iLifiInputSettler *ILifiInputSettler) PackApplyGovernanceFee() []byte { + enc, err := iLifiInputSettler.abi.Pack("applyGovernanceFee") + if err != nil { + panic(err) + } + return enc +} + +// TryPackApplyGovernanceFee is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x8198db87. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function applyGovernanceFee() returns() +func (iLifiInputSettler *ILifiInputSettler) TryPackApplyGovernanceFee() ([]byte, error) { + return iLifiInputSettler.abi.Pack("applyGovernanceFee") +} + +// PackCancelOwnershipHandover is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x54d1f13d. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function cancelOwnershipHandover() payable returns() +func (iLifiInputSettler *ILifiInputSettler) PackCancelOwnershipHandover() []byte { + enc, err := iLifiInputSettler.abi.Pack("cancelOwnershipHandover") + if err != nil { + panic(err) + } + return enc +} + +// TryPackCancelOwnershipHandover is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x54d1f13d. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function cancelOwnershipHandover() payable returns() +func (iLifiInputSettler *ILifiInputSettler) TryPackCancelOwnershipHandover() ([]byte, error) { + return iLifiInputSettler.abi.Pack("cancelOwnershipHandover") +} + +// PackCompleteOwnershipHandover is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf04e283e. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function completeOwnershipHandover(address pendingOwner) payable returns() +func (iLifiInputSettler *ILifiInputSettler) PackCompleteOwnershipHandover(pendingOwner common.Address) []byte { + enc, err := iLifiInputSettler.abi.Pack("completeOwnershipHandover", pendingOwner) + if err != nil { + panic(err) + } + return enc +} + +// TryPackCompleteOwnershipHandover is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf04e283e. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function completeOwnershipHandover(address pendingOwner) payable returns() +func (iLifiInputSettler *ILifiInputSettler) TryPackCompleteOwnershipHandover(pendingOwner common.Address) ([]byte, error) { + return iLifiInputSettler.abi.Pack("completeOwnershipHandover", pendingOwner) +} + +// PackEip712Domain is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x84b0196e. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function eip712Domain() view returns(bytes1 fields, string name, string version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] extensions) +func (iLifiInputSettler *ILifiInputSettler) PackEip712Domain() []byte { + enc, err := iLifiInputSettler.abi.Pack("eip712Domain") + if err != nil { + panic(err) + } + return enc +} + +// TryPackEip712Domain is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x84b0196e. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function eip712Domain() view returns(bytes1 fields, string name, string version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] extensions) +func (iLifiInputSettler *ILifiInputSettler) TryPackEip712Domain() ([]byte, error) { + return iLifiInputSettler.abi.Pack("eip712Domain") +} + +// Eip712DomainOutput serves as a container for the return parameters of contract +// method Eip712Domain. +type Eip712DomainOutput struct { + Fields [1]byte + Name string + Version string + ChainId *big.Int + VerifyingContract common.Address + Salt [32]byte + Extensions []*big.Int +} + +// UnpackEip712Domain is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x84b0196e. +// +// Solidity: function eip712Domain() view returns(bytes1 fields, string name, string version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] extensions) +func (iLifiInputSettler *ILifiInputSettler) UnpackEip712Domain(data []byte) (Eip712DomainOutput, error) { + out, err := iLifiInputSettler.abi.Unpack("eip712Domain", data) + outstruct := new(Eip712DomainOutput) + if err != nil { + return *outstruct, err + } + outstruct.Fields = *abi.ConvertType(out[0], new([1]byte)).(*[1]byte) + outstruct.Name = *abi.ConvertType(out[1], new(string)).(*string) + outstruct.Version = *abi.ConvertType(out[2], new(string)).(*string) + outstruct.ChainId = abi.ConvertType(out[3], new(big.Int)).(*big.Int) + outstruct.VerifyingContract = *abi.ConvertType(out[4], new(common.Address)).(*common.Address) + outstruct.Salt = *abi.ConvertType(out[5], new([32]byte)).(*[32]byte) + outstruct.Extensions = *abi.ConvertType(out[6], new([]*big.Int)).(*[]*big.Int) + return *outstruct, nil +} + +// PackFinalise is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xbab36441. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function finalise((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order, (uint32,bytes32)[] solveParams, bytes32 destination, bytes call) returns() +func (iLifiInputSettler *ILifiInputSettler) PackFinalise(order StandardOrder, solveParams []InputSettlerBaseSolveParams, destination [32]byte, call []byte) []byte { + enc, err := iLifiInputSettler.abi.Pack("finalise", order, solveParams, destination, call) + if err != nil { + panic(err) + } + return enc +} + +// TryPackFinalise is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xbab36441. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function finalise((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order, (uint32,bytes32)[] solveParams, bytes32 destination, bytes call) returns() +func (iLifiInputSettler *ILifiInputSettler) TryPackFinalise(order StandardOrder, solveParams []InputSettlerBaseSolveParams, destination [32]byte, call []byte) ([]byte, error) { + return iLifiInputSettler.abi.Pack("finalise", order, solveParams, destination, call) +} + +// PackFinaliseWithSignature is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x73ce1aaa. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function finaliseWithSignature((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order, (uint32,bytes32)[] solveParams, bytes32 destination, bytes call, bytes orderOwnerSignature) returns() +func (iLifiInputSettler *ILifiInputSettler) PackFinaliseWithSignature(order StandardOrder, solveParams []InputSettlerBaseSolveParams, destination [32]byte, call []byte, orderOwnerSignature []byte) []byte { + enc, err := iLifiInputSettler.abi.Pack("finaliseWithSignature", order, solveParams, destination, call, orderOwnerSignature) + if err != nil { + panic(err) + } + return enc +} + +// TryPackFinaliseWithSignature is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x73ce1aaa. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function finaliseWithSignature((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order, (uint32,bytes32)[] solveParams, bytes32 destination, bytes call, bytes orderOwnerSignature) returns() +func (iLifiInputSettler *ILifiInputSettler) TryPackFinaliseWithSignature(order StandardOrder, solveParams []InputSettlerBaseSolveParams, destination [32]byte, call []byte, orderOwnerSignature []byte) ([]byte, error) { + return iLifiInputSettler.abi.Pack("finaliseWithSignature", order, solveParams, destination, call, orderOwnerSignature) +} + +// PackGovernanceFee is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x0ea90a12. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function governanceFee() view returns(uint64) +func (iLifiInputSettler *ILifiInputSettler) PackGovernanceFee() []byte { + enc, err := iLifiInputSettler.abi.Pack("governanceFee") + if err != nil { + panic(err) + } + return enc +} + +// TryPackGovernanceFee is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x0ea90a12. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function governanceFee() view returns(uint64) +func (iLifiInputSettler *ILifiInputSettler) TryPackGovernanceFee() ([]byte, error) { + return iLifiInputSettler.abi.Pack("governanceFee") +} + +// UnpackGovernanceFee is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x0ea90a12. +// +// Solidity: function governanceFee() view returns(uint64) +func (iLifiInputSettler *ILifiInputSettler) UnpackGovernanceFee(data []byte) (uint64, error) { + out, err := iLifiInputSettler.abi.Unpack("governanceFee", data) + if err != nil { + return *new(uint64), err + } + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + return out0, nil +} + +// PackNextGovernanceFee is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xc0e31352. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function nextGovernanceFee() view returns(uint64) +func (iLifiInputSettler *ILifiInputSettler) PackNextGovernanceFee() []byte { + enc, err := iLifiInputSettler.abi.Pack("nextGovernanceFee") + if err != nil { + panic(err) + } + return enc +} + +// TryPackNextGovernanceFee is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xc0e31352. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function nextGovernanceFee() view returns(uint64) +func (iLifiInputSettler *ILifiInputSettler) TryPackNextGovernanceFee() ([]byte, error) { + return iLifiInputSettler.abi.Pack("nextGovernanceFee") +} + +// UnpackNextGovernanceFee is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xc0e31352. +// +// Solidity: function nextGovernanceFee() view returns(uint64) +func (iLifiInputSettler *ILifiInputSettler) UnpackNextGovernanceFee(data []byte) (uint64, error) { + out, err := iLifiInputSettler.abi.Unpack("nextGovernanceFee", data) + if err != nil { + return *new(uint64), err + } + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + return out0, nil +} + +// PackNextGovernanceFeeTime is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x5791edc0. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function nextGovernanceFeeTime() view returns(uint64) +func (iLifiInputSettler *ILifiInputSettler) PackNextGovernanceFeeTime() []byte { + enc, err := iLifiInputSettler.abi.Pack("nextGovernanceFeeTime") + if err != nil { + panic(err) + } + return enc +} + +// TryPackNextGovernanceFeeTime is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x5791edc0. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function nextGovernanceFeeTime() view returns(uint64) +func (iLifiInputSettler *ILifiInputSettler) TryPackNextGovernanceFeeTime() ([]byte, error) { + return iLifiInputSettler.abi.Pack("nextGovernanceFeeTime") +} + +// UnpackNextGovernanceFeeTime is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x5791edc0. +// +// Solidity: function nextGovernanceFeeTime() view returns(uint64) +func (iLifiInputSettler *ILifiInputSettler) UnpackNextGovernanceFeeTime(data []byte) (uint64, error) { + out, err := iLifiInputSettler.abi.Unpack("nextGovernanceFeeTime", data) + if err != nil { + return *new(uint64), err + } + out0 := *abi.ConvertType(out[0], new(uint64)).(*uint64) + return out0, nil +} + +// PackOpen is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x7515fd56. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function open((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order) returns() +func (iLifiInputSettler *ILifiInputSettler) PackOpen(order StandardOrder) []byte { + enc, err := iLifiInputSettler.abi.Pack("open", order) + if err != nil { + panic(err) + } + return enc +} + +// TryPackOpen is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x7515fd56. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function open((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order) returns() +func (iLifiInputSettler *ILifiInputSettler) TryPackOpen(order StandardOrder) ([]byte, error) { + return iLifiInputSettler.abi.Pack("open", order) +} + +// PackOpenFor is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x49927074. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function openFor((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order, address sponsor, bytes signature) returns() +func (iLifiInputSettler *ILifiInputSettler) PackOpenFor(order StandardOrder, sponsor common.Address, signature []byte) []byte { + enc, err := iLifiInputSettler.abi.Pack("openFor", order, sponsor, signature) + if err != nil { + panic(err) + } + return enc +} + +// TryPackOpenFor is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x49927074. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function openFor((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order, address sponsor, bytes signature) returns() +func (iLifiInputSettler *ILifiInputSettler) TryPackOpenFor(order StandardOrder, sponsor common.Address, signature []byte) ([]byte, error) { + return iLifiInputSettler.abi.Pack("openFor", order, sponsor, signature) +} + +// PackOpenForAndFinalise is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xafe55c7e. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function openForAndFinalise((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order, address sponsor, bytes signature, address destination, bytes call) returns() +func (iLifiInputSettler *ILifiInputSettler) PackOpenForAndFinalise(order StandardOrder, sponsor common.Address, signature []byte, destination common.Address, call []byte) []byte { + enc, err := iLifiInputSettler.abi.Pack("openForAndFinalise", order, sponsor, signature, destination, call) + if err != nil { + panic(err) + } + return enc +} + +// TryPackOpenForAndFinalise is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xafe55c7e. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function openForAndFinalise((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order, address sponsor, bytes signature, address destination, bytes call) returns() +func (iLifiInputSettler *ILifiInputSettler) TryPackOpenForAndFinalise(order StandardOrder, sponsor common.Address, signature []byte, destination common.Address, call []byte) ([]byte, error) { + return iLifiInputSettler.abi.Pack("openForAndFinalise", order, sponsor, signature, destination, call) +} + +// PackOrderIdentifier is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x609dbfa0. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function orderIdentifier((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order) view returns(bytes32) +func (iLifiInputSettler *ILifiInputSettler) PackOrderIdentifier(order StandardOrder) []byte { + enc, err := iLifiInputSettler.abi.Pack("orderIdentifier", order) + if err != nil { + panic(err) + } + return enc +} + +// TryPackOrderIdentifier is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x609dbfa0. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function orderIdentifier((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order) view returns(bytes32) +func (iLifiInputSettler *ILifiInputSettler) TryPackOrderIdentifier(order StandardOrder) ([]byte, error) { + return iLifiInputSettler.abi.Pack("orderIdentifier", order) +} + +// UnpackOrderIdentifier is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x609dbfa0. +// +// Solidity: function orderIdentifier((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order) view returns(bytes32) +func (iLifiInputSettler *ILifiInputSettler) UnpackOrderIdentifier(data []byte) ([32]byte, error) { + out, err := iLifiInputSettler.abi.Unpack("orderIdentifier", data) + if err != nil { + return *new([32]byte), err + } + out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) + return out0, nil +} + +// PackOrderStatus is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x2dff692d. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function orderStatus(bytes32 orderId) view returns(uint8) +func (iLifiInputSettler *ILifiInputSettler) PackOrderStatus(orderId [32]byte) []byte { + enc, err := iLifiInputSettler.abi.Pack("orderStatus", orderId) + if err != nil { + panic(err) + } + return enc +} + +// TryPackOrderStatus is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x2dff692d. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function orderStatus(bytes32 orderId) view returns(uint8) +func (iLifiInputSettler *ILifiInputSettler) TryPackOrderStatus(orderId [32]byte) ([]byte, error) { + return iLifiInputSettler.abi.Pack("orderStatus", orderId) +} + +// UnpackOrderStatus is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x2dff692d. +// +// Solidity: function orderStatus(bytes32 orderId) view returns(uint8) +func (iLifiInputSettler *ILifiInputSettler) UnpackOrderStatus(data []byte) (uint8, error) { + out, err := iLifiInputSettler.abi.Unpack("orderStatus", data) + if err != nil { + return *new(uint8), err + } + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + 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 result) +func (iLifiInputSettler *ILifiInputSettler) PackOwner() []byte { + enc, err := iLifiInputSettler.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 result) +func (iLifiInputSettler *ILifiInputSettler) TryPackOwner() ([]byte, error) { + return iLifiInputSettler.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 result) +func (iLifiInputSettler *ILifiInputSettler) UnpackOwner(data []byte) (common.Address, error) { + out, err := iLifiInputSettler.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 +} + +// PackOwnershipHandoverExpiresAt is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xfee81cf4. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function ownershipHandoverExpiresAt(address pendingOwner) view returns(uint256 result) +func (iLifiInputSettler *ILifiInputSettler) PackOwnershipHandoverExpiresAt(pendingOwner common.Address) []byte { + enc, err := iLifiInputSettler.abi.Pack("ownershipHandoverExpiresAt", pendingOwner) + if err != nil { + panic(err) + } + return enc +} + +// TryPackOwnershipHandoverExpiresAt is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xfee81cf4. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function ownershipHandoverExpiresAt(address pendingOwner) view returns(uint256 result) +func (iLifiInputSettler *ILifiInputSettler) TryPackOwnershipHandoverExpiresAt(pendingOwner common.Address) ([]byte, error) { + return iLifiInputSettler.abi.Pack("ownershipHandoverExpiresAt", pendingOwner) +} + +// UnpackOwnershipHandoverExpiresAt is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xfee81cf4. +// +// Solidity: function ownershipHandoverExpiresAt(address pendingOwner) view returns(uint256 result) +func (iLifiInputSettler *ILifiInputSettler) UnpackOwnershipHandoverExpiresAt(data []byte) (*big.Int, error) { + out, err := iLifiInputSettler.abi.Unpack("ownershipHandoverExpiresAt", data) + if err != nil { + return new(big.Int), err + } + out0 := abi.ConvertType(out[0], new(big.Int)).(*big.Int) + return out0, nil +} + +// PackPurchaseOrder is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x72903ef8. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function purchaseOrder((bytes32,address,bytes,uint64,uint32) orderPurchase, (address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order, bytes32 orderSolvedByIdentifier, bytes32 purchaser, uint256 expiryTimestamp, bytes solverSignature) returns() +func (iLifiInputSettler *ILifiInputSettler) PackPurchaseOrder(orderPurchase OrderPurchase, order StandardOrder, orderSolvedByIdentifier [32]byte, purchaser [32]byte, expiryTimestamp *big.Int, solverSignature []byte) []byte { + enc, err := iLifiInputSettler.abi.Pack("purchaseOrder", orderPurchase, order, orderSolvedByIdentifier, purchaser, expiryTimestamp, solverSignature) + if err != nil { + panic(err) + } + return enc +} + +// TryPackPurchaseOrder is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x72903ef8. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function purchaseOrder((bytes32,address,bytes,uint64,uint32) orderPurchase, (address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order, bytes32 orderSolvedByIdentifier, bytes32 purchaser, uint256 expiryTimestamp, bytes solverSignature) returns() +func (iLifiInputSettler *ILifiInputSettler) TryPackPurchaseOrder(orderPurchase OrderPurchase, order StandardOrder, orderSolvedByIdentifier [32]byte, purchaser [32]byte, expiryTimestamp *big.Int, solverSignature []byte) ([]byte, error) { + return iLifiInputSettler.abi.Pack("purchaseOrder", orderPurchase, order, orderSolvedByIdentifier, purchaser, expiryTimestamp, solverSignature) +} + +// PackPurchasedOrders is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x9efa6120. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function purchasedOrders(bytes32 solver, bytes32 orderId) view returns(uint32 lastOrderTimestamp, bytes32 purchaser) +func (iLifiInputSettler *ILifiInputSettler) PackPurchasedOrders(solver [32]byte, orderId [32]byte) []byte { + enc, err := iLifiInputSettler.abi.Pack("purchasedOrders", solver, orderId) + if err != nil { + panic(err) + } + return enc +} + +// TryPackPurchasedOrders is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x9efa6120. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function purchasedOrders(bytes32 solver, bytes32 orderId) view returns(uint32 lastOrderTimestamp, bytes32 purchaser) +func (iLifiInputSettler *ILifiInputSettler) TryPackPurchasedOrders(solver [32]byte, orderId [32]byte) ([]byte, error) { + return iLifiInputSettler.abi.Pack("purchasedOrders", solver, orderId) +} + +// PurchasedOrdersOutput serves as a container for the return parameters of contract +// method PurchasedOrders. +type PurchasedOrdersOutput struct { + LastOrderTimestamp uint32 + Purchaser [32]byte +} + +// UnpackPurchasedOrders is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0x9efa6120. +// +// Solidity: function purchasedOrders(bytes32 solver, bytes32 orderId) view returns(uint32 lastOrderTimestamp, bytes32 purchaser) +func (iLifiInputSettler *ILifiInputSettler) UnpackPurchasedOrders(data []byte) (PurchasedOrdersOutput, error) { + out, err := iLifiInputSettler.abi.Unpack("purchasedOrders", data) + outstruct := new(PurchasedOrdersOutput) + if err != nil { + return *outstruct, err + } + outstruct.LastOrderTimestamp = *abi.ConvertType(out[0], new(uint32)).(*uint32) + outstruct.Purchaser = *abi.ConvertType(out[1], new([32]byte)).(*[32]byte) + return *outstruct, nil +} + +// PackRefund is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x48f49eaf. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function refund((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order) returns() +func (iLifiInputSettler *ILifiInputSettler) PackRefund(order StandardOrder) []byte { + enc, err := iLifiInputSettler.abi.Pack("refund", order) + if err != nil { + panic(err) + } + return enc +} + +// TryPackRefund is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x48f49eaf. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function refund((address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order) returns() +func (iLifiInputSettler *ILifiInputSettler) TryPackRefund(order StandardOrder) ([]byte, error) { + return iLifiInputSettler.abi.Pack("refund", order) +} + +// 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() payable returns() +func (iLifiInputSettler *ILifiInputSettler) PackRenounceOwnership() []byte { + enc, err := iLifiInputSettler.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() payable returns() +func (iLifiInputSettler *ILifiInputSettler) TryPackRenounceOwnership() ([]byte, error) { + return iLifiInputSettler.abi.Pack("renounceOwnership") +} + +// PackRequestOwnershipHandover is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x25692962. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function requestOwnershipHandover() payable returns() +func (iLifiInputSettler *ILifiInputSettler) PackRequestOwnershipHandover() []byte { + enc, err := iLifiInputSettler.abi.Pack("requestOwnershipHandover") + if err != nil { + panic(err) + } + return enc +} + +// TryPackRequestOwnershipHandover is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x25692962. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function requestOwnershipHandover() payable returns() +func (iLifiInputSettler *ILifiInputSettler) TryPackRequestOwnershipHandover() ([]byte, error) { + return iLifiInputSettler.abi.Pack("requestOwnershipHandover") +} + +// PackSetGovernanceFee is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x586f9800. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function setGovernanceFee(uint64 _nextGovernanceFee) returns() +func (iLifiInputSettler *ILifiInputSettler) PackSetGovernanceFee(nextGovernanceFee uint64) []byte { + enc, err := iLifiInputSettler.abi.Pack("setGovernanceFee", nextGovernanceFee) + if err != nil { + panic(err) + } + return enc +} + +// TryPackSetGovernanceFee is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x586f9800. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function setGovernanceFee(uint64 _nextGovernanceFee) returns() +func (iLifiInputSettler *ILifiInputSettler) TryPackSetGovernanceFee(nextGovernanceFee uint64) ([]byte, error) { + return iLifiInputSettler.abi.Pack("setGovernanceFee", nextGovernanceFee) +} + +// 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) payable returns() +func (iLifiInputSettler *ILifiInputSettler) PackTransferOwnership(newOwner common.Address) []byte { + enc, err := iLifiInputSettler.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) payable returns() +func (iLifiInputSettler *ILifiInputSettler) TryPackTransferOwnership(newOwner common.Address) ([]byte, error) { + return iLifiInputSettler.abi.Pack("transferOwnership", newOwner) +} + +// ILifiInputSettlerEIP712DomainChanged represents a EIP712DomainChanged event raised by the ILifiInputSettler contract. +type ILifiInputSettlerEIP712DomainChanged struct { + Raw *types.Log // Blockchain specific contextual infos +} + +const ILifiInputSettlerEIP712DomainChangedEventName = "EIP712DomainChanged" + +// ContractEventName returns the user-defined event name. +func (ILifiInputSettlerEIP712DomainChanged) ContractEventName() string { + return ILifiInputSettlerEIP712DomainChangedEventName +} + +// UnpackEIP712DomainChangedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event EIP712DomainChanged() +func (iLifiInputSettler *ILifiInputSettler) UnpackEIP712DomainChangedEvent(log *types.Log) (*ILifiInputSettlerEIP712DomainChanged, error) { + event := "EIP712DomainChanged" + if log.Topics[0] != iLifiInputSettler.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ILifiInputSettlerEIP712DomainChanged) + if len(log.Data) > 0 { + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iLifiInputSettler.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 +} + +// ILifiInputSettlerFinalised represents a Finalised event raised by the ILifiInputSettler contract. +type ILifiInputSettlerFinalised struct { + OrderId [32]byte + Solver [32]byte + Destination [32]byte + Raw *types.Log // Blockchain specific contextual infos +} + +const ILifiInputSettlerFinalisedEventName = "Finalised" + +// ContractEventName returns the user-defined event name. +func (ILifiInputSettlerFinalised) ContractEventName() string { + return ILifiInputSettlerFinalisedEventName +} + +// UnpackFinalisedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event Finalised(bytes32 indexed orderId, bytes32 solver, bytes32 destination) +func (iLifiInputSettler *ILifiInputSettler) UnpackFinalisedEvent(log *types.Log) (*ILifiInputSettlerFinalised, error) { + event := "Finalised" + if log.Topics[0] != iLifiInputSettler.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ILifiInputSettlerFinalised) + if len(log.Data) > 0 { + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iLifiInputSettler.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 +} + +// ILifiInputSettlerGovernanceFeeChanged represents a GovernanceFeeChanged event raised by the ILifiInputSettler contract. +type ILifiInputSettlerGovernanceFeeChanged struct { + OldGovernanceFee uint64 + NewGovernanceFee uint64 + Raw *types.Log // Blockchain specific contextual infos +} + +const ILifiInputSettlerGovernanceFeeChangedEventName = "GovernanceFeeChanged" + +// ContractEventName returns the user-defined event name. +func (ILifiInputSettlerGovernanceFeeChanged) ContractEventName() string { + return ILifiInputSettlerGovernanceFeeChangedEventName +} + +// UnpackGovernanceFeeChangedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event GovernanceFeeChanged(uint64 oldGovernanceFee, uint64 newGovernanceFee) +func (iLifiInputSettler *ILifiInputSettler) UnpackGovernanceFeeChangedEvent(log *types.Log) (*ILifiInputSettlerGovernanceFeeChanged, error) { + event := "GovernanceFeeChanged" + if log.Topics[0] != iLifiInputSettler.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ILifiInputSettlerGovernanceFeeChanged) + if len(log.Data) > 0 { + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iLifiInputSettler.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 +} + +// ILifiInputSettlerNextGovernanceFee represents a NextGovernanceFee event raised by the ILifiInputSettler contract. +type ILifiInputSettlerNextGovernanceFee struct { + NextGovernanceFee uint64 + NextGovernanceFeeTime uint64 + Raw *types.Log // Blockchain specific contextual infos +} + +const ILifiInputSettlerNextGovernanceFeeEventName = "NextGovernanceFee" + +// ContractEventName returns the user-defined event name. +func (ILifiInputSettlerNextGovernanceFee) ContractEventName() string { + return ILifiInputSettlerNextGovernanceFeeEventName +} + +// UnpackNextGovernanceFeeEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event NextGovernanceFee(uint64 nextGovernanceFee, uint64 nextGovernanceFeeTime) +func (iLifiInputSettler *ILifiInputSettler) UnpackNextGovernanceFeeEvent(log *types.Log) (*ILifiInputSettlerNextGovernanceFee, error) { + event := "NextGovernanceFee" + if log.Topics[0] != iLifiInputSettler.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ILifiInputSettlerNextGovernanceFee) + if len(log.Data) > 0 { + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iLifiInputSettler.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 +} + +// ILifiInputSettlerOpen represents a Open event raised by the ILifiInputSettler contract. +type ILifiInputSettlerOpen struct { + OrderId [32]byte + Raw *types.Log // Blockchain specific contextual infos +} + +const ILifiInputSettlerOpenEventName = "Open" + +// ContractEventName returns the user-defined event name. +func (ILifiInputSettlerOpen) ContractEventName() string { + return ILifiInputSettlerOpenEventName +} + +// UnpackOpenEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event Open(bytes32 indexed orderId) +func (iLifiInputSettler *ILifiInputSettler) UnpackOpenEvent(log *types.Log) (*ILifiInputSettlerOpen, error) { + event := "Open" + if log.Topics[0] != iLifiInputSettler.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ILifiInputSettlerOpen) + if len(log.Data) > 0 { + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iLifiInputSettler.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 +} + +// ILifiInputSettlerOpen0 represents a Open0 event raised by the ILifiInputSettler contract. +type ILifiInputSettlerOpen0 struct { + OrderId [32]byte + Order StandardOrder + Raw *types.Log // Blockchain specific contextual infos +} + +const ILifiInputSettlerOpen0EventName = "Open0" + +// ContractEventName returns the user-defined event name. +func (ILifiInputSettlerOpen0) ContractEventName() string { + return ILifiInputSettlerOpen0EventName +} + +// UnpackOpen0Event is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event Open(bytes32 indexed orderId, (address,uint256,uint256,uint32,uint32,address,uint256[2][],(bytes32,bytes32,uint256,bytes32,uint256,bytes32,bytes,bytes)[]) order) +func (iLifiInputSettler *ILifiInputSettler) UnpackOpen0Event(log *types.Log) (*ILifiInputSettlerOpen0, error) { + event := "Open0" + if log.Topics[0] != iLifiInputSettler.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ILifiInputSettlerOpen0) + if len(log.Data) > 0 { + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iLifiInputSettler.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 +} + +// ILifiInputSettlerOrderPurchased represents a OrderPurchased event raised by the ILifiInputSettler contract. +type ILifiInputSettlerOrderPurchased struct { + OrderId [32]byte + Solver [32]byte + Purchaser [32]byte + Raw *types.Log // Blockchain specific contextual infos +} + +const ILifiInputSettlerOrderPurchasedEventName = "OrderPurchased" + +// ContractEventName returns the user-defined event name. +func (ILifiInputSettlerOrderPurchased) ContractEventName() string { + return ILifiInputSettlerOrderPurchasedEventName +} + +// UnpackOrderPurchasedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event OrderPurchased(bytes32 indexed orderId, bytes32 solver, bytes32 purchaser) +func (iLifiInputSettler *ILifiInputSettler) UnpackOrderPurchasedEvent(log *types.Log) (*ILifiInputSettlerOrderPurchased, error) { + event := "OrderPurchased" + if log.Topics[0] != iLifiInputSettler.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ILifiInputSettlerOrderPurchased) + if len(log.Data) > 0 { + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iLifiInputSettler.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 +} + +// ILifiInputSettlerOwnershipHandoverCanceled represents a OwnershipHandoverCanceled event raised by the ILifiInputSettler contract. +type ILifiInputSettlerOwnershipHandoverCanceled struct { + PendingOwner common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const ILifiInputSettlerOwnershipHandoverCanceledEventName = "OwnershipHandoverCanceled" + +// ContractEventName returns the user-defined event name. +func (ILifiInputSettlerOwnershipHandoverCanceled) ContractEventName() string { + return ILifiInputSettlerOwnershipHandoverCanceledEventName +} + +// UnpackOwnershipHandoverCanceledEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event OwnershipHandoverCanceled(address indexed pendingOwner) +func (iLifiInputSettler *ILifiInputSettler) UnpackOwnershipHandoverCanceledEvent(log *types.Log) (*ILifiInputSettlerOwnershipHandoverCanceled, error) { + event := "OwnershipHandoverCanceled" + if log.Topics[0] != iLifiInputSettler.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ILifiInputSettlerOwnershipHandoverCanceled) + if len(log.Data) > 0 { + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iLifiInputSettler.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 +} + +// ILifiInputSettlerOwnershipHandoverRequested represents a OwnershipHandoverRequested event raised by the ILifiInputSettler contract. +type ILifiInputSettlerOwnershipHandoverRequested struct { + PendingOwner common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const ILifiInputSettlerOwnershipHandoverRequestedEventName = "OwnershipHandoverRequested" + +// ContractEventName returns the user-defined event name. +func (ILifiInputSettlerOwnershipHandoverRequested) ContractEventName() string { + return ILifiInputSettlerOwnershipHandoverRequestedEventName +} + +// UnpackOwnershipHandoverRequestedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event OwnershipHandoverRequested(address indexed pendingOwner) +func (iLifiInputSettler *ILifiInputSettler) UnpackOwnershipHandoverRequestedEvent(log *types.Log) (*ILifiInputSettlerOwnershipHandoverRequested, error) { + event := "OwnershipHandoverRequested" + if log.Topics[0] != iLifiInputSettler.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ILifiInputSettlerOwnershipHandoverRequested) + if len(log.Data) > 0 { + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iLifiInputSettler.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 +} + +// ILifiInputSettlerOwnershipTransferred represents a OwnershipTransferred event raised by the ILifiInputSettler contract. +type ILifiInputSettlerOwnershipTransferred struct { + OldOwner common.Address + NewOwner common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const ILifiInputSettlerOwnershipTransferredEventName = "OwnershipTransferred" + +// ContractEventName returns the user-defined event name. +func (ILifiInputSettlerOwnershipTransferred) ContractEventName() string { + return ILifiInputSettlerOwnershipTransferredEventName +} + +// UnpackOwnershipTransferredEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event OwnershipTransferred(address indexed oldOwner, address indexed newOwner) +func (iLifiInputSettler *ILifiInputSettler) UnpackOwnershipTransferredEvent(log *types.Log) (*ILifiInputSettlerOwnershipTransferred, error) { + event := "OwnershipTransferred" + if log.Topics[0] != iLifiInputSettler.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ILifiInputSettlerOwnershipTransferred) + if len(log.Data) > 0 { + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iLifiInputSettler.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 +} + +// ILifiInputSettlerRefunded represents a Refunded event raised by the ILifiInputSettler contract. +type ILifiInputSettlerRefunded struct { + OrderId [32]byte + Raw *types.Log // Blockchain specific contextual infos +} + +const ILifiInputSettlerRefundedEventName = "Refunded" + +// ContractEventName returns the user-defined event name. +func (ILifiInputSettlerRefunded) ContractEventName() string { + return ILifiInputSettlerRefundedEventName +} + +// UnpackRefundedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event Refunded(bytes32 indexed orderId) +func (iLifiInputSettler *ILifiInputSettler) UnpackRefundedEvent(log *types.Log) (*ILifiInputSettlerRefunded, error) { + event := "Refunded" + if log.Topics[0] != iLifiInputSettler.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(ILifiInputSettlerRefunded) + if len(log.Data) > 0 { + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range iLifiInputSettler.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 (iLifiInputSettler *ILifiInputSettler) UnpackError(raw []byte) (any, error) { + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["AlreadyInitialized"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackAlreadyInitializedError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["AlreadyPurchased"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackAlreadyPurchasedError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["CallOutOfRange"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackCallOutOfRangeError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["CodeSize0"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackCodeSize0Error(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["ContextOutOfRange"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackContextOutOfRangeError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["Expired"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackExpiredError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["FillDeadlineAfterExpiry"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackFillDeadlineAfterExpiryError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["FilledTooLate"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackFilledTooLateError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["GovernanceFeeChangeNotReady"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackGovernanceFeeChangeNotReadyError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["GovernanceFeeTooHigh"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackGovernanceFeeTooHighError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["HasDirtyBits"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackHasDirtyBitsError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["InvalidOrderStatus"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackInvalidOrderStatusError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["InvalidPurchaser"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackInvalidPurchaserError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["InvalidShortString"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackInvalidShortStringError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["InvalidSigner"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackInvalidSignerError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["InvalidTimestampLength"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackInvalidTimestampLengthError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["NewOwnerIsZeroAddress"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackNewOwnerIsZeroAddressError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["NoDestination"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackNoDestinationError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["NoHandoverRequest"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackNoHandoverRequestError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["NotOrderOwner"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackNotOrderOwnerError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["OrderIdMismatch"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackOrderIdMismatchError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["ReentrancyDetected"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackReentrancyDetectedError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["SafeERC20FailedOperation"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackSafeERC20FailedOperationError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["SignatureAndInputsNotEqual"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackSignatureAndInputsNotEqualError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["SignatureNotSupported"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackSignatureNotSupportedError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["StringTooLong"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackStringTooLongError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["TimestampNotPassed"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackTimestampNotPassedError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["TimestampPassed"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackTimestampPassedError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["Unauthorized"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackUnauthorizedError(raw[4:]) + } + if bytes.Equal(raw[:4], iLifiInputSettler.abi.Errors["WrongChain"].ID.Bytes()[:4]) { + return iLifiInputSettler.UnpackWrongChainError(raw[4:]) + } + return nil, errors.New("Unknown error") +} + +// ILifiInputSettlerAlreadyInitialized represents a AlreadyInitialized error raised by the ILifiInputSettler contract. +type ILifiInputSettlerAlreadyInitialized struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error AlreadyInitialized() +func ILifiInputSettlerAlreadyInitializedErrorID() 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 (iLifiInputSettler *ILifiInputSettler) UnpackAlreadyInitializedError(raw []byte) (*ILifiInputSettlerAlreadyInitialized, error) { + out := new(ILifiInputSettlerAlreadyInitialized) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "AlreadyInitialized", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerAlreadyPurchased represents a AlreadyPurchased error raised by the ILifiInputSettler contract. +type ILifiInputSettlerAlreadyPurchased struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error AlreadyPurchased() +func ILifiInputSettlerAlreadyPurchasedErrorID() common.Hash { + return common.HexToHash("0x3367b554dccf0f6b7e731388e7b58cf6b61aa57a5d2d9b20798abf1e9a9eb9d9") +} + +// UnpackAlreadyPurchasedError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error AlreadyPurchased() +func (iLifiInputSettler *ILifiInputSettler) UnpackAlreadyPurchasedError(raw []byte) (*ILifiInputSettlerAlreadyPurchased, error) { + out := new(ILifiInputSettlerAlreadyPurchased) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "AlreadyPurchased", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerCallOutOfRange represents a CallOutOfRange error raised by the ILifiInputSettler contract. +type ILifiInputSettlerCallOutOfRange struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error CallOutOfRange() +func ILifiInputSettlerCallOutOfRangeErrorID() common.Hash { + return common.HexToHash("0x4fe9ad238b0efcfdcc07e41ff080de6477c45b0a2b23e6a1710bf7a4561340e9") +} + +// UnpackCallOutOfRangeError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error CallOutOfRange() +func (iLifiInputSettler *ILifiInputSettler) UnpackCallOutOfRangeError(raw []byte) (*ILifiInputSettlerCallOutOfRange, error) { + out := new(ILifiInputSettlerCallOutOfRange) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "CallOutOfRange", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerCodeSize0 represents a CodeSize0 error raised by the ILifiInputSettler contract. +type ILifiInputSettlerCodeSize0 struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error CodeSize0() +func ILifiInputSettlerCodeSize0ErrorID() common.Hash { + return common.HexToHash("0xfbc1d8e2c3f2772770ee2062b2b56e4b23e4e91332347f7656f0f8aafbb9cb0c") +} + +// UnpackCodeSize0Error is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error CodeSize0() +func (iLifiInputSettler *ILifiInputSettler) UnpackCodeSize0Error(raw []byte) (*ILifiInputSettlerCodeSize0, error) { + out := new(ILifiInputSettlerCodeSize0) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "CodeSize0", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerContextOutOfRange represents a ContextOutOfRange error raised by the ILifiInputSettler contract. +type ILifiInputSettlerContextOutOfRange struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error ContextOutOfRange() +func ILifiInputSettlerContextOutOfRangeErrorID() common.Hash { + return common.HexToHash("0xd94d6ce6aedb93cc32ffa64d0fd16f10262e85593f5410fe9a0c38743fb09af7") +} + +// UnpackContextOutOfRangeError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error ContextOutOfRange() +func (iLifiInputSettler *ILifiInputSettler) UnpackContextOutOfRangeError(raw []byte) (*ILifiInputSettlerContextOutOfRange, error) { + out := new(ILifiInputSettlerContextOutOfRange) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "ContextOutOfRange", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerExpired represents a Expired error raised by the ILifiInputSettler contract. +type ILifiInputSettlerExpired struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error Expired() +func ILifiInputSettlerExpiredErrorID() common.Hash { + return common.HexToHash("0x203d82d8d99f63bfecc8335216735e0271df4249ea752b030f9ab305b94e5afe") +} + +// UnpackExpiredError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error Expired() +func (iLifiInputSettler *ILifiInputSettler) UnpackExpiredError(raw []byte) (*ILifiInputSettlerExpired, error) { + out := new(ILifiInputSettlerExpired) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "Expired", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerFillDeadlineAfterExpiry represents a FillDeadlineAfterExpiry error raised by the ILifiInputSettler contract. +type ILifiInputSettlerFillDeadlineAfterExpiry struct { + FillDeadline uint32 + Expires uint32 +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error FillDeadlineAfterExpiry(uint32 fillDeadline, uint32 expires) +func ILifiInputSettlerFillDeadlineAfterExpiryErrorID() common.Hash { + return common.HexToHash("0xf31549efc20c86d21b99f1bedbff489d9bf9d83f68b5771ceeb0cadd440a8415") +} + +// UnpackFillDeadlineAfterExpiryError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error FillDeadlineAfterExpiry(uint32 fillDeadline, uint32 expires) +func (iLifiInputSettler *ILifiInputSettler) UnpackFillDeadlineAfterExpiryError(raw []byte) (*ILifiInputSettlerFillDeadlineAfterExpiry, error) { + out := new(ILifiInputSettlerFillDeadlineAfterExpiry) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "FillDeadlineAfterExpiry", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerFilledTooLate represents a FilledTooLate error raised by the ILifiInputSettler contract. +type ILifiInputSettlerFilledTooLate struct { + Expected uint32 + Actual uint32 +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error FilledTooLate(uint32 expected, uint32 actual) +func ILifiInputSettlerFilledTooLateErrorID() common.Hash { + return common.HexToHash("0x0ad67c09a1e19240ccd1a72ebab6667d70cc8087485302bc38f12238e5e9d074") +} + +// UnpackFilledTooLateError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error FilledTooLate(uint32 expected, uint32 actual) +func (iLifiInputSettler *ILifiInputSettler) UnpackFilledTooLateError(raw []byte) (*ILifiInputSettlerFilledTooLate, error) { + out := new(ILifiInputSettlerFilledTooLate) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "FilledTooLate", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerGovernanceFeeChangeNotReady represents a GovernanceFeeChangeNotReady error raised by the ILifiInputSettler contract. +type ILifiInputSettlerGovernanceFeeChangeNotReady struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error GovernanceFeeChangeNotReady() +func ILifiInputSettlerGovernanceFeeChangeNotReadyErrorID() common.Hash { + return common.HexToHash("0x6f4cfed1c34a227615bf9d3fb4f3149b79498b8ff3c30e5c7dba10fc2c31e408") +} + +// UnpackGovernanceFeeChangeNotReadyError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error GovernanceFeeChangeNotReady() +func (iLifiInputSettler *ILifiInputSettler) UnpackGovernanceFeeChangeNotReadyError(raw []byte) (*ILifiInputSettlerGovernanceFeeChangeNotReady, error) { + out := new(ILifiInputSettlerGovernanceFeeChangeNotReady) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "GovernanceFeeChangeNotReady", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerGovernanceFeeTooHigh represents a GovernanceFeeTooHigh error raised by the ILifiInputSettler contract. +type ILifiInputSettlerGovernanceFeeTooHigh struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error GovernanceFeeTooHigh() +func ILifiInputSettlerGovernanceFeeTooHighErrorID() common.Hash { + return common.HexToHash("0x0f4820d8a6b3e19893860b79e29977fda9aa6ef4b2e1a7d09c8e8955b69be56c") +} + +// UnpackGovernanceFeeTooHighError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error GovernanceFeeTooHigh() +func (iLifiInputSettler *ILifiInputSettler) UnpackGovernanceFeeTooHighError(raw []byte) (*ILifiInputSettlerGovernanceFeeTooHigh, error) { + out := new(ILifiInputSettlerGovernanceFeeTooHigh) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "GovernanceFeeTooHigh", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerHasDirtyBits represents a HasDirtyBits error raised by the ILifiInputSettler contract. +type ILifiInputSettlerHasDirtyBits struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error HasDirtyBits() +func ILifiInputSettlerHasDirtyBitsErrorID() common.Hash { + return common.HexToHash("0x5f3d6d4f57bdccabacd05058457a7e7ae88d95331a81a9def1d147b62fdf9eab") +} + +// UnpackHasDirtyBitsError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error HasDirtyBits() +func (iLifiInputSettler *ILifiInputSettler) UnpackHasDirtyBitsError(raw []byte) (*ILifiInputSettlerHasDirtyBits, error) { + out := new(ILifiInputSettlerHasDirtyBits) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "HasDirtyBits", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerInvalidOrderStatus represents a InvalidOrderStatus error raised by the ILifiInputSettler contract. +type ILifiInputSettlerInvalidOrderStatus struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InvalidOrderStatus() +func ILifiInputSettlerInvalidOrderStatusErrorID() common.Hash { + return common.HexToHash("0x2916ae33cf4ed00872aaf269c86d13a12e9ad47f836db89ea191297fecc7a2e7") +} + +// UnpackInvalidOrderStatusError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InvalidOrderStatus() +func (iLifiInputSettler *ILifiInputSettler) UnpackInvalidOrderStatusError(raw []byte) (*ILifiInputSettlerInvalidOrderStatus, error) { + out := new(ILifiInputSettlerInvalidOrderStatus) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "InvalidOrderStatus", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerInvalidPurchaser represents a InvalidPurchaser error raised by the ILifiInputSettler contract. +type ILifiInputSettlerInvalidPurchaser struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InvalidPurchaser() +func ILifiInputSettlerInvalidPurchaserErrorID() common.Hash { + return common.HexToHash("0xcf7899a1ea308d1129fe0e01fbd4fdca283f8c93391f8c697f69a9b2d02d339e") +} + +// UnpackInvalidPurchaserError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InvalidPurchaser() +func (iLifiInputSettler *ILifiInputSettler) UnpackInvalidPurchaserError(raw []byte) (*ILifiInputSettlerInvalidPurchaser, error) { + out := new(ILifiInputSettlerInvalidPurchaser) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "InvalidPurchaser", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerInvalidShortString represents a InvalidShortString error raised by the ILifiInputSettler contract. +type ILifiInputSettlerInvalidShortString struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InvalidShortString() +func ILifiInputSettlerInvalidShortStringErrorID() common.Hash { + return common.HexToHash("0xb3512b0c6163e5f0bafab72bb631b9d58cd7a731b082f910338aa21c83d5c274") +} + +// UnpackInvalidShortStringError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InvalidShortString() +func (iLifiInputSettler *ILifiInputSettler) UnpackInvalidShortStringError(raw []byte) (*ILifiInputSettlerInvalidShortString, error) { + out := new(ILifiInputSettlerInvalidShortString) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "InvalidShortString", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerInvalidSigner represents a InvalidSigner error raised by the ILifiInputSettler contract. +type ILifiInputSettlerInvalidSigner struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InvalidSigner() +func ILifiInputSettlerInvalidSignerErrorID() common.Hash { + return common.HexToHash("0x815e1d64efb74fbe314c20a2b8a2335d18bce12a19165e447fa36bcb35959528") +} + +// UnpackInvalidSignerError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InvalidSigner() +func (iLifiInputSettler *ILifiInputSettler) UnpackInvalidSignerError(raw []byte) (*ILifiInputSettlerInvalidSigner, error) { + out := new(ILifiInputSettlerInvalidSigner) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "InvalidSigner", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerInvalidTimestampLength represents a InvalidTimestampLength error raised by the ILifiInputSettler contract. +type ILifiInputSettlerInvalidTimestampLength struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InvalidTimestampLength() +func ILifiInputSettlerInvalidTimestampLengthErrorID() common.Hash { + return common.HexToHash("0x12d486097b64be32f9dcb600781aa0b64747f2a80f9865544107941f4921cea0") +} + +// UnpackInvalidTimestampLengthError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InvalidTimestampLength() +func (iLifiInputSettler *ILifiInputSettler) UnpackInvalidTimestampLengthError(raw []byte) (*ILifiInputSettlerInvalidTimestampLength, error) { + out := new(ILifiInputSettlerInvalidTimestampLength) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "InvalidTimestampLength", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerNewOwnerIsZeroAddress represents a NewOwnerIsZeroAddress error raised by the ILifiInputSettler contract. +type ILifiInputSettlerNewOwnerIsZeroAddress struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NewOwnerIsZeroAddress() +func ILifiInputSettlerNewOwnerIsZeroAddressErrorID() common.Hash { + return common.HexToHash("0x7448fbae245b5163a637f61fac94c5376c3e155928452ce47ee52d8c1b99587a") +} + +// UnpackNewOwnerIsZeroAddressError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NewOwnerIsZeroAddress() +func (iLifiInputSettler *ILifiInputSettler) UnpackNewOwnerIsZeroAddressError(raw []byte) (*ILifiInputSettlerNewOwnerIsZeroAddress, error) { + out := new(ILifiInputSettlerNewOwnerIsZeroAddress) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "NewOwnerIsZeroAddress", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerNoDestination represents a NoDestination error raised by the ILifiInputSettler contract. +type ILifiInputSettlerNoDestination struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NoDestination() +func ILifiInputSettlerNoDestinationErrorID() common.Hash { + return common.HexToHash("0xb8e78e8013c2b18060a5e1d1d47e7c487b3f4c9e26fe84ba199887e6c88abda5") +} + +// UnpackNoDestinationError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NoDestination() +func (iLifiInputSettler *ILifiInputSettler) UnpackNoDestinationError(raw []byte) (*ILifiInputSettlerNoDestination, error) { + out := new(ILifiInputSettlerNoDestination) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "NoDestination", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerNoHandoverRequest represents a NoHandoverRequest error raised by the ILifiInputSettler contract. +type ILifiInputSettlerNoHandoverRequest struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NoHandoverRequest() +func ILifiInputSettlerNoHandoverRequestErrorID() common.Hash { + return common.HexToHash("0x6f5e8818469c73d5be4a0d17c371cde64695907022629c1d064c895f98d466a6") +} + +// UnpackNoHandoverRequestError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NoHandoverRequest() +func (iLifiInputSettler *ILifiInputSettler) UnpackNoHandoverRequestError(raw []byte) (*ILifiInputSettlerNoHandoverRequest, error) { + out := new(ILifiInputSettlerNoHandoverRequest) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "NoHandoverRequest", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerNotOrderOwner represents a NotOrderOwner error raised by the ILifiInputSettler contract. +type ILifiInputSettlerNotOrderOwner struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotOrderOwner() +func ILifiInputSettlerNotOrderOwnerErrorID() common.Hash { + return common.HexToHash("0xf6412b5a9f98f861af79c1937e4ad40c98a45a023657259dd5775a8de7ecca15") +} + +// UnpackNotOrderOwnerError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotOrderOwner() +func (iLifiInputSettler *ILifiInputSettler) UnpackNotOrderOwnerError(raw []byte) (*ILifiInputSettlerNotOrderOwner, error) { + out := new(ILifiInputSettlerNotOrderOwner) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "NotOrderOwner", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerOrderIdMismatch represents a OrderIdMismatch error raised by the ILifiInputSettler contract. +type ILifiInputSettlerOrderIdMismatch struct { + Provided [32]byte + Computed [32]byte +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error OrderIdMismatch(bytes32 provided, bytes32 computed) +func ILifiInputSettlerOrderIdMismatchErrorID() common.Hash { + return common.HexToHash("0x0517adf9c87f4f5cb24c4c43e313f684b98703db5b126fdd4f5ac47cc02267d5") +} + +// UnpackOrderIdMismatchError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error OrderIdMismatch(bytes32 provided, bytes32 computed) +func (iLifiInputSettler *ILifiInputSettler) UnpackOrderIdMismatchError(raw []byte) (*ILifiInputSettlerOrderIdMismatch, error) { + out := new(ILifiInputSettlerOrderIdMismatch) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "OrderIdMismatch", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerReentrancyDetected represents a ReentrancyDetected error raised by the ILifiInputSettler contract. +type ILifiInputSettlerReentrancyDetected struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error ReentrancyDetected() +func ILifiInputSettlerReentrancyDetectedErrorID() common.Hash { + return common.HexToHash("0xc5f2be51ec4ec0ad8a7972d497da993a6fcbb89cf72c05f97d654ed81ce53492") +} + +// UnpackReentrancyDetectedError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error ReentrancyDetected() +func (iLifiInputSettler *ILifiInputSettler) UnpackReentrancyDetectedError(raw []byte) (*ILifiInputSettlerReentrancyDetected, error) { + out := new(ILifiInputSettlerReentrancyDetected) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "ReentrancyDetected", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerSafeERC20FailedOperation represents a SafeERC20FailedOperation error raised by the ILifiInputSettler contract. +type ILifiInputSettlerSafeERC20FailedOperation struct { + Token common.Address +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error SafeERC20FailedOperation(address token) +func ILifiInputSettlerSafeERC20FailedOperationErrorID() 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 (iLifiInputSettler *ILifiInputSettler) UnpackSafeERC20FailedOperationError(raw []byte) (*ILifiInputSettlerSafeERC20FailedOperation, error) { + out := new(ILifiInputSettlerSafeERC20FailedOperation) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "SafeERC20FailedOperation", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerSignatureAndInputsNotEqual represents a SignatureAndInputsNotEqual error raised by the ILifiInputSettler contract. +type ILifiInputSettlerSignatureAndInputsNotEqual struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error SignatureAndInputsNotEqual() +func ILifiInputSettlerSignatureAndInputsNotEqualErrorID() common.Hash { + return common.HexToHash("0x06f68b62ffd2436fe64050449d9d38b1823a747a993c088a72845ae5994b9883") +} + +// UnpackSignatureAndInputsNotEqualError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error SignatureAndInputsNotEqual() +func (iLifiInputSettler *ILifiInputSettler) UnpackSignatureAndInputsNotEqualError(raw []byte) (*ILifiInputSettlerSignatureAndInputsNotEqual, error) { + out := new(ILifiInputSettlerSignatureAndInputsNotEqual) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "SignatureAndInputsNotEqual", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerSignatureNotSupported represents a SignatureNotSupported error raised by the ILifiInputSettler contract. +type ILifiInputSettlerSignatureNotSupported struct { + Arg0 [1]byte +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error SignatureNotSupported(bytes1 arg0) +func ILifiInputSettlerSignatureNotSupportedErrorID() common.Hash { + return common.HexToHash("0x5d0b6f18a8b247272db8eeca2dbe086a9850e7bfd217b19bd636e0a15fbd7861") +} + +// UnpackSignatureNotSupportedError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error SignatureNotSupported(bytes1 arg0) +func (iLifiInputSettler *ILifiInputSettler) UnpackSignatureNotSupportedError(raw []byte) (*ILifiInputSettlerSignatureNotSupported, error) { + out := new(ILifiInputSettlerSignatureNotSupported) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "SignatureNotSupported", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerStringTooLong represents a StringTooLong error raised by the ILifiInputSettler contract. +type ILifiInputSettlerStringTooLong struct { + Str string +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error StringTooLong(string str) +func ILifiInputSettlerStringTooLongErrorID() common.Hash { + return common.HexToHash("0x305a27a93f8e33b7392df0a0f91d6fc63847395853c45991eec52dbf24d72381") +} + +// UnpackStringTooLongError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error StringTooLong(string str) +func (iLifiInputSettler *ILifiInputSettler) UnpackStringTooLongError(raw []byte) (*ILifiInputSettlerStringTooLong, error) { + out := new(ILifiInputSettlerStringTooLong) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "StringTooLong", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerTimestampNotPassed represents a TimestampNotPassed error raised by the ILifiInputSettler contract. +type ILifiInputSettlerTimestampNotPassed struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error TimestampNotPassed() +func ILifiInputSettlerTimestampNotPassedErrorID() common.Hash { + return common.HexToHash("0xeb21afbdfbff45b8884b33197c58f4fdd57aeee3ef678ac1e61248dc84fa5ac0") +} + +// UnpackTimestampNotPassedError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error TimestampNotPassed() +func (iLifiInputSettler *ILifiInputSettler) UnpackTimestampNotPassedError(raw []byte) (*ILifiInputSettlerTimestampNotPassed, error) { + out := new(ILifiInputSettlerTimestampNotPassed) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "TimestampNotPassed", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerTimestampPassed represents a TimestampPassed error raised by the ILifiInputSettler contract. +type ILifiInputSettlerTimestampPassed struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error TimestampPassed() +func ILifiInputSettlerTimestampPassedErrorID() common.Hash { + return common.HexToHash("0x4a313c2dac3291054a75303df1d71c904dff49517ea33f42a82307b8ddca441a") +} + +// UnpackTimestampPassedError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error TimestampPassed() +func (iLifiInputSettler *ILifiInputSettler) UnpackTimestampPassedError(raw []byte) (*ILifiInputSettlerTimestampPassed, error) { + out := new(ILifiInputSettlerTimestampPassed) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "TimestampPassed", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerUnauthorized represents a Unauthorized error raised by the ILifiInputSettler contract. +type ILifiInputSettlerUnauthorized struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error Unauthorized() +func ILifiInputSettlerUnauthorizedErrorID() common.Hash { + return common.HexToHash("0x82b4290015f7ec7256ca2a6247d3c2a89c4865c0e791456df195f40ad0a81367") +} + +// UnpackUnauthorizedError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error Unauthorized() +func (iLifiInputSettler *ILifiInputSettler) UnpackUnauthorizedError(raw []byte) (*ILifiInputSettlerUnauthorized, error) { + out := new(ILifiInputSettlerUnauthorized) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "Unauthorized", raw); err != nil { + return nil, err + } + return out, nil +} + +// ILifiInputSettlerWrongChain represents a WrongChain error raised by the ILifiInputSettler contract. +type ILifiInputSettlerWrongChain struct { + Expected *big.Int + Actual *big.Int +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error WrongChain(uint256 expected, uint256 actual) +func ILifiInputSettlerWrongChainErrorID() common.Hash { + return common.HexToHash("0x24497bc308635bccbc06f4997297d2158da178be2267eeead419bfdb19d42d4b") +} + +// UnpackWrongChainError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error WrongChain(uint256 expected, uint256 actual) +func (iLifiInputSettler *ILifiInputSettler) UnpackWrongChainError(raw []byte) (*ILifiInputSettlerWrongChain, error) { + out := new(ILifiInputSettlerWrongChain) + if err := iLifiInputSettler.abi.UnpackIntoInterface(out, "WrongChain", raw); err != nil { + return nil, err + } + return out, nil +} diff --git a/api/bindings/uniswapx/executor/LiquidLaneUniswapXExecutor.go b/api/bindings/uniswapx/executor/LiquidLaneUniswapXExecutor.go new file mode 100644 index 00000000..bb596a65 --- /dev/null +++ b/api/bindings/uniswapx/executor/LiquidLaneUniswapXExecutor.go @@ -0,0 +1,711 @@ +// 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 +) + +// ILiquidLaneAdapterDiscount is an auto generated low-level Go binding around an user-defined struct. +type ILiquidLaneAdapterDiscount struct { + TokenToRedeem common.Address + Discount *big.Int + Signer common.Address + Protocol common.Address + Nonce *big.Int + Deadline *big.Int +} + +// ILiquidLaneAdapterDiscountSwap is an auto generated low-level Go binding around an user-defined struct. +type ILiquidLaneAdapterDiscountSwap struct { + Discount ILiquidLaneAdapterDiscount + SignerSignature []byte + ProtocolDeadline *big.Int +} + +// ILiquidLaneUniswapXExecutorDiscountRoute is an auto generated low-level Go binding around an user-defined struct. +type ILiquidLaneUniswapXExecutorDiscountRoute struct { + Adapter common.Address + AmountIn *big.Int + DiscountSwap ILiquidLaneAdapterDiscountSwap + ProtocolSignature []byte +} + +// ILiquidLaneUniswapXExecutorFillCall is an auto generated low-level Go binding around an user-defined struct. +type ILiquidLaneUniswapXExecutorFillCall struct { + Routes []ILiquidLaneUniswapXExecutorFillRoute + DiscountRoutes []ILiquidLaneUniswapXExecutorDiscountRoute +} + +// ILiquidLaneUniswapXExecutorFillRoute is an auto generated low-level Go binding around an user-defined struct. +type ILiquidLaneUniswapXExecutorFillRoute struct { + Adapter common.Address + AmountIn *big.Int + AmountOut *big.Int +} + +// UniswapXInputToken is an auto generated low-level Go binding around an user-defined struct. +type UniswapXInputToken struct { + Token common.Address + Amount *big.Int + MaxAmount *big.Int +} + +// UniswapXOrderInfo is an auto generated low-level Go binding around an user-defined struct. +type UniswapXOrderInfo struct { + Reactor common.Address + Swapper common.Address + Nonce *big.Int + Deadline *big.Int + AdditionalValidationContract common.Address + AdditionalValidationData []byte +} + +// UniswapXOutputToken is an auto generated low-level Go binding around an user-defined struct. +type UniswapXOutputToken struct { + Token common.Address + Amount *big.Int + Recipient common.Address +} + +// UniswapXResolvedOrder is an auto generated low-level Go binding around an user-defined struct. +type UniswapXResolvedOrder struct { + Info UniswapXOrderInfo + Input UniswapXInputToken + Outputs []UniswapXOutputToken + Sig []byte + Hash [32]byte +} + +// UniswapXSignedOrder is an auto generated low-level Go binding around an user-defined struct. +type UniswapXSignedOrder struct { + Order []byte + Sig []byte +} + +// LiquidLaneUniswapXExecutorMetaData contains all meta data concerning the LiquidLaneUniswapXExecutor contract. +var LiquidLaneUniswapXExecutorMetaData = bind.MetaData{ + ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"reactor\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"receive\",\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"callers\",\"inputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"execute\",\"inputs\":[{\"name\":\"order\",\"type\":\"tuple\",\"internalType\":\"structUniswapXSignedOrder\",\"components\":[{\"name\":\"order\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"sig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"fillCall\",\"type\":\"tuple\",\"internalType\":\"structILiquidLaneUniswapXExecutor.FillCall\",\"components\":[{\"name\":\"routes\",\"type\":\"tuple[]\",\"internalType\":\"structILiquidLaneUniswapXExecutor.FillRoute[]\",\"components\":[{\"name\":\"adapter\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amountIn\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"amountOut\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"name\":\"discountRoutes\",\"type\":\"tuple[]\",\"internalType\":\"structILiquidLaneUniswapXExecutor.DiscountRoute[]\",\"components\":[{\"name\":\"adapter\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amountIn\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"discountSwap\",\"type\":\"tuple\",\"internalType\":\"structILiquidLaneAdapter.DiscountSwap\",\"components\":[{\"name\":\"discount\",\"type\":\"tuple\",\"internalType\":\"structILiquidLaneAdapter.Discount\",\"components\":[{\"name\":\"tokenToRedeem\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"discount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"signer\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"protocol\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"deadline\",\"type\":\"uint48\",\"internalType\":\"uint48\"}]},{\"name\":\"signerSignature\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"protocolDeadline\",\"type\":\"uint48\",\"internalType\":\"uint48\"}]},{\"name\":\"protocolSignature\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"initialize\",\"inputs\":[{\"name\":\"owner\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"initCallers\",\"type\":\"address[]\",\"internalType\":\"address[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"owner\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"reactorCallback\",\"inputs\":[{\"name\":\"resolvedOrders\",\"type\":\"tuple[]\",\"internalType\":\"structUniswapXResolvedOrder[]\",\"components\":[{\"name\":\"info\",\"type\":\"tuple\",\"internalType\":\"structUniswapXOrderInfo\",\"components\":[{\"name\":\"reactor\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"swapper\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"deadline\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"additionalValidationContract\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"additionalValidationData\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"input\",\"type\":\"tuple\",\"internalType\":\"structUniswapXInputToken\",\"components\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"maxAmount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"name\":\"outputs\",\"type\":\"tuple[]\",\"internalType\":\"structUniswapXOutputToken[]\",\"components\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"recipient\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"name\":\"sig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"hash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"callbackData\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"renounceOwnership\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setCallers\",\"inputs\":[{\"name\":\"newCallers\",\"type\":\"address[]\",\"internalType\":\"address[]\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"transferOwnership\",\"inputs\":[{\"name\":\"newOwner\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"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\":\"SetCallers\",\"inputs\":[{\"name\":\"newCallers\",\"type\":\"address[]\",\"indexed\":false,\"internalType\":\"address[]\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"FailedCall\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientBalance\",\"inputs\":[{\"name\":\"balance\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"needed\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"InvalidInitialization\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotCaller\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotInitializing\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotReactor\",\"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\":\"SafeERC20FailedOperation\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}]}]", + ID: "LiquidLaneUniswapXExecutor", +} + +// LiquidLaneUniswapXExecutor is an auto generated Go binding around an Ethereum contract. +type LiquidLaneUniswapXExecutor struct { + abi abi.ABI +} + +// NewLiquidLaneUniswapXExecutor creates a new instance of LiquidLaneUniswapXExecutor. +func NewLiquidLaneUniswapXExecutor() *LiquidLaneUniswapXExecutor { + parsed, err := LiquidLaneUniswapXExecutorMetaData.ParseABI() + if err != nil { + panic(errors.New("invalid ABI: " + err.Error())) + } + return &LiquidLaneUniswapXExecutor{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 *LiquidLaneUniswapXExecutor) 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 reactor) returns() +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) PackConstructor(reactor common.Address) []byte { + enc, err := liquidLaneUniswapXExecutor.abi.Pack("", reactor) + if err != nil { + panic(err) + } + return enc +} + +// PackCallers is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xaa03fa3d. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function callers(uint256 ) view returns(address) +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) PackCallers(arg0 *big.Int) []byte { + enc, err := liquidLaneUniswapXExecutor.abi.Pack("callers", arg0) + if err != nil { + panic(err) + } + return enc +} + +// TryPackCallers is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xaa03fa3d. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function callers(uint256 ) view returns(address) +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) TryPackCallers(arg0 *big.Int) ([]byte, error) { + return liquidLaneUniswapXExecutor.abi.Pack("callers", arg0) +} + +// UnpackCallers is the Go binding that unpacks the parameters returned +// from invoking the contract method with ID 0xaa03fa3d. +// +// Solidity: function callers(uint256 ) view returns(address) +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackCallers(data []byte) (common.Address, error) { + out, err := liquidLaneUniswapXExecutor.abi.Unpack("callers", data) + if err != nil { + return *new(common.Address), err + } + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + return out0, nil +} + +// PackExecute is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf21abd0f. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function execute((bytes,bytes) order, ((address,uint256,uint256)[],(address,uint256,((address,uint256,address,address,uint256,uint48),bytes,uint48),bytes)[]) fillCall) returns() +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) PackExecute(order UniswapXSignedOrder, fillCall ILiquidLaneUniswapXExecutorFillCall) []byte { + enc, err := liquidLaneUniswapXExecutor.abi.Pack("execute", order, fillCall) + if err != nil { + panic(err) + } + return enc +} + +// TryPackExecute is the Go binding used to pack the parameters required for calling +// the contract method with ID 0xf21abd0f. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function execute((bytes,bytes) order, ((address,uint256,uint256)[],(address,uint256,((address,uint256,address,address,uint256,uint48),bytes,uint48),bytes)[]) fillCall) returns() +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) TryPackExecute(order UniswapXSignedOrder, fillCall ILiquidLaneUniswapXExecutorFillCall) ([]byte, error) { + return liquidLaneUniswapXExecutor.abi.Pack("execute", order, fillCall) +} + +// PackInitialize is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x946d9204. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function initialize(address owner, address[] initCallers) returns() +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) PackInitialize(owner common.Address, initCallers []common.Address) []byte { + enc, err := liquidLaneUniswapXExecutor.abi.Pack("initialize", owner, initCallers) + 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 0x946d9204. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function initialize(address owner, address[] initCallers) returns() +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) TryPackInitialize(owner common.Address, initCallers []common.Address) ([]byte, error) { + return liquidLaneUniswapXExecutor.abi.Pack("initialize", owner, initCallers) +} + +// 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 (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) PackOwner() []byte { + enc, err := liquidLaneUniswapXExecutor.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 (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) TryPackOwner() ([]byte, error) { + return liquidLaneUniswapXExecutor.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 (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackOwner(data []byte) (common.Address, error) { + out, err := liquidLaneUniswapXExecutor.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 +} + +// PackReactorCallback is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x585da628. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function reactorCallback(((address,address,uint256,uint256,address,bytes),(address,uint256,uint256),(address,uint256,address)[],bytes,bytes32)[] resolvedOrders, bytes callbackData) returns() +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) PackReactorCallback(resolvedOrders []UniswapXResolvedOrder, callbackData []byte) []byte { + enc, err := liquidLaneUniswapXExecutor.abi.Pack("reactorCallback", resolvedOrders, callbackData) + if err != nil { + panic(err) + } + return enc +} + +// TryPackReactorCallback is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x585da628. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function reactorCallback(((address,address,uint256,uint256,address,bytes),(address,uint256,uint256),(address,uint256,address)[],bytes,bytes32)[] resolvedOrders, bytes callbackData) returns() +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) TryPackReactorCallback(resolvedOrders []UniswapXResolvedOrder, callbackData []byte) ([]byte, error) { + return liquidLaneUniswapXExecutor.abi.Pack("reactorCallback", resolvedOrders, callbackData) +} + +// 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 (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) PackRenounceOwnership() []byte { + enc, err := liquidLaneUniswapXExecutor.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 (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) TryPackRenounceOwnership() ([]byte, error) { + return liquidLaneUniswapXExecutor.abi.Pack("renounceOwnership") +} + +// PackSetCallers is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x43ded848. This method will panic if any +// invalid/nil inputs are passed. +// +// Solidity: function setCallers(address[] newCallers) returns() +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) PackSetCallers(newCallers []common.Address) []byte { + enc, err := liquidLaneUniswapXExecutor.abi.Pack("setCallers", newCallers) + if err != nil { + panic(err) + } + return enc +} + +// TryPackSetCallers is the Go binding used to pack the parameters required for calling +// the contract method with ID 0x43ded848. This method will return an error +// if any inputs are invalid/nil. +// +// Solidity: function setCallers(address[] newCallers) returns() +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) TryPackSetCallers(newCallers []common.Address) ([]byte, error) { + return liquidLaneUniswapXExecutor.abi.Pack("setCallers", newCallers) +} + +// 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 (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) PackTransferOwnership(newOwner common.Address) []byte { + enc, err := liquidLaneUniswapXExecutor.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 (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) TryPackTransferOwnership(newOwner common.Address) ([]byte, error) { + return liquidLaneUniswapXExecutor.abi.Pack("transferOwnership", newOwner) +} + +// LiquidLaneUniswapXExecutorInitialized represents a Initialized event raised by the LiquidLaneUniswapXExecutor contract. +type LiquidLaneUniswapXExecutorInitialized struct { + Version uint64 + Raw *types.Log // Blockchain specific contextual infos +} + +const LiquidLaneUniswapXExecutorInitializedEventName = "Initialized" + +// ContractEventName returns the user-defined event name. +func (LiquidLaneUniswapXExecutorInitialized) ContractEventName() string { + return LiquidLaneUniswapXExecutorInitializedEventName +} + +// UnpackInitializedEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event Initialized(uint64 version) +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackInitializedEvent(log *types.Log) (*LiquidLaneUniswapXExecutorInitialized, error) { + event := "Initialized" + if log.Topics[0] != liquidLaneUniswapXExecutor.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(LiquidLaneUniswapXExecutorInitialized) + if len(log.Data) > 0 { + if err := liquidLaneUniswapXExecutor.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range liquidLaneUniswapXExecutor.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 +} + +// LiquidLaneUniswapXExecutorOwnershipTransferred represents a OwnershipTransferred event raised by the LiquidLaneUniswapXExecutor contract. +type LiquidLaneUniswapXExecutorOwnershipTransferred struct { + PreviousOwner common.Address + NewOwner common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const LiquidLaneUniswapXExecutorOwnershipTransferredEventName = "OwnershipTransferred" + +// ContractEventName returns the user-defined event name. +func (LiquidLaneUniswapXExecutorOwnershipTransferred) ContractEventName() string { + return LiquidLaneUniswapXExecutorOwnershipTransferredEventName +} + +// UnpackOwnershipTransferredEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event OwnershipTransferred(address indexed previousOwner, address indexed newOwner) +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackOwnershipTransferredEvent(log *types.Log) (*LiquidLaneUniswapXExecutorOwnershipTransferred, error) { + event := "OwnershipTransferred" + if log.Topics[0] != liquidLaneUniswapXExecutor.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(LiquidLaneUniswapXExecutorOwnershipTransferred) + if len(log.Data) > 0 { + if err := liquidLaneUniswapXExecutor.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range liquidLaneUniswapXExecutor.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 +} + +// LiquidLaneUniswapXExecutorSetCallers represents a SetCallers event raised by the LiquidLaneUniswapXExecutor contract. +type LiquidLaneUniswapXExecutorSetCallers struct { + NewCallers []common.Address + Raw *types.Log // Blockchain specific contextual infos +} + +const LiquidLaneUniswapXExecutorSetCallersEventName = "SetCallers" + +// ContractEventName returns the user-defined event name. +func (LiquidLaneUniswapXExecutorSetCallers) ContractEventName() string { + return LiquidLaneUniswapXExecutorSetCallersEventName +} + +// UnpackSetCallersEvent is the Go binding that unpacks the event data emitted +// by contract. +// +// Solidity: event SetCallers(address[] newCallers) +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackSetCallersEvent(log *types.Log) (*LiquidLaneUniswapXExecutorSetCallers, error) { + event := "SetCallers" + if log.Topics[0] != liquidLaneUniswapXExecutor.abi.Events[event].ID { + return nil, errors.New("event signature mismatch") + } + out := new(LiquidLaneUniswapXExecutorSetCallers) + if len(log.Data) > 0 { + if err := liquidLaneUniswapXExecutor.abi.UnpackIntoInterface(out, event, log.Data); err != nil { + return nil, err + } + } + var indexed abi.Arguments + for _, arg := range liquidLaneUniswapXExecutor.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 (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackError(raw []byte) (any, error) { + if bytes.Equal(raw[:4], liquidLaneUniswapXExecutor.abi.Errors["FailedCall"].ID.Bytes()[:4]) { + return liquidLaneUniswapXExecutor.UnpackFailedCallError(raw[4:]) + } + if bytes.Equal(raw[:4], liquidLaneUniswapXExecutor.abi.Errors["InsufficientBalance"].ID.Bytes()[:4]) { + return liquidLaneUniswapXExecutor.UnpackInsufficientBalanceError(raw[4:]) + } + if bytes.Equal(raw[:4], liquidLaneUniswapXExecutor.abi.Errors["InvalidInitialization"].ID.Bytes()[:4]) { + return liquidLaneUniswapXExecutor.UnpackInvalidInitializationError(raw[4:]) + } + if bytes.Equal(raw[:4], liquidLaneUniswapXExecutor.abi.Errors["NotCaller"].ID.Bytes()[:4]) { + return liquidLaneUniswapXExecutor.UnpackNotCallerError(raw[4:]) + } + if bytes.Equal(raw[:4], liquidLaneUniswapXExecutor.abi.Errors["NotInitializing"].ID.Bytes()[:4]) { + return liquidLaneUniswapXExecutor.UnpackNotInitializingError(raw[4:]) + } + if bytes.Equal(raw[:4], liquidLaneUniswapXExecutor.abi.Errors["NotReactor"].ID.Bytes()[:4]) { + return liquidLaneUniswapXExecutor.UnpackNotReactorError(raw[4:]) + } + if bytes.Equal(raw[:4], liquidLaneUniswapXExecutor.abi.Errors["OwnableInvalidOwner"].ID.Bytes()[:4]) { + return liquidLaneUniswapXExecutor.UnpackOwnableInvalidOwnerError(raw[4:]) + } + if bytes.Equal(raw[:4], liquidLaneUniswapXExecutor.abi.Errors["OwnableUnauthorizedAccount"].ID.Bytes()[:4]) { + return liquidLaneUniswapXExecutor.UnpackOwnableUnauthorizedAccountError(raw[4:]) + } + if bytes.Equal(raw[:4], liquidLaneUniswapXExecutor.abi.Errors["SafeERC20FailedOperation"].ID.Bytes()[:4]) { + return liquidLaneUniswapXExecutor.UnpackSafeERC20FailedOperationError(raw[4:]) + } + return nil, errors.New("Unknown error") +} + +// LiquidLaneUniswapXExecutorFailedCall represents a FailedCall error raised by the LiquidLaneUniswapXExecutor contract. +type LiquidLaneUniswapXExecutorFailedCall struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error FailedCall() +func LiquidLaneUniswapXExecutorFailedCallErrorID() common.Hash { + return common.HexToHash("0xd6bda27508c0fb6d8a39b4b122878dab26f731a7d4e4abe711dd3731899052a4") +} + +// UnpackFailedCallError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error FailedCall() +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackFailedCallError(raw []byte) (*LiquidLaneUniswapXExecutorFailedCall, error) { + out := new(LiquidLaneUniswapXExecutorFailedCall) + if err := liquidLaneUniswapXExecutor.abi.UnpackIntoInterface(out, "FailedCall", raw); err != nil { + return nil, err + } + return out, nil +} + +// LiquidLaneUniswapXExecutorInsufficientBalance represents a InsufficientBalance error raised by the LiquidLaneUniswapXExecutor contract. +type LiquidLaneUniswapXExecutorInsufficientBalance struct { + Balance *big.Int + Needed *big.Int +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InsufficientBalance(uint256 balance, uint256 needed) +func LiquidLaneUniswapXExecutorInsufficientBalanceErrorID() common.Hash { + return common.HexToHash("0xcf4791818fba6e019216eb4864093b4947f674afada5d305e57d598b641dad1d") +} + +// UnpackInsufficientBalanceError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error InsufficientBalance(uint256 balance, uint256 needed) +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackInsufficientBalanceError(raw []byte) (*LiquidLaneUniswapXExecutorInsufficientBalance, error) { + out := new(LiquidLaneUniswapXExecutorInsufficientBalance) + if err := liquidLaneUniswapXExecutor.abi.UnpackIntoInterface(out, "InsufficientBalance", raw); err != nil { + return nil, err + } + return out, nil +} + +// LiquidLaneUniswapXExecutorInvalidInitialization represents a InvalidInitialization error raised by the LiquidLaneUniswapXExecutor contract. +type LiquidLaneUniswapXExecutorInvalidInitialization struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error InvalidInitialization() +func LiquidLaneUniswapXExecutorInvalidInitializationErrorID() 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 (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackInvalidInitializationError(raw []byte) (*LiquidLaneUniswapXExecutorInvalidInitialization, error) { + out := new(LiquidLaneUniswapXExecutorInvalidInitialization) + if err := liquidLaneUniswapXExecutor.abi.UnpackIntoInterface(out, "InvalidInitialization", raw); err != nil { + return nil, err + } + return out, nil +} + +// LiquidLaneUniswapXExecutorNotCaller represents a NotCaller error raised by the LiquidLaneUniswapXExecutor contract. +type LiquidLaneUniswapXExecutorNotCaller struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotCaller() +func LiquidLaneUniswapXExecutorNotCallerErrorID() common.Hash { + return common.HexToHash("0x16c618d80989492b64dbf0ed90935e3959f670b9b9d57385b45d00c0d1cdedf9") +} + +// UnpackNotCallerError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotCaller() +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackNotCallerError(raw []byte) (*LiquidLaneUniswapXExecutorNotCaller, error) { + out := new(LiquidLaneUniswapXExecutorNotCaller) + if err := liquidLaneUniswapXExecutor.abi.UnpackIntoInterface(out, "NotCaller", raw); err != nil { + return nil, err + } + return out, nil +} + +// LiquidLaneUniswapXExecutorNotInitializing represents a NotInitializing error raised by the LiquidLaneUniswapXExecutor contract. +type LiquidLaneUniswapXExecutorNotInitializing struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotInitializing() +func LiquidLaneUniswapXExecutorNotInitializingErrorID() 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 (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackNotInitializingError(raw []byte) (*LiquidLaneUniswapXExecutorNotInitializing, error) { + out := new(LiquidLaneUniswapXExecutorNotInitializing) + if err := liquidLaneUniswapXExecutor.abi.UnpackIntoInterface(out, "NotInitializing", raw); err != nil { + return nil, err + } + return out, nil +} + +// LiquidLaneUniswapXExecutorNotReactor represents a NotReactor error raised by the LiquidLaneUniswapXExecutor contract. +type LiquidLaneUniswapXExecutorNotReactor struct { +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error NotReactor() +func LiquidLaneUniswapXExecutorNotReactorErrorID() common.Hash { + return common.HexToHash("0x73f7fe5f826382662fca59946d03ef1eeb4c9e934f9b5911fe3582eee63054f6") +} + +// UnpackNotReactorError is the Go binding used to decode the provided +// error data into the corresponding Go error struct. +// +// Solidity: error NotReactor() +func (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackNotReactorError(raw []byte) (*LiquidLaneUniswapXExecutorNotReactor, error) { + out := new(LiquidLaneUniswapXExecutorNotReactor) + if err := liquidLaneUniswapXExecutor.abi.UnpackIntoInterface(out, "NotReactor", raw); err != nil { + return nil, err + } + return out, nil +} + +// LiquidLaneUniswapXExecutorOwnableInvalidOwner represents a OwnableInvalidOwner error raised by the LiquidLaneUniswapXExecutor contract. +type LiquidLaneUniswapXExecutorOwnableInvalidOwner struct { + Owner common.Address +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error OwnableInvalidOwner(address owner) +func LiquidLaneUniswapXExecutorOwnableInvalidOwnerErrorID() 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 (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackOwnableInvalidOwnerError(raw []byte) (*LiquidLaneUniswapXExecutorOwnableInvalidOwner, error) { + out := new(LiquidLaneUniswapXExecutorOwnableInvalidOwner) + if err := liquidLaneUniswapXExecutor.abi.UnpackIntoInterface(out, "OwnableInvalidOwner", raw); err != nil { + return nil, err + } + return out, nil +} + +// LiquidLaneUniswapXExecutorOwnableUnauthorizedAccount represents a OwnableUnauthorizedAccount error raised by the LiquidLaneUniswapXExecutor contract. +type LiquidLaneUniswapXExecutorOwnableUnauthorizedAccount struct { + Account common.Address +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error OwnableUnauthorizedAccount(address account) +func LiquidLaneUniswapXExecutorOwnableUnauthorizedAccountErrorID() 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 (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackOwnableUnauthorizedAccountError(raw []byte) (*LiquidLaneUniswapXExecutorOwnableUnauthorizedAccount, error) { + out := new(LiquidLaneUniswapXExecutorOwnableUnauthorizedAccount) + if err := liquidLaneUniswapXExecutor.abi.UnpackIntoInterface(out, "OwnableUnauthorizedAccount", raw); err != nil { + return nil, err + } + return out, nil +} + +// LiquidLaneUniswapXExecutorSafeERC20FailedOperation represents a SafeERC20FailedOperation error raised by the LiquidLaneUniswapXExecutor contract. +type LiquidLaneUniswapXExecutorSafeERC20FailedOperation struct { + Token common.Address +} + +// ErrorID returns the hash of canonical representation of the error's signature. +// +// Solidity: error SafeERC20FailedOperation(address token) +func LiquidLaneUniswapXExecutorSafeERC20FailedOperationErrorID() 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 (liquidLaneUniswapXExecutor *LiquidLaneUniswapXExecutor) UnpackSafeERC20FailedOperationError(raw []byte) (*LiquidLaneUniswapXExecutorSafeERC20FailedOperation, error) { + out := new(LiquidLaneUniswapXExecutorSafeERC20FailedOperation) + if err := liquidLaneUniswapXExecutor.abi.UnpackIntoInterface(out, "SafeERC20FailedOperation", raw); err != nil { + return nil, err + } + return out, nil +} diff --git a/api/lifiorder/api_bridge_api.go b/api/lifiorder/api_bridge_api.go index ea3edb37..892a31e4 100644 --- a/api/lifiorder/api_bridge_api.go +++ b/api/lifiorder/api_bridge_api.go @@ -126,13 +126,13 @@ type ApiOrdersControllerGetOrderStatusRequest struct { catalystOrderId *string } -// On chain order id propagated in the logs/events. +// On chain order id propagated in the logs/events. At least one of `onChainOrderId` or `catalystOrderId` must be provided. func (r ApiOrdersControllerGetOrderStatusRequest) OnChainOrderId(onChainOrderId string) ApiOrdersControllerGetOrderStatusRequest { r.onChainOrderId = &onChainOrderId return r } -// Internal order id returned by Lifi Intents API +// Internal order id returned by Lifi Intents API. At least one of `onChainOrderId` or `catalystOrderId` must be provided. func (r ApiOrdersControllerGetOrderStatusRequest) CatalystOrderId(catalystOrderId string) ApiOrdersControllerGetOrderStatusRequest { r.catalystOrderId = &catalystOrderId return r @@ -628,7 +628,7 @@ func (r ApiQuotesControllerRequestQuoteRequest) OifQuoteRequestDto(oifQuoteReque return r } -// Raw integrator API key (obtained from integrator onboarding). When provided, integrator-specific quotes become eligible in addition to open-market quotes. +// Raw integrator key. Use a high-entropy random string. When provided, integrator-specific quotes become eligible in addition to open-market quotes. func (r ApiQuotesControllerRequestQuoteRequest) XIntegratorKey(xIntegratorKey string) ApiQuotesControllerRequestQuoteRequest { r.xIntegratorKey = &xIntegratorKey return r diff --git a/api/lifiorder/api_bridge_apiv1.go b/api/lifiorder/api_bridge_apiv1.go index d3dfdc27..2140b21a 100644 --- a/api/lifiorder/api_bridge_apiv1.go +++ b/api/lifiorder/api_bridge_apiv1.go @@ -33,7 +33,7 @@ func (r ApiQuotesControllerV1GetQuoteRequest) QuoteRequestDto(quoteRequestDto Qu return r } -// Raw integrator API key (obtained from integrator onboarding). When provided, integrator-specific quotes become eligible in addition to open-market quotes. +// Raw integrator key. Use a high-entropy random string. When provided, integrator-specific quotes become eligible in addition to open-market quotes. func (r ApiQuotesControllerV1GetQuoteRequest) XIntegratorKey(xIntegratorKey string) ApiQuotesControllerV1GetQuoteRequest { r.xIntegratorKey = &xIntegratorKey return r diff --git a/api/lifiorder/configuration.go b/api/lifiorder/configuration.go index 8f172df9..5c4335f3 100644 --- a/api/lifiorder/configuration.go +++ b/api/lifiorder/configuration.go @@ -93,8 +93,12 @@ func NewConfiguration() *Configuration { Debug: false, Servers: ServerConfigurations{ { - URL: "", - Description: "No description provided", + URL: "https://order.li.fi", + Description: "Production", + }, + { + URL: "https://order-dev.li.fi", + Description: "Development", }, }, OperationServers: map[string]ServerConfigurations{}, diff --git a/api/lifiorder/model_allowance_check_dto.go b/api/lifiorder/model_allowance_check_dto.go new file mode 100644 index 00000000..e34050f0 --- /dev/null +++ b/api/lifiorder/model_allowance_check_dto.go @@ -0,0 +1,273 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the AllowanceCheckDto type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &AllowanceCheckDto{} + +// AllowanceCheckDto struct for AllowanceCheckDto +type AllowanceCheckDto struct { + // CAIP-2 chain identifier for this allowance check (e.g., \"eip155:1\") + Chain string `json:"chain"` + // Native token address + Token string `json:"token"` + // Native user address + User string `json:"user"` + // Native spender address - InputSettlerEscrowLIFI + Spender string `json:"spender"` + // Required allowance amount as string-encoded integer + Required string `json:"required"` +} + +type _AllowanceCheckDto AllowanceCheckDto + +// NewAllowanceCheckDto instantiates a new AllowanceCheckDto object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewAllowanceCheckDto(chain string, token string, user string, spender string, required string) *AllowanceCheckDto { + this := AllowanceCheckDto{} + this.Chain = chain + this.Token = token + this.User = user + this.Spender = spender + this.Required = required + return &this +} + +// NewAllowanceCheckDtoWithDefaults instantiates a new AllowanceCheckDto object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewAllowanceCheckDtoWithDefaults() *AllowanceCheckDto { + this := AllowanceCheckDto{} + return &this +} + +// GetChain returns the Chain field value +func (o *AllowanceCheckDto) GetChain() string { + if o == nil { + var ret string + return ret + } + + return o.Chain +} + +// GetChainOk returns a tuple with the Chain field value +// and a boolean to check if the value has been set. +func (o *AllowanceCheckDto) GetChainOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Chain, true +} + +// SetChain sets field value +func (o *AllowanceCheckDto) SetChain(v string) { + o.Chain = v +} + +// GetToken returns the Token field value +func (o *AllowanceCheckDto) GetToken() string { + if o == nil { + var ret string + return ret + } + + return o.Token +} + +// GetTokenOk returns a tuple with the Token field value +// and a boolean to check if the value has been set. +func (o *AllowanceCheckDto) GetTokenOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Token, true +} + +// SetToken sets field value +func (o *AllowanceCheckDto) SetToken(v string) { + o.Token = v +} + +// GetUser returns the User field value +func (o *AllowanceCheckDto) GetUser() string { + if o == nil { + var ret string + return ret + } + + return o.User +} + +// GetUserOk returns a tuple with the User field value +// and a boolean to check if the value has been set. +func (o *AllowanceCheckDto) GetUserOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.User, true +} + +// SetUser sets field value +func (o *AllowanceCheckDto) SetUser(v string) { + o.User = v +} + +// GetSpender returns the Spender field value +func (o *AllowanceCheckDto) GetSpender() string { + if o == nil { + var ret string + return ret + } + + return o.Spender +} + +// GetSpenderOk returns a tuple with the Spender field value +// and a boolean to check if the value has been set. +func (o *AllowanceCheckDto) GetSpenderOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Spender, true +} + +// SetSpender sets field value +func (o *AllowanceCheckDto) SetSpender(v string) { + o.Spender = v +} + +// GetRequired returns the Required field value +func (o *AllowanceCheckDto) GetRequired() string { + if o == nil { + var ret string + return ret + } + + return o.Required +} + +// GetRequiredOk returns a tuple with the Required field value +// and a boolean to check if the value has been set. +func (o *AllowanceCheckDto) GetRequiredOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Required, true +} + +// SetRequired sets field value +func (o *AllowanceCheckDto) SetRequired(v string) { + o.Required = v +} + +func (o AllowanceCheckDto) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o AllowanceCheckDto) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["chain"] = o.Chain + toSerialize["token"] = o.Token + toSerialize["user"] = o.User + toSerialize["spender"] = o.Spender + toSerialize["required"] = o.Required + return toSerialize, nil +} + +func (o *AllowanceCheckDto) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "chain", + "token", + "user", + "spender", + "required", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varAllowanceCheckDto := _AllowanceCheckDto{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varAllowanceCheckDto) + + if err != nil { + return err + } + + *o = AllowanceCheckDto(varAllowanceCheckDto) + + return err +} + +type NullableAllowanceCheckDto struct { + value *AllowanceCheckDto + isSet bool +} + +func (v NullableAllowanceCheckDto) Get() *AllowanceCheckDto { + return v.value +} + +func (v *NullableAllowanceCheckDto) Set(val *AllowanceCheckDto) { + v.value = val + v.isSet = true +} + +func (v NullableAllowanceCheckDto) IsSet() bool { + return v.isSet +} + +func (v *NullableAllowanceCheckDto) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableAllowanceCheckDto(val *AllowanceCheckDto) *NullableAllowanceCheckDto { + return &NullableAllowanceCheckDto{value: val, isSet: true} +} + +func (v NullableAllowanceCheckDto) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableAllowanceCheckDto) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_checks_dto.go b/api/lifiorder/model_checks_dto.go new file mode 100644 index 00000000..140ed5a4 --- /dev/null +++ b/api/lifiorder/model_checks_dto.go @@ -0,0 +1,157 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the ChecksDto type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &ChecksDto{} + +// ChecksDto struct for ChecksDto +type ChecksDto struct { + // Required allowances and balances. Each item asserts that user has at least required balance and allowance for spender on token. + Allowances []AllowanceCheckDto `json:"allowances"` +} + +type _ChecksDto ChecksDto + +// NewChecksDto instantiates a new ChecksDto object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewChecksDto(allowances []AllowanceCheckDto) *ChecksDto { + this := ChecksDto{} + this.Allowances = allowances + return &this +} + +// NewChecksDtoWithDefaults instantiates a new ChecksDto object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewChecksDtoWithDefaults() *ChecksDto { + this := ChecksDto{} + return &this +} + +// GetAllowances returns the Allowances field value +func (o *ChecksDto) GetAllowances() []AllowanceCheckDto { + if o == nil { + var ret []AllowanceCheckDto + return ret + } + + return o.Allowances +} + +// GetAllowancesOk returns a tuple with the Allowances field value +// and a boolean to check if the value has been set. +func (o *ChecksDto) GetAllowancesOk() ([]AllowanceCheckDto, bool) { + if o == nil { + return nil, false + } + return o.Allowances, true +} + +// SetAllowances sets field value +func (o *ChecksDto) SetAllowances(v []AllowanceCheckDto) { + o.Allowances = v +} + +func (o ChecksDto) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o ChecksDto) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["allowances"] = o.Allowances + return toSerialize, nil +} + +func (o *ChecksDto) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "allowances", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varChecksDto := _ChecksDto{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varChecksDto) + + if err != nil { + return err + } + + *o = ChecksDto(varChecksDto) + + return err +} + +type NullableChecksDto struct { + value *ChecksDto + isSet bool +} + +func (v NullableChecksDto) Get() *ChecksDto { + return v.value +} + +func (v *NullableChecksDto) Set(val *ChecksDto) { + v.value = val + v.isSet = true +} + +func (v NullableChecksDto) IsSet() bool { + return v.isSet +} + +func (v *NullableChecksDto) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableChecksDto(val *ChecksDto) *NullableChecksDto { + return &NullableChecksDto{value: val, isSet: true} +} + +func (v NullableChecksDto) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableChecksDto) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_eip712_payload_dto.go b/api/lifiorder/model_eip712_payload_dto.go new file mode 100644 index 00000000..a7a4e17d --- /dev/null +++ b/api/lifiorder/model_eip712_payload_dto.go @@ -0,0 +1,273 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the Eip712PayloadDto type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &Eip712PayloadDto{} + +// Eip712PayloadDto struct for Eip712PayloadDto +type Eip712PayloadDto struct { + // Signature type indicator + SignatureType string `json:"signatureType"` + // EIP-712 domain separator + Domain map[string]interface{} `json:"domain"` + // Primary type name + PrimaryType string `json:"primaryType"` + // The message object + Message map[string]interface{} `json:"message"` + // EIP-712 types used to construct the digest + Types map[string]interface{} `json:"types"` +} + +type _Eip712PayloadDto Eip712PayloadDto + +// NewEip712PayloadDto instantiates a new Eip712PayloadDto object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewEip712PayloadDto(signatureType string, domain map[string]interface{}, primaryType string, message map[string]interface{}, types map[string]interface{}) *Eip712PayloadDto { + this := Eip712PayloadDto{} + this.SignatureType = signatureType + this.Domain = domain + this.PrimaryType = primaryType + this.Message = message + this.Types = types + return &this +} + +// NewEip712PayloadDtoWithDefaults instantiates a new Eip712PayloadDto object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewEip712PayloadDtoWithDefaults() *Eip712PayloadDto { + this := Eip712PayloadDto{} + return &this +} + +// GetSignatureType returns the SignatureType field value +func (o *Eip712PayloadDto) GetSignatureType() string { + if o == nil { + var ret string + return ret + } + + return o.SignatureType +} + +// GetSignatureTypeOk returns a tuple with the SignatureType field value +// and a boolean to check if the value has been set. +func (o *Eip712PayloadDto) GetSignatureTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.SignatureType, true +} + +// SetSignatureType sets field value +func (o *Eip712PayloadDto) SetSignatureType(v string) { + o.SignatureType = v +} + +// GetDomain returns the Domain field value +func (o *Eip712PayloadDto) GetDomain() map[string]interface{} { + if o == nil { + var ret map[string]interface{} + return ret + } + + return o.Domain +} + +// GetDomainOk returns a tuple with the Domain field value +// and a boolean to check if the value has been set. +func (o *Eip712PayloadDto) GetDomainOk() (map[string]interface{}, bool) { + if o == nil { + return map[string]interface{}{}, false + } + return o.Domain, true +} + +// SetDomain sets field value +func (o *Eip712PayloadDto) SetDomain(v map[string]interface{}) { + o.Domain = v +} + +// GetPrimaryType returns the PrimaryType field value +func (o *Eip712PayloadDto) GetPrimaryType() string { + if o == nil { + var ret string + return ret + } + + return o.PrimaryType +} + +// GetPrimaryTypeOk returns a tuple with the PrimaryType field value +// and a boolean to check if the value has been set. +func (o *Eip712PayloadDto) GetPrimaryTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.PrimaryType, true +} + +// SetPrimaryType sets field value +func (o *Eip712PayloadDto) SetPrimaryType(v string) { + o.PrimaryType = v +} + +// GetMessage returns the Message field value +func (o *Eip712PayloadDto) GetMessage() map[string]interface{} { + if o == nil { + var ret map[string]interface{} + return ret + } + + return o.Message +} + +// GetMessageOk returns a tuple with the Message field value +// and a boolean to check if the value has been set. +func (o *Eip712PayloadDto) GetMessageOk() (map[string]interface{}, bool) { + if o == nil { + return map[string]interface{}{}, false + } + return o.Message, true +} + +// SetMessage sets field value +func (o *Eip712PayloadDto) SetMessage(v map[string]interface{}) { + o.Message = v +} + +// GetTypes returns the Types field value +func (o *Eip712PayloadDto) GetTypes() map[string]interface{} { + if o == nil { + var ret map[string]interface{} + return ret + } + + return o.Types +} + +// GetTypesOk returns a tuple with the Types field value +// and a boolean to check if the value has been set. +func (o *Eip712PayloadDto) GetTypesOk() (map[string]interface{}, bool) { + if o == nil { + return map[string]interface{}{}, false + } + return o.Types, true +} + +// SetTypes sets field value +func (o *Eip712PayloadDto) SetTypes(v map[string]interface{}) { + o.Types = v +} + +func (o Eip712PayloadDto) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o Eip712PayloadDto) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["signatureType"] = o.SignatureType + toSerialize["domain"] = o.Domain + toSerialize["primaryType"] = o.PrimaryType + toSerialize["message"] = o.Message + toSerialize["types"] = o.Types + return toSerialize, nil +} + +func (o *Eip712PayloadDto) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "signatureType", + "domain", + "primaryType", + "message", + "types", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varEip712PayloadDto := _Eip712PayloadDto{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varEip712PayloadDto) + + if err != nil { + return err + } + + *o = Eip712PayloadDto(varEip712PayloadDto) + + return err +} + +type NullableEip712PayloadDto struct { + value *Eip712PayloadDto + isSet bool +} + +func (v NullableEip712PayloadDto) Get() *Eip712PayloadDto { + return v.value +} + +func (v *NullableEip712PayloadDto) Set(val *Eip712PayloadDto) { + v.value = val + v.isSet = true +} + +func (v NullableEip712PayloadDto) IsSet() bool { + return v.isSet +} + +func (v *NullableEip712PayloadDto) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableEip712PayloadDto(val *Eip712PayloadDto) *NullableEip712PayloadDto { + return &NullableEip712PayloadDto{value: val, isSet: true} +} + +func (v NullableEip712PayloadDto) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableEip712PayloadDto) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_input_dto.go b/api/lifiorder/model_input_dto.go new file mode 100644 index 00000000..cd0febda --- /dev/null +++ b/api/lifiorder/model_input_dto.go @@ -0,0 +1,299 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the InputDto type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &InputDto{} + +// InputDto struct for InputDto +type InputDto struct { + // CAIP-2 chain identifier for this input (e.g., \"eip155:1\"). Applies to both user and asset. + Chain string `json:"chain"` + // Native address of the user providing the input assets + User string `json:"user"` + // Native address of the token/asset being provided as input + Asset string `json:"asset"` + Amount NullableString `json:"amount,omitempty"` + // Optional lock reference for securing the input assets. Shape: { kind: \"the-compact\" | \"rhinestone\", params: object }. Currently ignored by the quote decoder. + Lock map[string]interface{} `json:"lock,omitempty"` +} + +type _InputDto InputDto + +// NewInputDto instantiates a new InputDto object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewInputDto(chain string, user string, asset string) *InputDto { + this := InputDto{} + this.Chain = chain + this.User = user + this.Asset = asset + return &this +} + +// NewInputDtoWithDefaults instantiates a new InputDto object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewInputDtoWithDefaults() *InputDto { + this := InputDto{} + return &this +} + +// GetChain returns the Chain field value +func (o *InputDto) GetChain() string { + if o == nil { + var ret string + return ret + } + + return o.Chain +} + +// GetChainOk returns a tuple with the Chain field value +// and a boolean to check if the value has been set. +func (o *InputDto) GetChainOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Chain, true +} + +// SetChain sets field value +func (o *InputDto) SetChain(v string) { + o.Chain = v +} + +// GetUser returns the User field value +func (o *InputDto) GetUser() string { + if o == nil { + var ret string + return ret + } + + return o.User +} + +// GetUserOk returns a tuple with the User field value +// and a boolean to check if the value has been set. +func (o *InputDto) GetUserOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.User, true +} + +// SetUser sets field value +func (o *InputDto) SetUser(v string) { + o.User = v +} + +// GetAsset returns the Asset field value +func (o *InputDto) GetAsset() string { + if o == nil { + var ret string + return ret + } + + return o.Asset +} + +// GetAssetOk returns a tuple with the Asset field value +// and a boolean to check if the value has been set. +func (o *InputDto) GetAssetOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Asset, true +} + +// SetAsset sets field value +func (o *InputDto) SetAsset(v string) { + o.Asset = v +} + +// GetAmount returns the Amount field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *InputDto) GetAmount() string { + if o == nil || IsNil(o.Amount.Get()) { + var ret string + return ret + } + return *o.Amount.Get() +} + +// GetAmountOk returns a tuple with the Amount field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *InputDto) GetAmountOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Amount.Get(), o.Amount.IsSet() +} + +// HasAmount returns a boolean if a field has been set. +func (o *InputDto) HasAmount() bool { + if o != nil && o.Amount.IsSet() { + return true + } + + return false +} + +// SetAmount gets a reference to the given NullableString and assigns it to the Amount field. +func (o *InputDto) SetAmount(v string) { + o.Amount.Set(&v) +} + +// SetAmountNil sets the value for Amount to be an explicit nil +func (o *InputDto) SetAmountNil() { + o.Amount.Set(nil) +} + +// UnsetAmount ensures that no value is present for Amount, not even an explicit nil +func (o *InputDto) UnsetAmount() { + o.Amount.Unset() +} + +// GetLock returns the Lock field value if set, zero value otherwise. +func (o *InputDto) GetLock() map[string]interface{} { + if o == nil || IsNil(o.Lock) { + var ret map[string]interface{} + return ret + } + return o.Lock +} + +// GetLockOk returns a tuple with the Lock field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *InputDto) GetLockOk() (map[string]interface{}, bool) { + if o == nil || IsNil(o.Lock) { + return map[string]interface{}{}, false + } + return o.Lock, true +} + +// HasLock returns a boolean if a field has been set. +func (o *InputDto) HasLock() bool { + if o != nil && !IsNil(o.Lock) { + return true + } + + return false +} + +// SetLock gets a reference to the given map[string]interface{} and assigns it to the Lock field. +func (o *InputDto) SetLock(v map[string]interface{}) { + o.Lock = v +} + +func (o InputDto) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o InputDto) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["chain"] = o.Chain + toSerialize["user"] = o.User + toSerialize["asset"] = o.Asset + if o.Amount.IsSet() { + toSerialize["amount"] = o.Amount.Get() + } + if !IsNil(o.Lock) { + toSerialize["lock"] = o.Lock + } + return toSerialize, nil +} + +func (o *InputDto) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "chain", + "user", + "asset", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varInputDto := _InputDto{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varInputDto) + + if err != nil { + return err + } + + *o = InputDto(varInputDto) + + return err +} + +type NullableInputDto struct { + value *InputDto + isSet bool +} + +func (v NullableInputDto) Get() *InputDto { + return v.value +} + +func (v *NullableInputDto) Set(val *InputDto) { + v.value = val + v.isSet = true +} + +func (v NullableInputDto) IsSet() bool { + return v.isSet +} + +func (v *NullableInputDto) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableInputDto(val *InputDto) *NullableInputDto { + return &NullableInputDto{value: val, isSet: true} +} + +func (v NullableInputDto) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableInputDto) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_oif3009_order_dto.go b/api/lifiorder/model_oif3009_order_dto.go new file mode 100644 index 00000000..9125a257 --- /dev/null +++ b/api/lifiorder/model_oif3009_order_dto.go @@ -0,0 +1,215 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the Oif3009OrderDto type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &Oif3009OrderDto{} + +// Oif3009OrderDto struct for Oif3009OrderDto +type Oif3009OrderDto struct { + // Order type identifier for EIP-3009 transfers + Type string `json:"type"` + // EIP-3009 Transfer With Authorization typed data + Payload Eip712PayloadDto `json:"payload"` + // Additional metadata for nonce verification and order tracking + Metadata map[string]interface{} `json:"metadata"` +} + +type _Oif3009OrderDto Oif3009OrderDto + +// NewOif3009OrderDto instantiates a new Oif3009OrderDto object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewOif3009OrderDto(type_ string, payload Eip712PayloadDto, metadata map[string]interface{}) *Oif3009OrderDto { + this := Oif3009OrderDto{} + this.Type = type_ + this.Payload = payload + this.Metadata = metadata + return &this +} + +// NewOif3009OrderDtoWithDefaults instantiates a new Oif3009OrderDto object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewOif3009OrderDtoWithDefaults() *Oif3009OrderDto { + this := Oif3009OrderDto{} + return &this +} + +// GetType returns the Type field value +func (o *Oif3009OrderDto) GetType() string { + if o == nil { + var ret string + return ret + } + + return o.Type +} + +// GetTypeOk returns a tuple with the Type field value +// and a boolean to check if the value has been set. +func (o *Oif3009OrderDto) GetTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Type, true +} + +// SetType sets field value +func (o *Oif3009OrderDto) SetType(v string) { + o.Type = v +} + +// GetPayload returns the Payload field value +func (o *Oif3009OrderDto) GetPayload() Eip712PayloadDto { + if o == nil { + var ret Eip712PayloadDto + return ret + } + + return o.Payload +} + +// GetPayloadOk returns a tuple with the Payload field value +// and a boolean to check if the value has been set. +func (o *Oif3009OrderDto) GetPayloadOk() (*Eip712PayloadDto, bool) { + if o == nil { + return nil, false + } + return &o.Payload, true +} + +// SetPayload sets field value +func (o *Oif3009OrderDto) SetPayload(v Eip712PayloadDto) { + o.Payload = v +} + +// GetMetadata returns the Metadata field value +func (o *Oif3009OrderDto) GetMetadata() map[string]interface{} { + if o == nil { + var ret map[string]interface{} + return ret + } + + return o.Metadata +} + +// GetMetadataOk returns a tuple with the Metadata field value +// and a boolean to check if the value has been set. +func (o *Oif3009OrderDto) GetMetadataOk() (map[string]interface{}, bool) { + if o == nil { + return map[string]interface{}{}, false + } + return o.Metadata, true +} + +// SetMetadata sets field value +func (o *Oif3009OrderDto) SetMetadata(v map[string]interface{}) { + o.Metadata = v +} + +func (o Oif3009OrderDto) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o Oif3009OrderDto) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["type"] = o.Type + toSerialize["payload"] = o.Payload + toSerialize["metadata"] = o.Metadata + return toSerialize, nil +} + +func (o *Oif3009OrderDto) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "type", + "payload", + "metadata", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varOif3009OrderDto := _Oif3009OrderDto{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varOif3009OrderDto) + + if err != nil { + return err + } + + *o = Oif3009OrderDto(varOif3009OrderDto) + + return err +} + +type NullableOif3009OrderDto struct { + value *Oif3009OrderDto + isSet bool +} + +func (v NullableOif3009OrderDto) Get() *Oif3009OrderDto { + return v.value +} + +func (v *NullableOif3009OrderDto) Set(val *Oif3009OrderDto) { + v.value = val + v.isSet = true +} + +func (v NullableOif3009OrderDto) IsSet() bool { + return v.isSet +} + +func (v *NullableOif3009OrderDto) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOif3009OrderDto(val *Oif3009OrderDto) *NullableOif3009OrderDto { + return &NullableOif3009OrderDto{value: val, isSet: true} +} + +func (v NullableOif3009OrderDto) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOif3009OrderDto) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_oif_escrow_order_dto.go b/api/lifiorder/model_oif_escrow_order_dto.go new file mode 100644 index 00000000..3a0c7b7b --- /dev/null +++ b/api/lifiorder/model_oif_escrow_order_dto.go @@ -0,0 +1,186 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the OifEscrowOrderDto type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OifEscrowOrderDto{} + +// OifEscrowOrderDto struct for OifEscrowOrderDto +type OifEscrowOrderDto struct { + // Order type identifier for escrow-based execution + Type string `json:"type"` + // EIP-712 payload for escrow order + Payload Eip712PayloadDto `json:"payload"` +} + +type _OifEscrowOrderDto OifEscrowOrderDto + +// NewOifEscrowOrderDto instantiates a new OifEscrowOrderDto object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewOifEscrowOrderDto(type_ string, payload Eip712PayloadDto) *OifEscrowOrderDto { + this := OifEscrowOrderDto{} + this.Type = type_ + this.Payload = payload + return &this +} + +// NewOifEscrowOrderDtoWithDefaults instantiates a new OifEscrowOrderDto object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewOifEscrowOrderDtoWithDefaults() *OifEscrowOrderDto { + this := OifEscrowOrderDto{} + return &this +} + +// GetType returns the Type field value +func (o *OifEscrowOrderDto) GetType() string { + if o == nil { + var ret string + return ret + } + + return o.Type +} + +// GetTypeOk returns a tuple with the Type field value +// and a boolean to check if the value has been set. +func (o *OifEscrowOrderDto) GetTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Type, true +} + +// SetType sets field value +func (o *OifEscrowOrderDto) SetType(v string) { + o.Type = v +} + +// GetPayload returns the Payload field value +func (o *OifEscrowOrderDto) GetPayload() Eip712PayloadDto { + if o == nil { + var ret Eip712PayloadDto + return ret + } + + return o.Payload +} + +// GetPayloadOk returns a tuple with the Payload field value +// and a boolean to check if the value has been set. +func (o *OifEscrowOrderDto) GetPayloadOk() (*Eip712PayloadDto, bool) { + if o == nil { + return nil, false + } + return &o.Payload, true +} + +// SetPayload sets field value +func (o *OifEscrowOrderDto) SetPayload(v Eip712PayloadDto) { + o.Payload = v +} + +func (o OifEscrowOrderDto) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o OifEscrowOrderDto) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["type"] = o.Type + toSerialize["payload"] = o.Payload + return toSerialize, nil +} + +func (o *OifEscrowOrderDto) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "type", + "payload", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varOifEscrowOrderDto := _OifEscrowOrderDto{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varOifEscrowOrderDto) + + if err != nil { + return err + } + + *o = OifEscrowOrderDto(varOifEscrowOrderDto) + + return err +} + +type NullableOifEscrowOrderDto struct { + value *OifEscrowOrderDto + isSet bool +} + +func (v NullableOifEscrowOrderDto) Get() *OifEscrowOrderDto { + return v.value +} + +func (v *NullableOifEscrowOrderDto) Set(val *OifEscrowOrderDto) { + v.value = val + v.isSet = true +} + +func (v NullableOifEscrowOrderDto) IsSet() bool { + return v.isSet +} + +func (v *NullableOifEscrowOrderDto) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOifEscrowOrderDto(val *OifEscrowOrderDto) *NullableOifEscrowOrderDto { + return &NullableOifEscrowOrderDto{value: val, isSet: true} +} + +func (v NullableOifEscrowOrderDto) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOifEscrowOrderDto) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_oif_quote_dto.go b/api/lifiorder/model_oif_quote_dto.go index 1c0ca638..fcb3eef4 100644 --- a/api/lifiorder/model_oif_quote_dto.go +++ b/api/lifiorder/model_oif_quote_dto.go @@ -21,7 +21,7 @@ var _ MappedNullable = &OifQuoteDto{} // OifQuoteDto struct for OifQuoteDto type OifQuoteDto struct { - // Order details (null for quote requests) + // Order details; null for quote requests, provider-specific structure when populated Order map[string]interface{} `json:"order,omitempty"` // Quote validity timestamp in seconds ValidUntil *float32 `json:"validUntil,omitempty"` @@ -32,7 +32,7 @@ type OifQuoteDto struct { // Provider identifier Provider *string `json:"provider,omitempty"` // Informational amounts for UX/display - Preview QuotePreviewDto `json:"preview"` + Preview OifQuotePreviewDto `json:"preview"` // Failure handling policy for execution FailureHandling string `json:"failureHandling"` // Whether the quote supports partial fills @@ -47,7 +47,7 @@ type _OifQuoteDto OifQuoteDto // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewOifQuoteDto(preview QuotePreviewDto, failureHandling string, partialFill bool, metadata OifQuoteMetadataDto) *OifQuoteDto { +func NewOifQuoteDto(preview OifQuotePreviewDto, failureHandling string, partialFill bool, metadata OifQuoteMetadataDto) *OifQuoteDto { this := OifQuoteDto{} this.Preview = preview this.FailureHandling = failureHandling @@ -226,9 +226,9 @@ func (o *OifQuoteDto) SetProvider(v string) { } // GetPreview returns the Preview field value -func (o *OifQuoteDto) GetPreview() QuotePreviewDto { +func (o *OifQuoteDto) GetPreview() OifQuotePreviewDto { if o == nil { - var ret QuotePreviewDto + var ret OifQuotePreviewDto return ret } @@ -237,7 +237,7 @@ func (o *OifQuoteDto) GetPreview() QuotePreviewDto { // GetPreviewOk returns a tuple with the Preview field value // and a boolean to check if the value has been set. -func (o *OifQuoteDto) GetPreviewOk() (*QuotePreviewDto, bool) { +func (o *OifQuoteDto) GetPreviewOk() (*OifQuotePreviewDto, bool) { if o == nil { return nil, false } @@ -245,7 +245,7 @@ func (o *OifQuoteDto) GetPreviewOk() (*QuotePreviewDto, bool) { } // SetPreview sets field value -func (o *OifQuoteDto) SetPreview(v QuotePreviewDto) { +func (o *OifQuoteDto) SetPreview(v OifQuotePreviewDto) { o.Preview = v } diff --git a/api/lifiorder/model_oif_quote_metadata_dto.go b/api/lifiorder/model_oif_quote_metadata_dto.go index ec4c2df3..45c0d0cd 100644 --- a/api/lifiorder/model_oif_quote_metadata_dto.go +++ b/api/lifiorder/model_oif_quote_metadata_dto.go @@ -22,7 +22,7 @@ var _ MappedNullable = &OifQuoteMetadataDto{} // OifQuoteMetadataDto struct for OifQuoteMetadataDto type OifQuoteMetadataDto struct { // Solver address with exclusivity on this quote, or null when no solver is exclusive - ExclusiveFor map[string]interface{} `json:"exclusiveFor"` + ExclusiveFor NullableString `json:"exclusiveFor"` } type _OifQuoteMetadataDto OifQuoteMetadataDto @@ -31,7 +31,7 @@ type _OifQuoteMetadataDto OifQuoteMetadataDto // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewOifQuoteMetadataDto(exclusiveFor map[string]interface{}) *OifQuoteMetadataDto { +func NewOifQuoteMetadataDto(exclusiveFor NullableString) *OifQuoteMetadataDto { this := OifQuoteMetadataDto{} this.ExclusiveFor = exclusiveFor return &this @@ -46,29 +46,29 @@ func NewOifQuoteMetadataDtoWithDefaults() *OifQuoteMetadataDto { } // GetExclusiveFor returns the ExclusiveFor field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *OifQuoteMetadataDto) GetExclusiveFor() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *OifQuoteMetadataDto) GetExclusiveFor() string { + if o == nil || o.ExclusiveFor.Get() == nil { + var ret string return ret } - return o.ExclusiveFor + return *o.ExclusiveFor.Get() } // GetExclusiveForOk returns a tuple with the ExclusiveFor field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *OifQuoteMetadataDto) GetExclusiveForOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.ExclusiveFor) { - return map[string]interface{}{}, false +func (o *OifQuoteMetadataDto) GetExclusiveForOk() (*string, bool) { + if o == nil { + return nil, false } - return o.ExclusiveFor, true + return o.ExclusiveFor.Get(), o.ExclusiveFor.IsSet() } // SetExclusiveFor sets field value -func (o *OifQuoteMetadataDto) SetExclusiveFor(v map[string]interface{}) { - o.ExclusiveFor = v +func (o *OifQuoteMetadataDto) SetExclusiveFor(v string) { + o.ExclusiveFor.Set(&v) } func (o OifQuoteMetadataDto) MarshalJSON() ([]byte, error) { @@ -81,9 +81,7 @@ func (o OifQuoteMetadataDto) MarshalJSON() ([]byte, error) { func (o OifQuoteMetadataDto) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} - if o.ExclusiveFor != nil { - toSerialize["exclusiveFor"] = o.ExclusiveFor - } + toSerialize["exclusiveFor"] = o.ExclusiveFor.Get() return toSerialize, nil } diff --git a/api/lifiorder/model_oif_quote_preview_dto.go b/api/lifiorder/model_oif_quote_preview_dto.go new file mode 100644 index 00000000..fc6a185c --- /dev/null +++ b/api/lifiorder/model_oif_quote_preview_dto.go @@ -0,0 +1,186 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the OifQuotePreviewDto type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OifQuotePreviewDto{} + +// OifQuotePreviewDto struct for OifQuotePreviewDto +type OifQuotePreviewDto struct { + // Inputs for the preview + Inputs []OifQuotePreviewDtoInputsInner `json:"inputs"` + // Outputs for the preview + Outputs []OifQuotePreviewDtoOutputsInner `json:"outputs"` +} + +type _OifQuotePreviewDto OifQuotePreviewDto + +// NewOifQuotePreviewDto instantiates a new OifQuotePreviewDto object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewOifQuotePreviewDto(inputs []OifQuotePreviewDtoInputsInner, outputs []OifQuotePreviewDtoOutputsInner) *OifQuotePreviewDto { + this := OifQuotePreviewDto{} + this.Inputs = inputs + this.Outputs = outputs + return &this +} + +// NewOifQuotePreviewDtoWithDefaults instantiates a new OifQuotePreviewDto object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewOifQuotePreviewDtoWithDefaults() *OifQuotePreviewDto { + this := OifQuotePreviewDto{} + return &this +} + +// GetInputs returns the Inputs field value +func (o *OifQuotePreviewDto) GetInputs() []OifQuotePreviewDtoInputsInner { + if o == nil { + var ret []OifQuotePreviewDtoInputsInner + return ret + } + + return o.Inputs +} + +// GetInputsOk returns a tuple with the Inputs field value +// and a boolean to check if the value has been set. +func (o *OifQuotePreviewDto) GetInputsOk() ([]OifQuotePreviewDtoInputsInner, bool) { + if o == nil { + return nil, false + } + return o.Inputs, true +} + +// SetInputs sets field value +func (o *OifQuotePreviewDto) SetInputs(v []OifQuotePreviewDtoInputsInner) { + o.Inputs = v +} + +// GetOutputs returns the Outputs field value +func (o *OifQuotePreviewDto) GetOutputs() []OifQuotePreviewDtoOutputsInner { + if o == nil { + var ret []OifQuotePreviewDtoOutputsInner + return ret + } + + return o.Outputs +} + +// GetOutputsOk returns a tuple with the Outputs field value +// and a boolean to check if the value has been set. +func (o *OifQuotePreviewDto) GetOutputsOk() ([]OifQuotePreviewDtoOutputsInner, bool) { + if o == nil { + return nil, false + } + return o.Outputs, true +} + +// SetOutputs sets field value +func (o *OifQuotePreviewDto) SetOutputs(v []OifQuotePreviewDtoOutputsInner) { + o.Outputs = v +} + +func (o OifQuotePreviewDto) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o OifQuotePreviewDto) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["inputs"] = o.Inputs + toSerialize["outputs"] = o.Outputs + return toSerialize, nil +} + +func (o *OifQuotePreviewDto) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "inputs", + "outputs", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varOifQuotePreviewDto := _OifQuotePreviewDto{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varOifQuotePreviewDto) + + if err != nil { + return err + } + + *o = OifQuotePreviewDto(varOifQuotePreviewDto) + + return err +} + +type NullableOifQuotePreviewDto struct { + value *OifQuotePreviewDto + isSet bool +} + +func (v NullableOifQuotePreviewDto) Get() *OifQuotePreviewDto { + return v.value +} + +func (v *NullableOifQuotePreviewDto) Set(val *OifQuotePreviewDto) { + v.value = val + v.isSet = true +} + +func (v NullableOifQuotePreviewDto) IsSet() bool { + return v.isSet +} + +func (v *NullableOifQuotePreviewDto) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOifQuotePreviewDto(val *OifQuotePreviewDto) *NullableOifQuotePreviewDto { + return &NullableOifQuotePreviewDto{value: val, isSet: true} +} + +func (v NullableOifQuotePreviewDto) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOifQuotePreviewDto) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_quote_preview_dto_inputs_inner.go b/api/lifiorder/model_oif_quote_preview_dto_inputs_inner.go similarity index 57% rename from api/lifiorder/model_quote_preview_dto_inputs_inner.go rename to api/lifiorder/model_oif_quote_preview_dto_inputs_inner.go index ba265001..3d4d33a9 100644 --- a/api/lifiorder/model_quote_preview_dto_inputs_inner.go +++ b/api/lifiorder/model_oif_quote_preview_dto_inputs_inner.go @@ -14,35 +14,35 @@ import ( "encoding/json" ) -// checks if the QuotePreviewDtoInputsInner type satisfies the MappedNullable interface at compile time -var _ MappedNullable = &QuotePreviewDtoInputsInner{} +// checks if the OifQuotePreviewDtoInputsInner type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OifQuotePreviewDtoInputsInner{} -// QuotePreviewDtoInputsInner struct for QuotePreviewDtoInputsInner -type QuotePreviewDtoInputsInner struct { +// OifQuotePreviewDtoInputsInner struct for OifQuotePreviewDtoInputsInner +type OifQuotePreviewDtoInputsInner struct { User *string `json:"user,omitempty"` Asset *string `json:"asset,omitempty"` Amount *string `json:"amount,omitempty"` } -// NewQuotePreviewDtoInputsInner instantiates a new QuotePreviewDtoInputsInner object +// NewOifQuotePreviewDtoInputsInner instantiates a new OifQuotePreviewDtoInputsInner object // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewQuotePreviewDtoInputsInner() *QuotePreviewDtoInputsInner { - this := QuotePreviewDtoInputsInner{} +func NewOifQuotePreviewDtoInputsInner() *OifQuotePreviewDtoInputsInner { + this := OifQuotePreviewDtoInputsInner{} return &this } -// NewQuotePreviewDtoInputsInnerWithDefaults instantiates a new QuotePreviewDtoInputsInner object +// NewOifQuotePreviewDtoInputsInnerWithDefaults instantiates a new OifQuotePreviewDtoInputsInner object // This constructor will only assign default values to properties that have it defined, // but it doesn't guarantee that properties required by API are set -func NewQuotePreviewDtoInputsInnerWithDefaults() *QuotePreviewDtoInputsInner { - this := QuotePreviewDtoInputsInner{} +func NewOifQuotePreviewDtoInputsInnerWithDefaults() *OifQuotePreviewDtoInputsInner { + this := OifQuotePreviewDtoInputsInner{} return &this } // GetUser returns the User field value if set, zero value otherwise. -func (o *QuotePreviewDtoInputsInner) GetUser() string { +func (o *OifQuotePreviewDtoInputsInner) GetUser() string { if o == nil || IsNil(o.User) { var ret string return ret @@ -52,7 +52,7 @@ func (o *QuotePreviewDtoInputsInner) GetUser() string { // GetUserOk returns a tuple with the User field value if set, nil otherwise // and a boolean to check if the value has been set. -func (o *QuotePreviewDtoInputsInner) GetUserOk() (*string, bool) { +func (o *OifQuotePreviewDtoInputsInner) GetUserOk() (*string, bool) { if o == nil || IsNil(o.User) { return nil, false } @@ -60,7 +60,7 @@ func (o *QuotePreviewDtoInputsInner) GetUserOk() (*string, bool) { } // HasUser returns a boolean if a field has been set. -func (o *QuotePreviewDtoInputsInner) HasUser() bool { +func (o *OifQuotePreviewDtoInputsInner) HasUser() bool { if o != nil && !IsNil(o.User) { return true } @@ -69,12 +69,12 @@ func (o *QuotePreviewDtoInputsInner) HasUser() bool { } // SetUser gets a reference to the given string and assigns it to the User field. -func (o *QuotePreviewDtoInputsInner) SetUser(v string) { +func (o *OifQuotePreviewDtoInputsInner) SetUser(v string) { o.User = &v } // GetAsset returns the Asset field value if set, zero value otherwise. -func (o *QuotePreviewDtoInputsInner) GetAsset() string { +func (o *OifQuotePreviewDtoInputsInner) GetAsset() string { if o == nil || IsNil(o.Asset) { var ret string return ret @@ -84,7 +84,7 @@ func (o *QuotePreviewDtoInputsInner) GetAsset() string { // GetAssetOk returns a tuple with the Asset field value if set, nil otherwise // and a boolean to check if the value has been set. -func (o *QuotePreviewDtoInputsInner) GetAssetOk() (*string, bool) { +func (o *OifQuotePreviewDtoInputsInner) GetAssetOk() (*string, bool) { if o == nil || IsNil(o.Asset) { return nil, false } @@ -92,7 +92,7 @@ func (o *QuotePreviewDtoInputsInner) GetAssetOk() (*string, bool) { } // HasAsset returns a boolean if a field has been set. -func (o *QuotePreviewDtoInputsInner) HasAsset() bool { +func (o *OifQuotePreviewDtoInputsInner) HasAsset() bool { if o != nil && !IsNil(o.Asset) { return true } @@ -101,12 +101,12 @@ func (o *QuotePreviewDtoInputsInner) HasAsset() bool { } // SetAsset gets a reference to the given string and assigns it to the Asset field. -func (o *QuotePreviewDtoInputsInner) SetAsset(v string) { +func (o *OifQuotePreviewDtoInputsInner) SetAsset(v string) { o.Asset = &v } // GetAmount returns the Amount field value if set, zero value otherwise. -func (o *QuotePreviewDtoInputsInner) GetAmount() string { +func (o *OifQuotePreviewDtoInputsInner) GetAmount() string { if o == nil || IsNil(o.Amount) { var ret string return ret @@ -116,7 +116,7 @@ func (o *QuotePreviewDtoInputsInner) GetAmount() string { // GetAmountOk returns a tuple with the Amount field value if set, nil otherwise // and a boolean to check if the value has been set. -func (o *QuotePreviewDtoInputsInner) GetAmountOk() (*string, bool) { +func (o *OifQuotePreviewDtoInputsInner) GetAmountOk() (*string, bool) { if o == nil || IsNil(o.Amount) { return nil, false } @@ -124,7 +124,7 @@ func (o *QuotePreviewDtoInputsInner) GetAmountOk() (*string, bool) { } // HasAmount returns a boolean if a field has been set. -func (o *QuotePreviewDtoInputsInner) HasAmount() bool { +func (o *OifQuotePreviewDtoInputsInner) HasAmount() bool { if o != nil && !IsNil(o.Amount) { return true } @@ -133,11 +133,11 @@ func (o *QuotePreviewDtoInputsInner) HasAmount() bool { } // SetAmount gets a reference to the given string and assigns it to the Amount field. -func (o *QuotePreviewDtoInputsInner) SetAmount(v string) { +func (o *OifQuotePreviewDtoInputsInner) SetAmount(v string) { o.Amount = &v } -func (o QuotePreviewDtoInputsInner) MarshalJSON() ([]byte, error) { +func (o OifQuotePreviewDtoInputsInner) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { return []byte{}, err @@ -145,7 +145,7 @@ func (o QuotePreviewDtoInputsInner) MarshalJSON() ([]byte, error) { return json.Marshal(toSerialize) } -func (o QuotePreviewDtoInputsInner) ToMap() (map[string]interface{}, error) { +func (o OifQuotePreviewDtoInputsInner) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} if !IsNil(o.User) { toSerialize["user"] = o.User @@ -159,38 +159,38 @@ func (o QuotePreviewDtoInputsInner) ToMap() (map[string]interface{}, error) { return toSerialize, nil } -type NullableQuotePreviewDtoInputsInner struct { - value *QuotePreviewDtoInputsInner +type NullableOifQuotePreviewDtoInputsInner struct { + value *OifQuotePreviewDtoInputsInner isSet bool } -func (v NullableQuotePreviewDtoInputsInner) Get() *QuotePreviewDtoInputsInner { +func (v NullableOifQuotePreviewDtoInputsInner) Get() *OifQuotePreviewDtoInputsInner { return v.value } -func (v *NullableQuotePreviewDtoInputsInner) Set(val *QuotePreviewDtoInputsInner) { +func (v *NullableOifQuotePreviewDtoInputsInner) Set(val *OifQuotePreviewDtoInputsInner) { v.value = val v.isSet = true } -func (v NullableQuotePreviewDtoInputsInner) IsSet() bool { +func (v NullableOifQuotePreviewDtoInputsInner) IsSet() bool { return v.isSet } -func (v *NullableQuotePreviewDtoInputsInner) Unset() { +func (v *NullableOifQuotePreviewDtoInputsInner) Unset() { v.value = nil v.isSet = false } -func NewNullableQuotePreviewDtoInputsInner(val *QuotePreviewDtoInputsInner) *NullableQuotePreviewDtoInputsInner { - return &NullableQuotePreviewDtoInputsInner{value: val, isSet: true} +func NewNullableOifQuotePreviewDtoInputsInner(val *OifQuotePreviewDtoInputsInner) *NullableOifQuotePreviewDtoInputsInner { + return &NullableOifQuotePreviewDtoInputsInner{value: val, isSet: true} } -func (v NullableQuotePreviewDtoInputsInner) MarshalJSON() ([]byte, error) { +func (v NullableOifQuotePreviewDtoInputsInner) MarshalJSON() ([]byte, error) { return json.Marshal(v.value) } -func (v *NullableQuotePreviewDtoInputsInner) UnmarshalJSON(src []byte) error { +func (v *NullableOifQuotePreviewDtoInputsInner) UnmarshalJSON(src []byte) error { v.isSet = true return json.Unmarshal(src, &v.value) } diff --git a/api/lifiorder/model_quote_preview_dto_outputs_inner.go b/api/lifiorder/model_oif_quote_preview_dto_outputs_inner.go similarity index 57% rename from api/lifiorder/model_quote_preview_dto_outputs_inner.go rename to api/lifiorder/model_oif_quote_preview_dto_outputs_inner.go index b5f2ecd0..f986d5fc 100644 --- a/api/lifiorder/model_quote_preview_dto_outputs_inner.go +++ b/api/lifiorder/model_oif_quote_preview_dto_outputs_inner.go @@ -14,35 +14,35 @@ import ( "encoding/json" ) -// checks if the QuotePreviewDtoOutputsInner type satisfies the MappedNullable interface at compile time -var _ MappedNullable = &QuotePreviewDtoOutputsInner{} +// checks if the OifQuotePreviewDtoOutputsInner type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OifQuotePreviewDtoOutputsInner{} -// QuotePreviewDtoOutputsInner struct for QuotePreviewDtoOutputsInner -type QuotePreviewDtoOutputsInner struct { +// OifQuotePreviewDtoOutputsInner struct for OifQuotePreviewDtoOutputsInner +type OifQuotePreviewDtoOutputsInner struct { Receiver *string `json:"receiver,omitempty"` Asset *string `json:"asset,omitempty"` Amount *string `json:"amount,omitempty"` } -// NewQuotePreviewDtoOutputsInner instantiates a new QuotePreviewDtoOutputsInner object +// NewOifQuotePreviewDtoOutputsInner instantiates a new OifQuotePreviewDtoOutputsInner object // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewQuotePreviewDtoOutputsInner() *QuotePreviewDtoOutputsInner { - this := QuotePreviewDtoOutputsInner{} +func NewOifQuotePreviewDtoOutputsInner() *OifQuotePreviewDtoOutputsInner { + this := OifQuotePreviewDtoOutputsInner{} return &this } -// NewQuotePreviewDtoOutputsInnerWithDefaults instantiates a new QuotePreviewDtoOutputsInner object +// NewOifQuotePreviewDtoOutputsInnerWithDefaults instantiates a new OifQuotePreviewDtoOutputsInner object // This constructor will only assign default values to properties that have it defined, // but it doesn't guarantee that properties required by API are set -func NewQuotePreviewDtoOutputsInnerWithDefaults() *QuotePreviewDtoOutputsInner { - this := QuotePreviewDtoOutputsInner{} +func NewOifQuotePreviewDtoOutputsInnerWithDefaults() *OifQuotePreviewDtoOutputsInner { + this := OifQuotePreviewDtoOutputsInner{} return &this } // GetReceiver returns the Receiver field value if set, zero value otherwise. -func (o *QuotePreviewDtoOutputsInner) GetReceiver() string { +func (o *OifQuotePreviewDtoOutputsInner) GetReceiver() string { if o == nil || IsNil(o.Receiver) { var ret string return ret @@ -52,7 +52,7 @@ func (o *QuotePreviewDtoOutputsInner) GetReceiver() string { // GetReceiverOk returns a tuple with the Receiver field value if set, nil otherwise // and a boolean to check if the value has been set. -func (o *QuotePreviewDtoOutputsInner) GetReceiverOk() (*string, bool) { +func (o *OifQuotePreviewDtoOutputsInner) GetReceiverOk() (*string, bool) { if o == nil || IsNil(o.Receiver) { return nil, false } @@ -60,7 +60,7 @@ func (o *QuotePreviewDtoOutputsInner) GetReceiverOk() (*string, bool) { } // HasReceiver returns a boolean if a field has been set. -func (o *QuotePreviewDtoOutputsInner) HasReceiver() bool { +func (o *OifQuotePreviewDtoOutputsInner) HasReceiver() bool { if o != nil && !IsNil(o.Receiver) { return true } @@ -69,12 +69,12 @@ func (o *QuotePreviewDtoOutputsInner) HasReceiver() bool { } // SetReceiver gets a reference to the given string and assigns it to the Receiver field. -func (o *QuotePreviewDtoOutputsInner) SetReceiver(v string) { +func (o *OifQuotePreviewDtoOutputsInner) SetReceiver(v string) { o.Receiver = &v } // GetAsset returns the Asset field value if set, zero value otherwise. -func (o *QuotePreviewDtoOutputsInner) GetAsset() string { +func (o *OifQuotePreviewDtoOutputsInner) GetAsset() string { if o == nil || IsNil(o.Asset) { var ret string return ret @@ -84,7 +84,7 @@ func (o *QuotePreviewDtoOutputsInner) GetAsset() string { // GetAssetOk returns a tuple with the Asset field value if set, nil otherwise // and a boolean to check if the value has been set. -func (o *QuotePreviewDtoOutputsInner) GetAssetOk() (*string, bool) { +func (o *OifQuotePreviewDtoOutputsInner) GetAssetOk() (*string, bool) { if o == nil || IsNil(o.Asset) { return nil, false } @@ -92,7 +92,7 @@ func (o *QuotePreviewDtoOutputsInner) GetAssetOk() (*string, bool) { } // HasAsset returns a boolean if a field has been set. -func (o *QuotePreviewDtoOutputsInner) HasAsset() bool { +func (o *OifQuotePreviewDtoOutputsInner) HasAsset() bool { if o != nil && !IsNil(o.Asset) { return true } @@ -101,12 +101,12 @@ func (o *QuotePreviewDtoOutputsInner) HasAsset() bool { } // SetAsset gets a reference to the given string and assigns it to the Asset field. -func (o *QuotePreviewDtoOutputsInner) SetAsset(v string) { +func (o *OifQuotePreviewDtoOutputsInner) SetAsset(v string) { o.Asset = &v } // GetAmount returns the Amount field value if set, zero value otherwise. -func (o *QuotePreviewDtoOutputsInner) GetAmount() string { +func (o *OifQuotePreviewDtoOutputsInner) GetAmount() string { if o == nil || IsNil(o.Amount) { var ret string return ret @@ -116,7 +116,7 @@ func (o *QuotePreviewDtoOutputsInner) GetAmount() string { // GetAmountOk returns a tuple with the Amount field value if set, nil otherwise // and a boolean to check if the value has been set. -func (o *QuotePreviewDtoOutputsInner) GetAmountOk() (*string, bool) { +func (o *OifQuotePreviewDtoOutputsInner) GetAmountOk() (*string, bool) { if o == nil || IsNil(o.Amount) { return nil, false } @@ -124,7 +124,7 @@ func (o *QuotePreviewDtoOutputsInner) GetAmountOk() (*string, bool) { } // HasAmount returns a boolean if a field has been set. -func (o *QuotePreviewDtoOutputsInner) HasAmount() bool { +func (o *OifQuotePreviewDtoOutputsInner) HasAmount() bool { if o != nil && !IsNil(o.Amount) { return true } @@ -133,11 +133,11 @@ func (o *QuotePreviewDtoOutputsInner) HasAmount() bool { } // SetAmount gets a reference to the given string and assigns it to the Amount field. -func (o *QuotePreviewDtoOutputsInner) SetAmount(v string) { +func (o *OifQuotePreviewDtoOutputsInner) SetAmount(v string) { o.Amount = &v } -func (o QuotePreviewDtoOutputsInner) MarshalJSON() ([]byte, error) { +func (o OifQuotePreviewDtoOutputsInner) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { return []byte{}, err @@ -145,7 +145,7 @@ func (o QuotePreviewDtoOutputsInner) MarshalJSON() ([]byte, error) { return json.Marshal(toSerialize) } -func (o QuotePreviewDtoOutputsInner) ToMap() (map[string]interface{}, error) { +func (o OifQuotePreviewDtoOutputsInner) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} if !IsNil(o.Receiver) { toSerialize["receiver"] = o.Receiver @@ -159,38 +159,38 @@ func (o QuotePreviewDtoOutputsInner) ToMap() (map[string]interface{}, error) { return toSerialize, nil } -type NullableQuotePreviewDtoOutputsInner struct { - value *QuotePreviewDtoOutputsInner +type NullableOifQuotePreviewDtoOutputsInner struct { + value *OifQuotePreviewDtoOutputsInner isSet bool } -func (v NullableQuotePreviewDtoOutputsInner) Get() *QuotePreviewDtoOutputsInner { +func (v NullableOifQuotePreviewDtoOutputsInner) Get() *OifQuotePreviewDtoOutputsInner { return v.value } -func (v *NullableQuotePreviewDtoOutputsInner) Set(val *QuotePreviewDtoOutputsInner) { +func (v *NullableOifQuotePreviewDtoOutputsInner) Set(val *OifQuotePreviewDtoOutputsInner) { v.value = val v.isSet = true } -func (v NullableQuotePreviewDtoOutputsInner) IsSet() bool { +func (v NullableOifQuotePreviewDtoOutputsInner) IsSet() bool { return v.isSet } -func (v *NullableQuotePreviewDtoOutputsInner) Unset() { +func (v *NullableOifQuotePreviewDtoOutputsInner) Unset() { v.value = nil v.isSet = false } -func NewNullableQuotePreviewDtoOutputsInner(val *QuotePreviewDtoOutputsInner) *NullableQuotePreviewDtoOutputsInner { - return &NullableQuotePreviewDtoOutputsInner{value: val, isSet: true} +func NewNullableOifQuotePreviewDtoOutputsInner(val *OifQuotePreviewDtoOutputsInner) *NullableOifQuotePreviewDtoOutputsInner { + return &NullableOifQuotePreviewDtoOutputsInner{value: val, isSet: true} } -func (v NullableQuotePreviewDtoOutputsInner) MarshalJSON() ([]byte, error) { +func (v NullableOifQuotePreviewDtoOutputsInner) MarshalJSON() ([]byte, error) { return json.Marshal(v.value) } -func (v *NullableQuotePreviewDtoOutputsInner) UnmarshalJSON(src []byte) error { +func (v *NullableOifQuotePreviewDtoOutputsInner) UnmarshalJSON(src []byte) error { v.isSet = true return json.Unmarshal(src, &v.value) } diff --git a/api/lifiorder/model_oif_user_open_intent_order_dto.go b/api/lifiorder/model_oif_user_open_intent_order_dto.go new file mode 100644 index 00000000..34f14107 --- /dev/null +++ b/api/lifiorder/model_oif_user_open_intent_order_dto.go @@ -0,0 +1,214 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the OifUserOpenIntentOrderDto type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OifUserOpenIntentOrderDto{} + +// OifUserOpenIntentOrderDto struct for OifUserOpenIntentOrderDto +type OifUserOpenIntentOrderDto struct { + // Order type identifier for user open intent execution + Type string `json:"type"` + OpenIntentTx OifUserOpenIntentOrderDtoOpenIntentTx `json:"openIntentTx"` + // Allowance and balance checks that must hold prior to execution. For Solana origins this array is empty; SPL transfers happen inside the open instruction. + Checks ChecksDto `json:"checks"` +} + +type _OifUserOpenIntentOrderDto OifUserOpenIntentOrderDto + +// NewOifUserOpenIntentOrderDto instantiates a new OifUserOpenIntentOrderDto object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewOifUserOpenIntentOrderDto(type_ string, openIntentTx OifUserOpenIntentOrderDtoOpenIntentTx, checks ChecksDto) *OifUserOpenIntentOrderDto { + this := OifUserOpenIntentOrderDto{} + this.Type = type_ + this.OpenIntentTx = openIntentTx + this.Checks = checks + return &this +} + +// NewOifUserOpenIntentOrderDtoWithDefaults instantiates a new OifUserOpenIntentOrderDto object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewOifUserOpenIntentOrderDtoWithDefaults() *OifUserOpenIntentOrderDto { + this := OifUserOpenIntentOrderDto{} + return &this +} + +// GetType returns the Type field value +func (o *OifUserOpenIntentOrderDto) GetType() string { + if o == nil { + var ret string + return ret + } + + return o.Type +} + +// GetTypeOk returns a tuple with the Type field value +// and a boolean to check if the value has been set. +func (o *OifUserOpenIntentOrderDto) GetTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Type, true +} + +// SetType sets field value +func (o *OifUserOpenIntentOrderDto) SetType(v string) { + o.Type = v +} + +// GetOpenIntentTx returns the OpenIntentTx field value +func (o *OifUserOpenIntentOrderDto) GetOpenIntentTx() OifUserOpenIntentOrderDtoOpenIntentTx { + if o == nil { + var ret OifUserOpenIntentOrderDtoOpenIntentTx + return ret + } + + return o.OpenIntentTx +} + +// GetOpenIntentTxOk returns a tuple with the OpenIntentTx field value +// and a boolean to check if the value has been set. +func (o *OifUserOpenIntentOrderDto) GetOpenIntentTxOk() (*OifUserOpenIntentOrderDtoOpenIntentTx, bool) { + if o == nil { + return nil, false + } + return &o.OpenIntentTx, true +} + +// SetOpenIntentTx sets field value +func (o *OifUserOpenIntentOrderDto) SetOpenIntentTx(v OifUserOpenIntentOrderDtoOpenIntentTx) { + o.OpenIntentTx = v +} + +// GetChecks returns the Checks field value +func (o *OifUserOpenIntentOrderDto) GetChecks() ChecksDto { + if o == nil { + var ret ChecksDto + return ret + } + + return o.Checks +} + +// GetChecksOk returns a tuple with the Checks field value +// and a boolean to check if the value has been set. +func (o *OifUserOpenIntentOrderDto) GetChecksOk() (*ChecksDto, bool) { + if o == nil { + return nil, false + } + return &o.Checks, true +} + +// SetChecks sets field value +func (o *OifUserOpenIntentOrderDto) SetChecks(v ChecksDto) { + o.Checks = v +} + +func (o OifUserOpenIntentOrderDto) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o OifUserOpenIntentOrderDto) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["type"] = o.Type + toSerialize["openIntentTx"] = o.OpenIntentTx + toSerialize["checks"] = o.Checks + return toSerialize, nil +} + +func (o *OifUserOpenIntentOrderDto) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "type", + "openIntentTx", + "checks", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varOifUserOpenIntentOrderDto := _OifUserOpenIntentOrderDto{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varOifUserOpenIntentOrderDto) + + if err != nil { + return err + } + + *o = OifUserOpenIntentOrderDto(varOifUserOpenIntentOrderDto) + + return err +} + +type NullableOifUserOpenIntentOrderDto struct { + value *OifUserOpenIntentOrderDto + isSet bool +} + +func (v NullableOifUserOpenIntentOrderDto) Get() *OifUserOpenIntentOrderDto { + return v.value +} + +func (v *NullableOifUserOpenIntentOrderDto) Set(val *OifUserOpenIntentOrderDto) { + v.value = val + v.isSet = true +} + +func (v NullableOifUserOpenIntentOrderDto) IsSet() bool { + return v.isSet +} + +func (v *NullableOifUserOpenIntentOrderDto) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOifUserOpenIntentOrderDto(val *OifUserOpenIntentOrderDto) *NullableOifUserOpenIntentOrderDto { + return &NullableOifUserOpenIntentOrderDto{value: val, isSet: true} +} + +func (v NullableOifUserOpenIntentOrderDto) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOifUserOpenIntentOrderDto) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_oif_user_open_intent_order_dto_open_intent_tx.go b/api/lifiorder/model_oif_user_open_intent_order_dto_open_intent_tx.go new file mode 100644 index 00000000..edc666d5 --- /dev/null +++ b/api/lifiorder/model_oif_user_open_intent_order_dto_open_intent_tx.go @@ -0,0 +1,206 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "encoding/json" + "fmt" + "gopkg.in/validator.v2" +) + +// OifUserOpenIntentOrderDtoOpenIntentTx - Open intent transaction. EVM produces hex calldata; Solana produces a base58 serialized VersionedTransaction whose recentBlockhash must be overwritten before signing; Tron produces hex calldata the client wraps in a TriggerSmartContract envelope. +type OifUserOpenIntentOrderDtoOpenIntentTx struct { + OpenIntentEvmTxDto *OpenIntentEvmTxDto + OpenIntentSvmTxDto *OpenIntentSvmTxDto + OpenIntentTronTxDto *OpenIntentTronTxDto +} + +// OpenIntentEvmTxDtoAsOifUserOpenIntentOrderDtoOpenIntentTx is a convenience function that returns OpenIntentEvmTxDto wrapped in OifUserOpenIntentOrderDtoOpenIntentTx +func OpenIntentEvmTxDtoAsOifUserOpenIntentOrderDtoOpenIntentTx(v *OpenIntentEvmTxDto) OifUserOpenIntentOrderDtoOpenIntentTx { + return OifUserOpenIntentOrderDtoOpenIntentTx{ + OpenIntentEvmTxDto: v, + } +} + +// OpenIntentSvmTxDtoAsOifUserOpenIntentOrderDtoOpenIntentTx is a convenience function that returns OpenIntentSvmTxDto wrapped in OifUserOpenIntentOrderDtoOpenIntentTx +func OpenIntentSvmTxDtoAsOifUserOpenIntentOrderDtoOpenIntentTx(v *OpenIntentSvmTxDto) OifUserOpenIntentOrderDtoOpenIntentTx { + return OifUserOpenIntentOrderDtoOpenIntentTx{ + OpenIntentSvmTxDto: v, + } +} + +// OpenIntentTronTxDtoAsOifUserOpenIntentOrderDtoOpenIntentTx is a convenience function that returns OpenIntentTronTxDto wrapped in OifUserOpenIntentOrderDtoOpenIntentTx +func OpenIntentTronTxDtoAsOifUserOpenIntentOrderDtoOpenIntentTx(v *OpenIntentTronTxDto) OifUserOpenIntentOrderDtoOpenIntentTx { + return OifUserOpenIntentOrderDtoOpenIntentTx{ + OpenIntentTronTxDto: v, + } +} + +// Unmarshal JSON data into one of the pointers in the struct +func (dst *OifUserOpenIntentOrderDtoOpenIntentTx) UnmarshalJSON(data []byte) error { + var err error + match := 0 + // try to unmarshal data into OpenIntentEvmTxDto + err = newStrictDecoder(data).Decode(&dst.OpenIntentEvmTxDto) + if err == nil { + jsonOpenIntentEvmTxDto, _ := json.Marshal(dst.OpenIntentEvmTxDto) + if string(jsonOpenIntentEvmTxDto) == "{}" { // empty struct + dst.OpenIntentEvmTxDto = nil + } else { + if err = validator.Validate(dst.OpenIntentEvmTxDto); err != nil { + dst.OpenIntentEvmTxDto = nil + } else { + match++ + } + } + } else { + dst.OpenIntentEvmTxDto = nil + } + + // try to unmarshal data into OpenIntentSvmTxDto + err = newStrictDecoder(data).Decode(&dst.OpenIntentSvmTxDto) + if err == nil { + jsonOpenIntentSvmTxDto, _ := json.Marshal(dst.OpenIntentSvmTxDto) + if string(jsonOpenIntentSvmTxDto) == "{}" { // empty struct + dst.OpenIntentSvmTxDto = nil + } else { + if err = validator.Validate(dst.OpenIntentSvmTxDto); err != nil { + dst.OpenIntentSvmTxDto = nil + } else { + match++ + } + } + } else { + dst.OpenIntentSvmTxDto = nil + } + + // try to unmarshal data into OpenIntentTronTxDto + err = newStrictDecoder(data).Decode(&dst.OpenIntentTronTxDto) + if err == nil { + jsonOpenIntentTronTxDto, _ := json.Marshal(dst.OpenIntentTronTxDto) + if string(jsonOpenIntentTronTxDto) == "{}" { // empty struct + dst.OpenIntentTronTxDto = nil + } else { + if err = validator.Validate(dst.OpenIntentTronTxDto); err != nil { + dst.OpenIntentTronTxDto = nil + } else { + match++ + } + } + } else { + dst.OpenIntentTronTxDto = nil + } + + if match > 1 { // more than 1 match + // reset to nil + dst.OpenIntentEvmTxDto = nil + dst.OpenIntentSvmTxDto = nil + dst.OpenIntentTronTxDto = nil + + return fmt.Errorf("data matches more than one schema in oneOf(OifUserOpenIntentOrderDtoOpenIntentTx)") + } else if match == 1 { + return nil // exactly one match + } else { // no match + return fmt.Errorf("data failed to match schemas in oneOf(OifUserOpenIntentOrderDtoOpenIntentTx)") + } +} + +// Marshal data from the first non-nil pointers in the struct to JSON +func (src OifUserOpenIntentOrderDtoOpenIntentTx) MarshalJSON() ([]byte, error) { + if src.OpenIntentEvmTxDto != nil { + return json.Marshal(&src.OpenIntentEvmTxDto) + } + + if src.OpenIntentSvmTxDto != nil { + return json.Marshal(&src.OpenIntentSvmTxDto) + } + + if src.OpenIntentTronTxDto != nil { + return json.Marshal(&src.OpenIntentTronTxDto) + } + + return nil, nil // no data in oneOf schemas +} + +// Get the actual instance +func (obj *OifUserOpenIntentOrderDtoOpenIntentTx) GetActualInstance() interface{} { + if obj == nil { + return nil + } + if obj.OpenIntentEvmTxDto != nil { + return obj.OpenIntentEvmTxDto + } + + if obj.OpenIntentSvmTxDto != nil { + return obj.OpenIntentSvmTxDto + } + + if obj.OpenIntentTronTxDto != nil { + return obj.OpenIntentTronTxDto + } + + // all schemas are nil + return nil +} + +// Get the actual instance value +func (obj OifUserOpenIntentOrderDtoOpenIntentTx) GetActualInstanceValue() interface{} { + if obj.OpenIntentEvmTxDto != nil { + return *obj.OpenIntentEvmTxDto + } + + if obj.OpenIntentSvmTxDto != nil { + return *obj.OpenIntentSvmTxDto + } + + if obj.OpenIntentTronTxDto != nil { + return *obj.OpenIntentTronTxDto + } + + // all schemas are nil + return nil +} + +type NullableOifUserOpenIntentOrderDtoOpenIntentTx struct { + value *OifUserOpenIntentOrderDtoOpenIntentTx + isSet bool +} + +func (v NullableOifUserOpenIntentOrderDtoOpenIntentTx) Get() *OifUserOpenIntentOrderDtoOpenIntentTx { + return v.value +} + +func (v *NullableOifUserOpenIntentOrderDtoOpenIntentTx) Set(val *OifUserOpenIntentOrderDtoOpenIntentTx) { + v.value = val + v.isSet = true +} + +func (v NullableOifUserOpenIntentOrderDtoOpenIntentTx) IsSet() bool { + return v.isSet +} + +func (v *NullableOifUserOpenIntentOrderDtoOpenIntentTx) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOifUserOpenIntentOrderDtoOpenIntentTx(val *OifUserOpenIntentOrderDtoOpenIntentTx) *NullableOifUserOpenIntentOrderDtoOpenIntentTx { + return &NullableOifUserOpenIntentOrderDtoOpenIntentTx{value: val, isSet: true} +} + +func (v NullableOifUserOpenIntentOrderDtoOpenIntentTx) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOifUserOpenIntentOrderDtoOpenIntentTx) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_open_intent_evm_tx_dto.go b/api/lifiorder/model_open_intent_evm_tx_dto.go new file mode 100644 index 00000000..9fec31fb --- /dev/null +++ b/api/lifiorder/model_open_intent_evm_tx_dto.go @@ -0,0 +1,244 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the OpenIntentEvmTxDto type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OpenIntentEvmTxDto{} + +// OpenIntentEvmTxDto struct for OpenIntentEvmTxDto +type OpenIntentEvmTxDto struct { + // CAIP-2 chain identifier for the destination contract + Chain string `json:"chain"` + // Destination contract address (checksummed hex) + To string `json:"to"` + // Transaction calldata as hex string + Data string `json:"data"` + // Gas required for execution as a decimal string + GasRequired string `json:"gasRequired"` +} + +type _OpenIntentEvmTxDto OpenIntentEvmTxDto + +// NewOpenIntentEvmTxDto instantiates a new OpenIntentEvmTxDto object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewOpenIntentEvmTxDto(chain string, to string, data string, gasRequired string) *OpenIntentEvmTxDto { + this := OpenIntentEvmTxDto{} + this.Chain = chain + this.To = to + this.Data = data + this.GasRequired = gasRequired + return &this +} + +// NewOpenIntentEvmTxDtoWithDefaults instantiates a new OpenIntentEvmTxDto object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewOpenIntentEvmTxDtoWithDefaults() *OpenIntentEvmTxDto { + this := OpenIntentEvmTxDto{} + return &this +} + +// GetChain returns the Chain field value +func (o *OpenIntentEvmTxDto) GetChain() string { + if o == nil { + var ret string + return ret + } + + return o.Chain +} + +// GetChainOk returns a tuple with the Chain field value +// and a boolean to check if the value has been set. +func (o *OpenIntentEvmTxDto) GetChainOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Chain, true +} + +// SetChain sets field value +func (o *OpenIntentEvmTxDto) SetChain(v string) { + o.Chain = v +} + +// GetTo returns the To field value +func (o *OpenIntentEvmTxDto) GetTo() string { + if o == nil { + var ret string + return ret + } + + return o.To +} + +// GetToOk returns a tuple with the To field value +// and a boolean to check if the value has been set. +func (o *OpenIntentEvmTxDto) GetToOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.To, true +} + +// SetTo sets field value +func (o *OpenIntentEvmTxDto) SetTo(v string) { + o.To = v +} + +// GetData returns the Data field value +func (o *OpenIntentEvmTxDto) GetData() string { + if o == nil { + var ret string + return ret + } + + return o.Data +} + +// GetDataOk returns a tuple with the Data field value +// and a boolean to check if the value has been set. +func (o *OpenIntentEvmTxDto) GetDataOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Data, true +} + +// SetData sets field value +func (o *OpenIntentEvmTxDto) SetData(v string) { + o.Data = v +} + +// GetGasRequired returns the GasRequired field value +func (o *OpenIntentEvmTxDto) GetGasRequired() string { + if o == nil { + var ret string + return ret + } + + return o.GasRequired +} + +// GetGasRequiredOk returns a tuple with the GasRequired field value +// and a boolean to check if the value has been set. +func (o *OpenIntentEvmTxDto) GetGasRequiredOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.GasRequired, true +} + +// SetGasRequired sets field value +func (o *OpenIntentEvmTxDto) SetGasRequired(v string) { + o.GasRequired = v +} + +func (o OpenIntentEvmTxDto) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o OpenIntentEvmTxDto) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["chain"] = o.Chain + toSerialize["to"] = o.To + toSerialize["data"] = o.Data + toSerialize["gasRequired"] = o.GasRequired + return toSerialize, nil +} + +func (o *OpenIntentEvmTxDto) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "chain", + "to", + "data", + "gasRequired", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varOpenIntentEvmTxDto := _OpenIntentEvmTxDto{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varOpenIntentEvmTxDto) + + if err != nil { + return err + } + + *o = OpenIntentEvmTxDto(varOpenIntentEvmTxDto) + + return err +} + +type NullableOpenIntentEvmTxDto struct { + value *OpenIntentEvmTxDto + isSet bool +} + +func (v NullableOpenIntentEvmTxDto) Get() *OpenIntentEvmTxDto { + return v.value +} + +func (v *NullableOpenIntentEvmTxDto) Set(val *OpenIntentEvmTxDto) { + v.value = val + v.isSet = true +} + +func (v NullableOpenIntentEvmTxDto) IsSet() bool { + return v.isSet +} + +func (v *NullableOpenIntentEvmTxDto) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOpenIntentEvmTxDto(val *OpenIntentEvmTxDto) *NullableOpenIntentEvmTxDto { + return &NullableOpenIntentEvmTxDto{value: val, isSet: true} +} + +func (v NullableOpenIntentEvmTxDto) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOpenIntentEvmTxDto) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_open_intent_svm_tx_dto.go b/api/lifiorder/model_open_intent_svm_tx_dto.go new file mode 100644 index 00000000..b0bd2fc2 --- /dev/null +++ b/api/lifiorder/model_open_intent_svm_tx_dto.go @@ -0,0 +1,244 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the OpenIntentSvmTxDto type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OpenIntentSvmTxDto{} + +// OpenIntentSvmTxDto struct for OpenIntentSvmTxDto +type OpenIntentSvmTxDto struct { + // CAIP-2 chain identifier (Solana namespace) + Chain string `json:"chain"` + // Input settler program ID (base58) + To string `json:"to"` + // Base58-encoded serialized VersionedTransaction. The dummy all-zeros recentBlockhash must be replaced with a fresh blockhash before signing. + Data string `json:"data"` + // Estimated compute units (decimal string) + ComputeUnitsRequired string `json:"computeUnitsRequired"` +} + +type _OpenIntentSvmTxDto OpenIntentSvmTxDto + +// NewOpenIntentSvmTxDto instantiates a new OpenIntentSvmTxDto object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewOpenIntentSvmTxDto(chain string, to string, data string, computeUnitsRequired string) *OpenIntentSvmTxDto { + this := OpenIntentSvmTxDto{} + this.Chain = chain + this.To = to + this.Data = data + this.ComputeUnitsRequired = computeUnitsRequired + return &this +} + +// NewOpenIntentSvmTxDtoWithDefaults instantiates a new OpenIntentSvmTxDto object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewOpenIntentSvmTxDtoWithDefaults() *OpenIntentSvmTxDto { + this := OpenIntentSvmTxDto{} + return &this +} + +// GetChain returns the Chain field value +func (o *OpenIntentSvmTxDto) GetChain() string { + if o == nil { + var ret string + return ret + } + + return o.Chain +} + +// GetChainOk returns a tuple with the Chain field value +// and a boolean to check if the value has been set. +func (o *OpenIntentSvmTxDto) GetChainOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Chain, true +} + +// SetChain sets field value +func (o *OpenIntentSvmTxDto) SetChain(v string) { + o.Chain = v +} + +// GetTo returns the To field value +func (o *OpenIntentSvmTxDto) GetTo() string { + if o == nil { + var ret string + return ret + } + + return o.To +} + +// GetToOk returns a tuple with the To field value +// and a boolean to check if the value has been set. +func (o *OpenIntentSvmTxDto) GetToOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.To, true +} + +// SetTo sets field value +func (o *OpenIntentSvmTxDto) SetTo(v string) { + o.To = v +} + +// GetData returns the Data field value +func (o *OpenIntentSvmTxDto) GetData() string { + if o == nil { + var ret string + return ret + } + + return o.Data +} + +// GetDataOk returns a tuple with the Data field value +// and a boolean to check if the value has been set. +func (o *OpenIntentSvmTxDto) GetDataOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Data, true +} + +// SetData sets field value +func (o *OpenIntentSvmTxDto) SetData(v string) { + o.Data = v +} + +// GetComputeUnitsRequired returns the ComputeUnitsRequired field value +func (o *OpenIntentSvmTxDto) GetComputeUnitsRequired() string { + if o == nil { + var ret string + return ret + } + + return o.ComputeUnitsRequired +} + +// GetComputeUnitsRequiredOk returns a tuple with the ComputeUnitsRequired field value +// and a boolean to check if the value has been set. +func (o *OpenIntentSvmTxDto) GetComputeUnitsRequiredOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.ComputeUnitsRequired, true +} + +// SetComputeUnitsRequired sets field value +func (o *OpenIntentSvmTxDto) SetComputeUnitsRequired(v string) { + o.ComputeUnitsRequired = v +} + +func (o OpenIntentSvmTxDto) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o OpenIntentSvmTxDto) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["chain"] = o.Chain + toSerialize["to"] = o.To + toSerialize["data"] = o.Data + toSerialize["computeUnitsRequired"] = o.ComputeUnitsRequired + return toSerialize, nil +} + +func (o *OpenIntentSvmTxDto) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "chain", + "to", + "data", + "computeUnitsRequired", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varOpenIntentSvmTxDto := _OpenIntentSvmTxDto{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varOpenIntentSvmTxDto) + + if err != nil { + return err + } + + *o = OpenIntentSvmTxDto(varOpenIntentSvmTxDto) + + return err +} + +type NullableOpenIntentSvmTxDto struct { + value *OpenIntentSvmTxDto + isSet bool +} + +func (v NullableOpenIntentSvmTxDto) Get() *OpenIntentSvmTxDto { + return v.value +} + +func (v *NullableOpenIntentSvmTxDto) Set(val *OpenIntentSvmTxDto) { + v.value = val + v.isSet = true +} + +func (v NullableOpenIntentSvmTxDto) IsSet() bool { + return v.isSet +} + +func (v *NullableOpenIntentSvmTxDto) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOpenIntentSvmTxDto(val *OpenIntentSvmTxDto) *NullableOpenIntentSvmTxDto { + return &NullableOpenIntentSvmTxDto{value: val, isSet: true} +} + +func (v NullableOpenIntentSvmTxDto) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOpenIntentSvmTxDto) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_open_intent_tron_tx_dto.go b/api/lifiorder/model_open_intent_tron_tx_dto.go new file mode 100644 index 00000000..a13ef04b --- /dev/null +++ b/api/lifiorder/model_open_intent_tron_tx_dto.go @@ -0,0 +1,244 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the OpenIntentTronTxDto type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OpenIntentTronTxDto{} + +// OpenIntentTronTxDto struct for OpenIntentTronTxDto +type OpenIntentTronTxDto struct { + // CAIP-2 chain identifier (Tron namespace) + Chain string `json:"chain"` + // Input settler contract address (base58check) + To string `json:"to"` + // Full ABI calldata (selector + args) as a 0x-prefixed hex string. Pass it as `data` (without the 0x prefix) to the fullnode HTTP endpoint wallet/triggersmartcontract, or with tronweb 6.x as `triggerSmartContract(to, \"\", { feeLimit, input: data }, [], owner)`. + Data string `json:"data"` + // Suggested fee_limit in SUN as a decimal string. A cap on energy spend, not an estimate. + FeeLimit string `json:"feeLimit"` +} + +type _OpenIntentTronTxDto OpenIntentTronTxDto + +// NewOpenIntentTronTxDto instantiates a new OpenIntentTronTxDto object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewOpenIntentTronTxDto(chain string, to string, data string, feeLimit string) *OpenIntentTronTxDto { + this := OpenIntentTronTxDto{} + this.Chain = chain + this.To = to + this.Data = data + this.FeeLimit = feeLimit + return &this +} + +// NewOpenIntentTronTxDtoWithDefaults instantiates a new OpenIntentTronTxDto object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewOpenIntentTronTxDtoWithDefaults() *OpenIntentTronTxDto { + this := OpenIntentTronTxDto{} + return &this +} + +// GetChain returns the Chain field value +func (o *OpenIntentTronTxDto) GetChain() string { + if o == nil { + var ret string + return ret + } + + return o.Chain +} + +// GetChainOk returns a tuple with the Chain field value +// and a boolean to check if the value has been set. +func (o *OpenIntentTronTxDto) GetChainOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Chain, true +} + +// SetChain sets field value +func (o *OpenIntentTronTxDto) SetChain(v string) { + o.Chain = v +} + +// GetTo returns the To field value +func (o *OpenIntentTronTxDto) GetTo() string { + if o == nil { + var ret string + return ret + } + + return o.To +} + +// GetToOk returns a tuple with the To field value +// and a boolean to check if the value has been set. +func (o *OpenIntentTronTxDto) GetToOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.To, true +} + +// SetTo sets field value +func (o *OpenIntentTronTxDto) SetTo(v string) { + o.To = v +} + +// GetData returns the Data field value +func (o *OpenIntentTronTxDto) GetData() string { + if o == nil { + var ret string + return ret + } + + return o.Data +} + +// GetDataOk returns a tuple with the Data field value +// and a boolean to check if the value has been set. +func (o *OpenIntentTronTxDto) GetDataOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Data, true +} + +// SetData sets field value +func (o *OpenIntentTronTxDto) SetData(v string) { + o.Data = v +} + +// GetFeeLimit returns the FeeLimit field value +func (o *OpenIntentTronTxDto) GetFeeLimit() string { + if o == nil { + var ret string + return ret + } + + return o.FeeLimit +} + +// GetFeeLimitOk returns a tuple with the FeeLimit field value +// and a boolean to check if the value has been set. +func (o *OpenIntentTronTxDto) GetFeeLimitOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.FeeLimit, true +} + +// SetFeeLimit sets field value +func (o *OpenIntentTronTxDto) SetFeeLimit(v string) { + o.FeeLimit = v +} + +func (o OpenIntentTronTxDto) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o OpenIntentTronTxDto) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["chain"] = o.Chain + toSerialize["to"] = o.To + toSerialize["data"] = o.Data + toSerialize["feeLimit"] = o.FeeLimit + return toSerialize, nil +} + +func (o *OpenIntentTronTxDto) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "chain", + "to", + "data", + "feeLimit", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varOpenIntentTronTxDto := _OpenIntentTronTxDto{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varOpenIntentTronTxDto) + + if err != nil { + return err + } + + *o = OpenIntentTronTxDto(varOpenIntentTronTxDto) + + return err +} + +type NullableOpenIntentTronTxDto struct { + value *OpenIntentTronTxDto + isSet bool +} + +func (v NullableOpenIntentTronTxDto) Get() *OpenIntentTronTxDto { + return v.value +} + +func (v *NullableOpenIntentTronTxDto) Set(val *OpenIntentTronTxDto) { + v.value = val + v.isSet = true +} + +func (v NullableOpenIntentTronTxDto) IsSet() bool { + return v.isSet +} + +func (v *NullableOpenIntentTronTxDto) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOpenIntentTronTxDto(val *OpenIntentTronTxDto) *NullableOpenIntentTronTxDto { + return &NullableOpenIntentTronTxDto{value: val, isSet: true} +} + +func (v NullableOpenIntentTronTxDto) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOpenIntentTronTxDto) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_order_meta_dto.go b/api/lifiorder/model_order_meta_dto.go index cccf0d99..44c4e335 100644 --- a/api/lifiorder/model_order_meta_dto.go +++ b/api/lifiorder/model_order_meta_dto.go @@ -32,31 +32,31 @@ type OrderMetaDto struct { // Parsed destination address of the order DestinationAddress string `json:"destinationAddress"` // Transaction hash when order was initiated (on-chain order) [eg: Open escrow event] - OrderInitiatedTxHash map[string]interface{} `json:"orderInitiatedTxHash"` + OrderInitiatedTxHash NullableString `json:"orderInitiatedTxHash"` // Transaction hash of the OutputFilled event - OrderDeliveredTxHash map[string]interface{} `json:"orderDeliveredTxHash"` + OrderDeliveredTxHash NullableString `json:"orderDeliveredTxHash"` // Transaction hash of the OutputProven event - OrderVerifiedTxHash map[string]interface{} `json:"orderVerifiedTxHash"` + OrderVerifiedTxHash NullableString `json:"orderVerifiedTxHash"` // Transaction hash of the Finalised event - OrderSettledTxHash map[string]interface{} `json:"orderSettledTxHash"` + OrderSettledTxHash NullableString `json:"orderSettledTxHash"` // Transaction hash of the Refunded event - RefundTxHash map[string]interface{} `json:"refundTxHash"` + RefundTxHash NullableString `json:"refundTxHash"` // Date when the order was signed - SignedAt map[string]interface{} `json:"signedAt"` + SignedAt NullableString `json:"signedAt"` // Date when the order expires - ExpiredAt map[string]interface{} `json:"expiredAt"` + ExpiredAt NullableString `json:"expiredAt"` // Date when the order was delivered - DeliveredAt map[string]interface{} `json:"deliveredAt"` + DeliveredAt NullableString `json:"deliveredAt"` // Date when the order was settled - SettledAt map[string]interface{} `json:"settledAt"` + SettledAt NullableString `json:"settledAt"` // Date when the order was refunded - RefundedAt map[string]interface{} `json:"refundedAt"` + RefundedAt NullableString `json:"refundedAt"` // Last compact deposit block number - LastCompactDepositBlockNumber map[string]interface{} `json:"lastCompactDepositBlockNumber"` + LastCompactDepositBlockNumber NullableString `json:"lastCompactDepositBlockNumber"` // Quote ID associated with the order - QuoteId map[string]interface{} `json:"quoteId"` + QuoteId NullableString `json:"quoteId"` // Solver address that filled the order - SolverAddress map[string]interface{} `json:"solverAddress,omitempty"` + SolverAddress NullableString `json:"solverAddress,omitempty"` // Integrator key hash identifying the integrator this order belongs to IntegratorKeyHash *string `json:"integratorKeyHash,omitempty"` } @@ -67,7 +67,7 @@ type _OrderMetaDto OrderMetaDto // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewOrderMetaDto(submitTime float32, orderStatus string, orderIdentifier string, onChainOrderId string, destinationAddress string, orderInitiatedTxHash map[string]interface{}, orderDeliveredTxHash map[string]interface{}, orderVerifiedTxHash map[string]interface{}, orderSettledTxHash map[string]interface{}, refundTxHash map[string]interface{}, signedAt map[string]interface{}, expiredAt map[string]interface{}, deliveredAt map[string]interface{}, settledAt map[string]interface{}, refundedAt map[string]interface{}, lastCompactDepositBlockNumber map[string]interface{}, quoteId map[string]interface{}) *OrderMetaDto { +func NewOrderMetaDto(submitTime float32, orderStatus string, orderIdentifier string, onChainOrderId string, destinationAddress string, orderInitiatedTxHash NullableString, orderDeliveredTxHash NullableString, orderVerifiedTxHash NullableString, orderSettledTxHash NullableString, refundTxHash NullableString, signedAt NullableString, expiredAt NullableString, deliveredAt NullableString, settledAt NullableString, refundedAt NullableString, lastCompactDepositBlockNumber NullableString, quoteId NullableString) *OrderMetaDto { this := OrderMetaDto{} this.SubmitTime = submitTime this.OrderStatus = orderStatus @@ -218,348 +218,358 @@ func (o *OrderMetaDto) SetDestinationAddress(v string) { } // GetOrderInitiatedTxHash returns the OrderInitiatedTxHash field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *OrderMetaDto) GetOrderInitiatedTxHash() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *OrderMetaDto) GetOrderInitiatedTxHash() string { + if o == nil || o.OrderInitiatedTxHash.Get() == nil { + var ret string return ret } - return o.OrderInitiatedTxHash + return *o.OrderInitiatedTxHash.Get() } // GetOrderInitiatedTxHashOk returns a tuple with the OrderInitiatedTxHash field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *OrderMetaDto) GetOrderInitiatedTxHashOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.OrderInitiatedTxHash) { - return map[string]interface{}{}, false +func (o *OrderMetaDto) GetOrderInitiatedTxHashOk() (*string, bool) { + if o == nil { + return nil, false } - return o.OrderInitiatedTxHash, true + return o.OrderInitiatedTxHash.Get(), o.OrderInitiatedTxHash.IsSet() } // SetOrderInitiatedTxHash sets field value -func (o *OrderMetaDto) SetOrderInitiatedTxHash(v map[string]interface{}) { - o.OrderInitiatedTxHash = v +func (o *OrderMetaDto) SetOrderInitiatedTxHash(v string) { + o.OrderInitiatedTxHash.Set(&v) } // GetOrderDeliveredTxHash returns the OrderDeliveredTxHash field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *OrderMetaDto) GetOrderDeliveredTxHash() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *OrderMetaDto) GetOrderDeliveredTxHash() string { + if o == nil || o.OrderDeliveredTxHash.Get() == nil { + var ret string return ret } - return o.OrderDeliveredTxHash + return *o.OrderDeliveredTxHash.Get() } // GetOrderDeliveredTxHashOk returns a tuple with the OrderDeliveredTxHash field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *OrderMetaDto) GetOrderDeliveredTxHashOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.OrderDeliveredTxHash) { - return map[string]interface{}{}, false +func (o *OrderMetaDto) GetOrderDeliveredTxHashOk() (*string, bool) { + if o == nil { + return nil, false } - return o.OrderDeliveredTxHash, true + return o.OrderDeliveredTxHash.Get(), o.OrderDeliveredTxHash.IsSet() } // SetOrderDeliveredTxHash sets field value -func (o *OrderMetaDto) SetOrderDeliveredTxHash(v map[string]interface{}) { - o.OrderDeliveredTxHash = v +func (o *OrderMetaDto) SetOrderDeliveredTxHash(v string) { + o.OrderDeliveredTxHash.Set(&v) } // GetOrderVerifiedTxHash returns the OrderVerifiedTxHash field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *OrderMetaDto) GetOrderVerifiedTxHash() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *OrderMetaDto) GetOrderVerifiedTxHash() string { + if o == nil || o.OrderVerifiedTxHash.Get() == nil { + var ret string return ret } - return o.OrderVerifiedTxHash + return *o.OrderVerifiedTxHash.Get() } // GetOrderVerifiedTxHashOk returns a tuple with the OrderVerifiedTxHash field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *OrderMetaDto) GetOrderVerifiedTxHashOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.OrderVerifiedTxHash) { - return map[string]interface{}{}, false +func (o *OrderMetaDto) GetOrderVerifiedTxHashOk() (*string, bool) { + if o == nil { + return nil, false } - return o.OrderVerifiedTxHash, true + return o.OrderVerifiedTxHash.Get(), o.OrderVerifiedTxHash.IsSet() } // SetOrderVerifiedTxHash sets field value -func (o *OrderMetaDto) SetOrderVerifiedTxHash(v map[string]interface{}) { - o.OrderVerifiedTxHash = v +func (o *OrderMetaDto) SetOrderVerifiedTxHash(v string) { + o.OrderVerifiedTxHash.Set(&v) } // GetOrderSettledTxHash returns the OrderSettledTxHash field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *OrderMetaDto) GetOrderSettledTxHash() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *OrderMetaDto) GetOrderSettledTxHash() string { + if o == nil || o.OrderSettledTxHash.Get() == nil { + var ret string return ret } - return o.OrderSettledTxHash + return *o.OrderSettledTxHash.Get() } // GetOrderSettledTxHashOk returns a tuple with the OrderSettledTxHash field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *OrderMetaDto) GetOrderSettledTxHashOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.OrderSettledTxHash) { - return map[string]interface{}{}, false +func (o *OrderMetaDto) GetOrderSettledTxHashOk() (*string, bool) { + if o == nil { + return nil, false } - return o.OrderSettledTxHash, true + return o.OrderSettledTxHash.Get(), o.OrderSettledTxHash.IsSet() } // SetOrderSettledTxHash sets field value -func (o *OrderMetaDto) SetOrderSettledTxHash(v map[string]interface{}) { - o.OrderSettledTxHash = v +func (o *OrderMetaDto) SetOrderSettledTxHash(v string) { + o.OrderSettledTxHash.Set(&v) } // GetRefundTxHash returns the RefundTxHash field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *OrderMetaDto) GetRefundTxHash() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *OrderMetaDto) GetRefundTxHash() string { + if o == nil || o.RefundTxHash.Get() == nil { + var ret string return ret } - return o.RefundTxHash + return *o.RefundTxHash.Get() } // GetRefundTxHashOk returns a tuple with the RefundTxHash field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *OrderMetaDto) GetRefundTxHashOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.RefundTxHash) { - return map[string]interface{}{}, false +func (o *OrderMetaDto) GetRefundTxHashOk() (*string, bool) { + if o == nil { + return nil, false } - return o.RefundTxHash, true + return o.RefundTxHash.Get(), o.RefundTxHash.IsSet() } // SetRefundTxHash sets field value -func (o *OrderMetaDto) SetRefundTxHash(v map[string]interface{}) { - o.RefundTxHash = v +func (o *OrderMetaDto) SetRefundTxHash(v string) { + o.RefundTxHash.Set(&v) } // GetSignedAt returns the SignedAt field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *OrderMetaDto) GetSignedAt() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *OrderMetaDto) GetSignedAt() string { + if o == nil || o.SignedAt.Get() == nil { + var ret string return ret } - return o.SignedAt + return *o.SignedAt.Get() } // GetSignedAtOk returns a tuple with the SignedAt field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *OrderMetaDto) GetSignedAtOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.SignedAt) { - return map[string]interface{}{}, false +func (o *OrderMetaDto) GetSignedAtOk() (*string, bool) { + if o == nil { + return nil, false } - return o.SignedAt, true + return o.SignedAt.Get(), o.SignedAt.IsSet() } // SetSignedAt sets field value -func (o *OrderMetaDto) SetSignedAt(v map[string]interface{}) { - o.SignedAt = v +func (o *OrderMetaDto) SetSignedAt(v string) { + o.SignedAt.Set(&v) } // GetExpiredAt returns the ExpiredAt field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *OrderMetaDto) GetExpiredAt() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *OrderMetaDto) GetExpiredAt() string { + if o == nil || o.ExpiredAt.Get() == nil { + var ret string return ret } - return o.ExpiredAt + return *o.ExpiredAt.Get() } // GetExpiredAtOk returns a tuple with the ExpiredAt field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *OrderMetaDto) GetExpiredAtOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.ExpiredAt) { - return map[string]interface{}{}, false +func (o *OrderMetaDto) GetExpiredAtOk() (*string, bool) { + if o == nil { + return nil, false } - return o.ExpiredAt, true + return o.ExpiredAt.Get(), o.ExpiredAt.IsSet() } // SetExpiredAt sets field value -func (o *OrderMetaDto) SetExpiredAt(v map[string]interface{}) { - o.ExpiredAt = v +func (o *OrderMetaDto) SetExpiredAt(v string) { + o.ExpiredAt.Set(&v) } // GetDeliveredAt returns the DeliveredAt field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *OrderMetaDto) GetDeliveredAt() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *OrderMetaDto) GetDeliveredAt() string { + if o == nil || o.DeliveredAt.Get() == nil { + var ret string return ret } - return o.DeliveredAt + return *o.DeliveredAt.Get() } // GetDeliveredAtOk returns a tuple with the DeliveredAt field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *OrderMetaDto) GetDeliveredAtOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.DeliveredAt) { - return map[string]interface{}{}, false +func (o *OrderMetaDto) GetDeliveredAtOk() (*string, bool) { + if o == nil { + return nil, false } - return o.DeliveredAt, true + return o.DeliveredAt.Get(), o.DeliveredAt.IsSet() } // SetDeliveredAt sets field value -func (o *OrderMetaDto) SetDeliveredAt(v map[string]interface{}) { - o.DeliveredAt = v +func (o *OrderMetaDto) SetDeliveredAt(v string) { + o.DeliveredAt.Set(&v) } // GetSettledAt returns the SettledAt field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *OrderMetaDto) GetSettledAt() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *OrderMetaDto) GetSettledAt() string { + if o == nil || o.SettledAt.Get() == nil { + var ret string return ret } - return o.SettledAt + return *o.SettledAt.Get() } // GetSettledAtOk returns a tuple with the SettledAt field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *OrderMetaDto) GetSettledAtOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.SettledAt) { - return map[string]interface{}{}, false +func (o *OrderMetaDto) GetSettledAtOk() (*string, bool) { + if o == nil { + return nil, false } - return o.SettledAt, true + return o.SettledAt.Get(), o.SettledAt.IsSet() } // SetSettledAt sets field value -func (o *OrderMetaDto) SetSettledAt(v map[string]interface{}) { - o.SettledAt = v +func (o *OrderMetaDto) SetSettledAt(v string) { + o.SettledAt.Set(&v) } // GetRefundedAt returns the RefundedAt field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *OrderMetaDto) GetRefundedAt() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *OrderMetaDto) GetRefundedAt() string { + if o == nil || o.RefundedAt.Get() == nil { + var ret string return ret } - return o.RefundedAt + return *o.RefundedAt.Get() } // GetRefundedAtOk returns a tuple with the RefundedAt field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *OrderMetaDto) GetRefundedAtOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.RefundedAt) { - return map[string]interface{}{}, false +func (o *OrderMetaDto) GetRefundedAtOk() (*string, bool) { + if o == nil { + return nil, false } - return o.RefundedAt, true + return o.RefundedAt.Get(), o.RefundedAt.IsSet() } // SetRefundedAt sets field value -func (o *OrderMetaDto) SetRefundedAt(v map[string]interface{}) { - o.RefundedAt = v +func (o *OrderMetaDto) SetRefundedAt(v string) { + o.RefundedAt.Set(&v) } // GetLastCompactDepositBlockNumber returns the LastCompactDepositBlockNumber field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *OrderMetaDto) GetLastCompactDepositBlockNumber() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *OrderMetaDto) GetLastCompactDepositBlockNumber() string { + if o == nil || o.LastCompactDepositBlockNumber.Get() == nil { + var ret string return ret } - return o.LastCompactDepositBlockNumber + return *o.LastCompactDepositBlockNumber.Get() } // GetLastCompactDepositBlockNumberOk returns a tuple with the LastCompactDepositBlockNumber field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *OrderMetaDto) GetLastCompactDepositBlockNumberOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.LastCompactDepositBlockNumber) { - return map[string]interface{}{}, false +func (o *OrderMetaDto) GetLastCompactDepositBlockNumberOk() (*string, bool) { + if o == nil { + return nil, false } - return o.LastCompactDepositBlockNumber, true + return o.LastCompactDepositBlockNumber.Get(), o.LastCompactDepositBlockNumber.IsSet() } // SetLastCompactDepositBlockNumber sets field value -func (o *OrderMetaDto) SetLastCompactDepositBlockNumber(v map[string]interface{}) { - o.LastCompactDepositBlockNumber = v +func (o *OrderMetaDto) SetLastCompactDepositBlockNumber(v string) { + o.LastCompactDepositBlockNumber.Set(&v) } // GetQuoteId returns the QuoteId field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *OrderMetaDto) GetQuoteId() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *OrderMetaDto) GetQuoteId() string { + if o == nil || o.QuoteId.Get() == nil { + var ret string return ret } - return o.QuoteId + return *o.QuoteId.Get() } // GetQuoteIdOk returns a tuple with the QuoteId field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *OrderMetaDto) GetQuoteIdOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.QuoteId) { - return map[string]interface{}{}, false +func (o *OrderMetaDto) GetQuoteIdOk() (*string, bool) { + if o == nil { + return nil, false } - return o.QuoteId, true + return o.QuoteId.Get(), o.QuoteId.IsSet() } // SetQuoteId sets field value -func (o *OrderMetaDto) SetQuoteId(v map[string]interface{}) { - o.QuoteId = v +func (o *OrderMetaDto) SetQuoteId(v string) { + o.QuoteId.Set(&v) } // GetSolverAddress returns the SolverAddress field value if set, zero value otherwise (both if not set or set to explicit null). -func (o *OrderMetaDto) GetSolverAddress() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +func (o *OrderMetaDto) GetSolverAddress() string { + if o == nil || IsNil(o.SolverAddress.Get()) { + var ret string return ret } - return o.SolverAddress + return *o.SolverAddress.Get() } // GetSolverAddressOk returns a tuple with the SolverAddress field value if set, nil otherwise // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *OrderMetaDto) GetSolverAddressOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.SolverAddress) { - return map[string]interface{}{}, false +func (o *OrderMetaDto) GetSolverAddressOk() (*string, bool) { + if o == nil { + return nil, false } - return o.SolverAddress, true + return o.SolverAddress.Get(), o.SolverAddress.IsSet() } // HasSolverAddress returns a boolean if a field has been set. func (o *OrderMetaDto) HasSolverAddress() bool { - if o != nil && !IsNil(o.SolverAddress) { + if o != nil && o.SolverAddress.IsSet() { return true } return false } -// SetSolverAddress gets a reference to the given map[string]interface{} and assigns it to the SolverAddress field. -func (o *OrderMetaDto) SetSolverAddress(v map[string]interface{}) { - o.SolverAddress = v +// SetSolverAddress gets a reference to the given NullableString and assigns it to the SolverAddress field. +func (o *OrderMetaDto) SetSolverAddress(v string) { + o.SolverAddress.Set(&v) +} + +// SetSolverAddressNil sets the value for SolverAddress to be an explicit nil +func (o *OrderMetaDto) SetSolverAddressNil() { + o.SolverAddress.Set(nil) +} + +// UnsetSolverAddress ensures that no value is present for SolverAddress, not even an explicit nil +func (o *OrderMetaDto) UnsetSolverAddress() { + o.SolverAddress.Unset() } // GetIntegratorKeyHash returns the IntegratorKeyHash field value if set, zero value otherwise. @@ -609,44 +619,20 @@ func (o OrderMetaDto) ToMap() (map[string]interface{}, error) { toSerialize["orderIdentifier"] = o.OrderIdentifier toSerialize["onChainOrderId"] = o.OnChainOrderId toSerialize["destinationAddress"] = o.DestinationAddress - if o.OrderInitiatedTxHash != nil { - toSerialize["orderInitiatedTxHash"] = o.OrderInitiatedTxHash - } - if o.OrderDeliveredTxHash != nil { - toSerialize["orderDeliveredTxHash"] = o.OrderDeliveredTxHash - } - if o.OrderVerifiedTxHash != nil { - toSerialize["orderVerifiedTxHash"] = o.OrderVerifiedTxHash - } - if o.OrderSettledTxHash != nil { - toSerialize["orderSettledTxHash"] = o.OrderSettledTxHash - } - if o.RefundTxHash != nil { - toSerialize["refundTxHash"] = o.RefundTxHash - } - if o.SignedAt != nil { - toSerialize["signedAt"] = o.SignedAt - } - if o.ExpiredAt != nil { - toSerialize["expiredAt"] = o.ExpiredAt - } - if o.DeliveredAt != nil { - toSerialize["deliveredAt"] = o.DeliveredAt - } - if o.SettledAt != nil { - toSerialize["settledAt"] = o.SettledAt - } - if o.RefundedAt != nil { - toSerialize["refundedAt"] = o.RefundedAt - } - if o.LastCompactDepositBlockNumber != nil { - toSerialize["lastCompactDepositBlockNumber"] = o.LastCompactDepositBlockNumber - } - if o.QuoteId != nil { - toSerialize["quoteId"] = o.QuoteId - } - if o.SolverAddress != nil { - toSerialize["solverAddress"] = o.SolverAddress + toSerialize["orderInitiatedTxHash"] = o.OrderInitiatedTxHash.Get() + toSerialize["orderDeliveredTxHash"] = o.OrderDeliveredTxHash.Get() + toSerialize["orderVerifiedTxHash"] = o.OrderVerifiedTxHash.Get() + toSerialize["orderSettledTxHash"] = o.OrderSettledTxHash.Get() + toSerialize["refundTxHash"] = o.RefundTxHash.Get() + toSerialize["signedAt"] = o.SignedAt.Get() + toSerialize["expiredAt"] = o.ExpiredAt.Get() + toSerialize["deliveredAt"] = o.DeliveredAt.Get() + toSerialize["settledAt"] = o.SettledAt.Get() + toSerialize["refundedAt"] = o.RefundedAt.Get() + toSerialize["lastCompactDepositBlockNumber"] = o.LastCompactDepositBlockNumber.Get() + toSerialize["quoteId"] = o.QuoteId.Get() + if o.SolverAddress.IsSet() { + toSerialize["solverAddress"] = o.SolverAddress.Get() } if !IsNil(o.IntegratorKeyHash) { toSerialize["integratorKeyHash"] = o.IntegratorKeyHash diff --git a/api/lifiorder/model_output_dto.go b/api/lifiorder/model_output_dto.go new file mode 100644 index 00000000..63625852 --- /dev/null +++ b/api/lifiorder/model_output_dto.go @@ -0,0 +1,299 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the OutputDto type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OutputDto{} + +// OutputDto struct for OutputDto +type OutputDto struct { + // CAIP-2 chain identifier for this output (e.g., \"eip155:1\"). Applies to both receiver and asset. + Chain string `json:"chain"` + // Native address that will receive the output assets + Receiver string `json:"receiver"` + // Native address of the token/asset to be received as output + Asset string `json:"asset"` + Amount NullableString `json:"amount,omitempty"` + // Optional calldata describing how the receiver will consume the output. Enables composability with other protocols + Calldata *string `json:"calldata,omitempty"` +} + +type _OutputDto OutputDto + +// NewOutputDto instantiates a new OutputDto object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewOutputDto(chain string, receiver string, asset string) *OutputDto { + this := OutputDto{} + this.Chain = chain + this.Receiver = receiver + this.Asset = asset + return &this +} + +// NewOutputDtoWithDefaults instantiates a new OutputDto object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewOutputDtoWithDefaults() *OutputDto { + this := OutputDto{} + return &this +} + +// GetChain returns the Chain field value +func (o *OutputDto) GetChain() string { + if o == nil { + var ret string + return ret + } + + return o.Chain +} + +// GetChainOk returns a tuple with the Chain field value +// and a boolean to check if the value has been set. +func (o *OutputDto) GetChainOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Chain, true +} + +// SetChain sets field value +func (o *OutputDto) SetChain(v string) { + o.Chain = v +} + +// GetReceiver returns the Receiver field value +func (o *OutputDto) GetReceiver() string { + if o == nil { + var ret string + return ret + } + + return o.Receiver +} + +// GetReceiverOk returns a tuple with the Receiver field value +// and a boolean to check if the value has been set. +func (o *OutputDto) GetReceiverOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Receiver, true +} + +// SetReceiver sets field value +func (o *OutputDto) SetReceiver(v string) { + o.Receiver = v +} + +// GetAsset returns the Asset field value +func (o *OutputDto) GetAsset() string { + if o == nil { + var ret string + return ret + } + + return o.Asset +} + +// GetAssetOk returns a tuple with the Asset field value +// and a boolean to check if the value has been set. +func (o *OutputDto) GetAssetOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Asset, true +} + +// SetAsset sets field value +func (o *OutputDto) SetAsset(v string) { + o.Asset = v +} + +// GetAmount returns the Amount field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *OutputDto) GetAmount() string { + if o == nil || IsNil(o.Amount.Get()) { + var ret string + return ret + } + return *o.Amount.Get() +} + +// GetAmountOk returns a tuple with the Amount field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *OutputDto) GetAmountOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Amount.Get(), o.Amount.IsSet() +} + +// HasAmount returns a boolean if a field has been set. +func (o *OutputDto) HasAmount() bool { + if o != nil && o.Amount.IsSet() { + return true + } + + return false +} + +// SetAmount gets a reference to the given NullableString and assigns it to the Amount field. +func (o *OutputDto) SetAmount(v string) { + o.Amount.Set(&v) +} + +// SetAmountNil sets the value for Amount to be an explicit nil +func (o *OutputDto) SetAmountNil() { + o.Amount.Set(nil) +} + +// UnsetAmount ensures that no value is present for Amount, not even an explicit nil +func (o *OutputDto) UnsetAmount() { + o.Amount.Unset() +} + +// GetCalldata returns the Calldata field value if set, zero value otherwise. +func (o *OutputDto) GetCalldata() string { + if o == nil || IsNil(o.Calldata) { + var ret string + return ret + } + return *o.Calldata +} + +// GetCalldataOk returns a tuple with the Calldata field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OutputDto) GetCalldataOk() (*string, bool) { + if o == nil || IsNil(o.Calldata) { + return nil, false + } + return o.Calldata, true +} + +// HasCalldata returns a boolean if a field has been set. +func (o *OutputDto) HasCalldata() bool { + if o != nil && !IsNil(o.Calldata) { + return true + } + + return false +} + +// SetCalldata gets a reference to the given string and assigns it to the Calldata field. +func (o *OutputDto) SetCalldata(v string) { + o.Calldata = &v +} + +func (o OutputDto) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o OutputDto) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["chain"] = o.Chain + toSerialize["receiver"] = o.Receiver + toSerialize["asset"] = o.Asset + if o.Amount.IsSet() { + toSerialize["amount"] = o.Amount.Get() + } + if !IsNil(o.Calldata) { + toSerialize["calldata"] = o.Calldata + } + return toSerialize, nil +} + +func (o *OutputDto) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "chain", + "receiver", + "asset", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varOutputDto := _OutputDto{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varOutputDto) + + if err != nil { + return err + } + + *o = OutputDto(varOutputDto) + + return err +} + +type NullableOutputDto struct { + value *OutputDto + isSet bool +} + +func (v NullableOutputDto) Get() *OutputDto { + return v.value +} + +func (v *NullableOutputDto) Set(val *OutputDto) { + v.value = val + v.isSet = true +} + +func (v NullableOutputDto) IsSet() bool { + return v.isSet +} + +func (v *NullableOutputDto) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOutputDto(val *OutputDto) *NullableOutputDto { + return &NullableOutputDto{value: val, isSet: true} +} + +func (v NullableOutputDto) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOutputDto) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_put_supported_contracts_dto.go b/api/lifiorder/model_put_supported_contracts_dto.go index e20f188f..1587ae9e 100644 --- a/api/lifiorder/model_put_supported_contracts_dto.go +++ b/api/lifiorder/model_put_supported_contracts_dto.go @@ -19,9 +19,9 @@ var _ MappedNullable = &PutSupportedContractsDto{} // PutSupportedContractsDto struct for PutSupportedContractsDto type PutSupportedContractsDto struct { - Oracle []PutSupportedContractsDtoOracleInner `json:"oracle,omitempty"` - InputSettler []PutSupportedContractsDtoOracleInner `json:"inputSettler,omitempty"` - OutputSettler []PutSupportedContractsDtoOracleInner `json:"outputSettler,omitempty"` + Oracle []QuoteRequestDtoIntentMetadataOracleInner `json:"oracle,omitempty"` + InputSettler []QuoteRequestDtoIntentMetadataOracleInner `json:"inputSettler,omitempty"` + OutputSettler []QuoteRequestDtoIntentMetadataOracleInner `json:"outputSettler,omitempty"` } // NewPutSupportedContractsDto instantiates a new PutSupportedContractsDto object @@ -42,9 +42,9 @@ func NewPutSupportedContractsDtoWithDefaults() *PutSupportedContractsDto { } // GetOracle returns the Oracle field value if set, zero value otherwise. -func (o *PutSupportedContractsDto) GetOracle() []PutSupportedContractsDtoOracleInner { +func (o *PutSupportedContractsDto) GetOracle() []QuoteRequestDtoIntentMetadataOracleInner { if o == nil || IsNil(o.Oracle) { - var ret []PutSupportedContractsDtoOracleInner + var ret []QuoteRequestDtoIntentMetadataOracleInner return ret } return o.Oracle @@ -52,7 +52,7 @@ func (o *PutSupportedContractsDto) GetOracle() []PutSupportedContractsDtoOracleI // GetOracleOk returns a tuple with the Oracle field value if set, nil otherwise // and a boolean to check if the value has been set. -func (o *PutSupportedContractsDto) GetOracleOk() ([]PutSupportedContractsDtoOracleInner, bool) { +func (o *PutSupportedContractsDto) GetOracleOk() ([]QuoteRequestDtoIntentMetadataOracleInner, bool) { if o == nil || IsNil(o.Oracle) { return nil, false } @@ -68,15 +68,15 @@ func (o *PutSupportedContractsDto) HasOracle() bool { return false } -// SetOracle gets a reference to the given []PutSupportedContractsDtoOracleInner and assigns it to the Oracle field. -func (o *PutSupportedContractsDto) SetOracle(v []PutSupportedContractsDtoOracleInner) { +// SetOracle gets a reference to the given []QuoteRequestDtoIntentMetadataOracleInner and assigns it to the Oracle field. +func (o *PutSupportedContractsDto) SetOracle(v []QuoteRequestDtoIntentMetadataOracleInner) { o.Oracle = v } // GetInputSettler returns the InputSettler field value if set, zero value otherwise. -func (o *PutSupportedContractsDto) GetInputSettler() []PutSupportedContractsDtoOracleInner { +func (o *PutSupportedContractsDto) GetInputSettler() []QuoteRequestDtoIntentMetadataOracleInner { if o == nil || IsNil(o.InputSettler) { - var ret []PutSupportedContractsDtoOracleInner + var ret []QuoteRequestDtoIntentMetadataOracleInner return ret } return o.InputSettler @@ -84,7 +84,7 @@ func (o *PutSupportedContractsDto) GetInputSettler() []PutSupportedContractsDtoO // GetInputSettlerOk returns a tuple with the InputSettler field value if set, nil otherwise // and a boolean to check if the value has been set. -func (o *PutSupportedContractsDto) GetInputSettlerOk() ([]PutSupportedContractsDtoOracleInner, bool) { +func (o *PutSupportedContractsDto) GetInputSettlerOk() ([]QuoteRequestDtoIntentMetadataOracleInner, bool) { if o == nil || IsNil(o.InputSettler) { return nil, false } @@ -100,15 +100,15 @@ func (o *PutSupportedContractsDto) HasInputSettler() bool { return false } -// SetInputSettler gets a reference to the given []PutSupportedContractsDtoOracleInner and assigns it to the InputSettler field. -func (o *PutSupportedContractsDto) SetInputSettler(v []PutSupportedContractsDtoOracleInner) { +// SetInputSettler gets a reference to the given []QuoteRequestDtoIntentMetadataOracleInner and assigns it to the InputSettler field. +func (o *PutSupportedContractsDto) SetInputSettler(v []QuoteRequestDtoIntentMetadataOracleInner) { o.InputSettler = v } // GetOutputSettler returns the OutputSettler field value if set, zero value otherwise. -func (o *PutSupportedContractsDto) GetOutputSettler() []PutSupportedContractsDtoOracleInner { +func (o *PutSupportedContractsDto) GetOutputSettler() []QuoteRequestDtoIntentMetadataOracleInner { if o == nil || IsNil(o.OutputSettler) { - var ret []PutSupportedContractsDtoOracleInner + var ret []QuoteRequestDtoIntentMetadataOracleInner return ret } return o.OutputSettler @@ -116,7 +116,7 @@ func (o *PutSupportedContractsDto) GetOutputSettler() []PutSupportedContractsDto // GetOutputSettlerOk returns a tuple with the OutputSettler field value if set, nil otherwise // and a boolean to check if the value has been set. -func (o *PutSupportedContractsDto) GetOutputSettlerOk() ([]PutSupportedContractsDtoOracleInner, bool) { +func (o *PutSupportedContractsDto) GetOutputSettlerOk() ([]QuoteRequestDtoIntentMetadataOracleInner, bool) { if o == nil || IsNil(o.OutputSettler) { return nil, false } @@ -132,8 +132,8 @@ func (o *PutSupportedContractsDto) HasOutputSettler() bool { return false } -// SetOutputSettler gets a reference to the given []PutSupportedContractsDtoOracleInner and assigns it to the OutputSettler field. -func (o *PutSupportedContractsDto) SetOutputSettler(v []PutSupportedContractsDtoOracleInner) { +// SetOutputSettler gets a reference to the given []QuoteRequestDtoIntentMetadataOracleInner and assigns it to the OutputSettler field. +func (o *PutSupportedContractsDto) SetOutputSettler(v []QuoteRequestDtoIntentMetadataOracleInner) { o.OutputSettler = v } diff --git a/api/lifiorder/model_put_supported_contracts_dto_oracle_inner.go b/api/lifiorder/model_put_supported_contracts_dto_oracle_inner.go deleted file mode 100644 index 51773bbc..00000000 --- a/api/lifiorder/model_put_supported_contracts_dto_oracle_inner.go +++ /dev/null @@ -1,186 +0,0 @@ -/* -Lifi Intents API Reference - -No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - -API version: 0.0.19 -*/ - -// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. - -package lifiorder - -import ( - "bytes" - "encoding/json" - "fmt" -) - -// checks if the PutSupportedContractsDtoOracleInner type satisfies the MappedNullable interface at compile time -var _ MappedNullable = &PutSupportedContractsDtoOracleInner{} - -// PutSupportedContractsDtoOracleInner struct for PutSupportedContractsDtoOracleInner -type PutSupportedContractsDtoOracleInner struct { - // CAIP-2 chain identifier, e.g. \"eip155:1\" - Chain string `json:"chain"` - // Native contract address for the chain - Address string `json:"address"` -} - -type _PutSupportedContractsDtoOracleInner PutSupportedContractsDtoOracleInner - -// NewPutSupportedContractsDtoOracleInner instantiates a new PutSupportedContractsDtoOracleInner object -// This constructor will assign default values to properties that have it defined, -// and makes sure properties required by API are set, but the set of arguments -// will change when the set of required properties is changed -func NewPutSupportedContractsDtoOracleInner(chain string, address string) *PutSupportedContractsDtoOracleInner { - this := PutSupportedContractsDtoOracleInner{} - this.Chain = chain - this.Address = address - return &this -} - -// NewPutSupportedContractsDtoOracleInnerWithDefaults instantiates a new PutSupportedContractsDtoOracleInner object -// This constructor will only assign default values to properties that have it defined, -// but it doesn't guarantee that properties required by API are set -func NewPutSupportedContractsDtoOracleInnerWithDefaults() *PutSupportedContractsDtoOracleInner { - this := PutSupportedContractsDtoOracleInner{} - return &this -} - -// GetChain returns the Chain field value -func (o *PutSupportedContractsDtoOracleInner) GetChain() string { - if o == nil { - var ret string - return ret - } - - return o.Chain -} - -// GetChainOk returns a tuple with the Chain field value -// and a boolean to check if the value has been set. -func (o *PutSupportedContractsDtoOracleInner) GetChainOk() (*string, bool) { - if o == nil { - return nil, false - } - return &o.Chain, true -} - -// SetChain sets field value -func (o *PutSupportedContractsDtoOracleInner) SetChain(v string) { - o.Chain = v -} - -// GetAddress returns the Address field value -func (o *PutSupportedContractsDtoOracleInner) GetAddress() string { - if o == nil { - var ret string - return ret - } - - return o.Address -} - -// GetAddressOk returns a tuple with the Address field value -// and a boolean to check if the value has been set. -func (o *PutSupportedContractsDtoOracleInner) GetAddressOk() (*string, bool) { - if o == nil { - return nil, false - } - return &o.Address, true -} - -// SetAddress sets field value -func (o *PutSupportedContractsDtoOracleInner) SetAddress(v string) { - o.Address = v -} - -func (o PutSupportedContractsDtoOracleInner) MarshalJSON() ([]byte, error) { - toSerialize, err := o.ToMap() - if err != nil { - return []byte{}, err - } - return json.Marshal(toSerialize) -} - -func (o PutSupportedContractsDtoOracleInner) ToMap() (map[string]interface{}, error) { - toSerialize := map[string]interface{}{} - toSerialize["chain"] = o.Chain - toSerialize["address"] = o.Address - return toSerialize, nil -} - -func (o *PutSupportedContractsDtoOracleInner) UnmarshalJSON(data []byte) (err error) { - // This validates that all required properties are included in the JSON object - // by unmarshalling the object into a generic map with string keys and checking - // that every required field exists as a key in the generic map. - requiredProperties := []string{ - "chain", - "address", - } - - allProperties := make(map[string]interface{}) - - err = json.Unmarshal(data, &allProperties) - - if err != nil { - return err - } - - for _, requiredProperty := range requiredProperties { - if _, exists := allProperties[requiredProperty]; !exists { - return fmt.Errorf("no value given for required property %v", requiredProperty) - } - } - - varPutSupportedContractsDtoOracleInner := _PutSupportedContractsDtoOracleInner{} - - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - err = decoder.Decode(&varPutSupportedContractsDtoOracleInner) - - if err != nil { - return err - } - - *o = PutSupportedContractsDtoOracleInner(varPutSupportedContractsDtoOracleInner) - - return err -} - -type NullablePutSupportedContractsDtoOracleInner struct { - value *PutSupportedContractsDtoOracleInner - isSet bool -} - -func (v NullablePutSupportedContractsDtoOracleInner) Get() *PutSupportedContractsDtoOracleInner { - return v.value -} - -func (v *NullablePutSupportedContractsDtoOracleInner) Set(val *PutSupportedContractsDtoOracleInner) { - v.value = val - v.isSet = true -} - -func (v NullablePutSupportedContractsDtoOracleInner) IsSet() bool { - return v.isSet -} - -func (v *NullablePutSupportedContractsDtoOracleInner) Unset() { - v.value = nil - v.isSet = false -} - -func NewNullablePutSupportedContractsDtoOracleInner(val *PutSupportedContractsDtoOracleInner) *NullablePutSupportedContractsDtoOracleInner { - return &NullablePutSupportedContractsDtoOracleInner{value: val, isSet: true} -} - -func (v NullablePutSupportedContractsDtoOracleInner) MarshalJSON() ([]byte, error) { - return json.Marshal(v.value) -} - -func (v *NullablePutSupportedContractsDtoOracleInner) UnmarshalJSON(src []byte) error { - v.isSet = true - return json.Unmarshal(src, &v.value) -} diff --git a/api/lifiorder/model_quote_dto.go b/api/lifiorder/model_quote_dto.go index d9fd3ed9..19f93413 100644 --- a/api/lifiorder/model_quote_dto.go +++ b/api/lifiorder/model_quote_dto.go @@ -21,8 +21,7 @@ var _ MappedNullable = &QuoteDto{} // QuoteDto struct for QuoteDto type QuoteDto struct { - // Order details - Order map[string]interface{} `json:"order"` + Order QuoteDtoOrder `json:"order"` // Quote validity timestamp in unix timestamp (seconds) ValidUntil *float32 `json:"validUntil,omitempty"` // Estimated time of arrival in seconds @@ -47,7 +46,7 @@ type _QuoteDto QuoteDto // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewQuoteDto(order map[string]interface{}, quoteId string, provider string, preview QuotePreviewDto, failureHandling string, partialFill bool, metadata QuoteMetadataDto) *QuoteDto { +func NewQuoteDto(order QuoteDtoOrder, quoteId string, provider string, preview QuotePreviewDto, failureHandling string, partialFill bool, metadata QuoteMetadataDto) *QuoteDto { this := QuoteDto{} this.Order = order this.QuoteId = quoteId @@ -68,9 +67,9 @@ func NewQuoteDtoWithDefaults() *QuoteDto { } // GetOrder returns the Order field value -func (o *QuoteDto) GetOrder() map[string]interface{} { +func (o *QuoteDto) GetOrder() QuoteDtoOrder { if o == nil { - var ret map[string]interface{} + var ret QuoteDtoOrder return ret } @@ -79,15 +78,15 @@ func (o *QuoteDto) GetOrder() map[string]interface{} { // GetOrderOk returns a tuple with the Order field value // and a boolean to check if the value has been set. -func (o *QuoteDto) GetOrderOk() (map[string]interface{}, bool) { +func (o *QuoteDto) GetOrderOk() (*QuoteDtoOrder, bool) { if o == nil { - return map[string]interface{}{}, false + return nil, false } - return o.Order, true + return &o.Order, true } // SetOrder sets field value -func (o *QuoteDto) SetOrder(v map[string]interface{}) { +func (o *QuoteDto) SetOrder(v QuoteDtoOrder) { o.Order = v } diff --git a/api/lifiorder/model_quote_dto_order.go b/api/lifiorder/model_quote_dto_order.go new file mode 100644 index 00000000..cf1da7b2 --- /dev/null +++ b/api/lifiorder/model_quote_dto_order.go @@ -0,0 +1,206 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "encoding/json" + "fmt" + "gopkg.in/validator.v2" +) + +// QuoteDtoOrder - Order details +type QuoteDtoOrder struct { + Oif3009OrderDto *Oif3009OrderDto + OifEscrowOrderDto *OifEscrowOrderDto + OifUserOpenIntentOrderDto *OifUserOpenIntentOrderDto +} + +// Oif3009OrderDtoAsQuoteDtoOrder is a convenience function that returns Oif3009OrderDto wrapped in QuoteDtoOrder +func Oif3009OrderDtoAsQuoteDtoOrder(v *Oif3009OrderDto) QuoteDtoOrder { + return QuoteDtoOrder{ + Oif3009OrderDto: v, + } +} + +// OifEscrowOrderDtoAsQuoteDtoOrder is a convenience function that returns OifEscrowOrderDto wrapped in QuoteDtoOrder +func OifEscrowOrderDtoAsQuoteDtoOrder(v *OifEscrowOrderDto) QuoteDtoOrder { + return QuoteDtoOrder{ + OifEscrowOrderDto: v, + } +} + +// OifUserOpenIntentOrderDtoAsQuoteDtoOrder is a convenience function that returns OifUserOpenIntentOrderDto wrapped in QuoteDtoOrder +func OifUserOpenIntentOrderDtoAsQuoteDtoOrder(v *OifUserOpenIntentOrderDto) QuoteDtoOrder { + return QuoteDtoOrder{ + OifUserOpenIntentOrderDto: v, + } +} + +// Unmarshal JSON data into one of the pointers in the struct +func (dst *QuoteDtoOrder) UnmarshalJSON(data []byte) error { + var err error + match := 0 + // try to unmarshal data into Oif3009OrderDto + err = newStrictDecoder(data).Decode(&dst.Oif3009OrderDto) + if err == nil { + jsonOif3009OrderDto, _ := json.Marshal(dst.Oif3009OrderDto) + if string(jsonOif3009OrderDto) == "{}" { // empty struct + dst.Oif3009OrderDto = nil + } else { + if err = validator.Validate(dst.Oif3009OrderDto); err != nil { + dst.Oif3009OrderDto = nil + } else { + match++ + } + } + } else { + dst.Oif3009OrderDto = nil + } + + // try to unmarshal data into OifEscrowOrderDto + err = newStrictDecoder(data).Decode(&dst.OifEscrowOrderDto) + if err == nil { + jsonOifEscrowOrderDto, _ := json.Marshal(dst.OifEscrowOrderDto) + if string(jsonOifEscrowOrderDto) == "{}" { // empty struct + dst.OifEscrowOrderDto = nil + } else { + if err = validator.Validate(dst.OifEscrowOrderDto); err != nil { + dst.OifEscrowOrderDto = nil + } else { + match++ + } + } + } else { + dst.OifEscrowOrderDto = nil + } + + // try to unmarshal data into OifUserOpenIntentOrderDto + err = newStrictDecoder(data).Decode(&dst.OifUserOpenIntentOrderDto) + if err == nil { + jsonOifUserOpenIntentOrderDto, _ := json.Marshal(dst.OifUserOpenIntentOrderDto) + if string(jsonOifUserOpenIntentOrderDto) == "{}" { // empty struct + dst.OifUserOpenIntentOrderDto = nil + } else { + if err = validator.Validate(dst.OifUserOpenIntentOrderDto); err != nil { + dst.OifUserOpenIntentOrderDto = nil + } else { + match++ + } + } + } else { + dst.OifUserOpenIntentOrderDto = nil + } + + if match > 1 { // more than 1 match + // reset to nil + dst.Oif3009OrderDto = nil + dst.OifEscrowOrderDto = nil + dst.OifUserOpenIntentOrderDto = nil + + return fmt.Errorf("data matches more than one schema in oneOf(QuoteDtoOrder)") + } else if match == 1 { + return nil // exactly one match + } else { // no match + return fmt.Errorf("data failed to match schemas in oneOf(QuoteDtoOrder)") + } +} + +// Marshal data from the first non-nil pointers in the struct to JSON +func (src QuoteDtoOrder) MarshalJSON() ([]byte, error) { + if src.Oif3009OrderDto != nil { + return json.Marshal(&src.Oif3009OrderDto) + } + + if src.OifEscrowOrderDto != nil { + return json.Marshal(&src.OifEscrowOrderDto) + } + + if src.OifUserOpenIntentOrderDto != nil { + return json.Marshal(&src.OifUserOpenIntentOrderDto) + } + + return nil, nil // no data in oneOf schemas +} + +// Get the actual instance +func (obj *QuoteDtoOrder) GetActualInstance() interface{} { + if obj == nil { + return nil + } + if obj.Oif3009OrderDto != nil { + return obj.Oif3009OrderDto + } + + if obj.OifEscrowOrderDto != nil { + return obj.OifEscrowOrderDto + } + + if obj.OifUserOpenIntentOrderDto != nil { + return obj.OifUserOpenIntentOrderDto + } + + // all schemas are nil + return nil +} + +// Get the actual instance value +func (obj QuoteDtoOrder) GetActualInstanceValue() interface{} { + if obj.Oif3009OrderDto != nil { + return *obj.Oif3009OrderDto + } + + if obj.OifEscrowOrderDto != nil { + return *obj.OifEscrowOrderDto + } + + if obj.OifUserOpenIntentOrderDto != nil { + return *obj.OifUserOpenIntentOrderDto + } + + // all schemas are nil + return nil +} + +type NullableQuoteDtoOrder struct { + value *QuoteDtoOrder + isSet bool +} + +func (v NullableQuoteDtoOrder) Get() *QuoteDtoOrder { + return v.value +} + +func (v *NullableQuoteDtoOrder) Set(val *QuoteDtoOrder) { + v.value = val + v.isSet = true +} + +func (v NullableQuoteDtoOrder) IsSet() bool { + return v.isSet +} + +func (v *NullableQuoteDtoOrder) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableQuoteDtoOrder(val *QuoteDtoOrder) *NullableQuoteDtoOrder { + return &NullableQuoteDtoOrder{value: val, isSet: true} +} + +func (v NullableQuoteDtoOrder) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableQuoteDtoOrder) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_quote_metadata_dto.go b/api/lifiorder/model_quote_metadata_dto.go index 1ff55166..d31fbc6b 100644 --- a/api/lifiorder/model_quote_metadata_dto.go +++ b/api/lifiorder/model_quote_metadata_dto.go @@ -22,7 +22,7 @@ var _ MappedNullable = &QuoteMetadataDto{} // QuoteMetadataDto struct for QuoteMetadataDto type QuoteMetadataDto struct { // Exclusive for address (hex32) - solver address that can fill this quote, or null - ExclusiveFor map[string]interface{} `json:"exclusiveFor"` + ExclusiveFor NullableString `json:"exclusiveFor"` } type _QuoteMetadataDto QuoteMetadataDto @@ -31,7 +31,7 @@ type _QuoteMetadataDto QuoteMetadataDto // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewQuoteMetadataDto(exclusiveFor map[string]interface{}) *QuoteMetadataDto { +func NewQuoteMetadataDto(exclusiveFor NullableString) *QuoteMetadataDto { this := QuoteMetadataDto{} this.ExclusiveFor = exclusiveFor return &this @@ -46,29 +46,29 @@ func NewQuoteMetadataDtoWithDefaults() *QuoteMetadataDto { } // GetExclusiveFor returns the ExclusiveFor field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *QuoteMetadataDto) GetExclusiveFor() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *QuoteMetadataDto) GetExclusiveFor() string { + if o == nil || o.ExclusiveFor.Get() == nil { + var ret string return ret } - return o.ExclusiveFor + return *o.ExclusiveFor.Get() } // GetExclusiveForOk returns a tuple with the ExclusiveFor field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *QuoteMetadataDto) GetExclusiveForOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.ExclusiveFor) { - return map[string]interface{}{}, false +func (o *QuoteMetadataDto) GetExclusiveForOk() (*string, bool) { + if o == nil { + return nil, false } - return o.ExclusiveFor, true + return o.ExclusiveFor.Get(), o.ExclusiveFor.IsSet() } // SetExclusiveFor sets field value -func (o *QuoteMetadataDto) SetExclusiveFor(v map[string]interface{}) { - o.ExclusiveFor = v +func (o *QuoteMetadataDto) SetExclusiveFor(v string) { + o.ExclusiveFor.Set(&v) } func (o QuoteMetadataDto) MarshalJSON() ([]byte, error) { @@ -81,9 +81,7 @@ func (o QuoteMetadataDto) MarshalJSON() ([]byte, error) { func (o QuoteMetadataDto) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} - if o.ExclusiveFor != nil { - toSerialize["exclusiveFor"] = o.ExclusiveFor - } + toSerialize["exclusiveFor"] = o.ExclusiveFor.Get() return toSerialize, nil } diff --git a/api/lifiorder/model_quote_preview_dto.go b/api/lifiorder/model_quote_preview_dto.go index ebde0bdd..55300168 100644 --- a/api/lifiorder/model_quote_preview_dto.go +++ b/api/lifiorder/model_quote_preview_dto.go @@ -22,9 +22,9 @@ var _ MappedNullable = &QuotePreviewDto{} // QuotePreviewDto struct for QuotePreviewDto type QuotePreviewDto struct { // Inputs for the preview - Inputs []QuotePreviewDtoInputsInner `json:"inputs"` + Inputs []InputDto `json:"inputs"` // Outputs for the preview - Outputs []QuotePreviewDtoOutputsInner `json:"outputs"` + Outputs []OutputDto `json:"outputs"` } type _QuotePreviewDto QuotePreviewDto @@ -33,7 +33,7 @@ type _QuotePreviewDto QuotePreviewDto // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewQuotePreviewDto(inputs []QuotePreviewDtoInputsInner, outputs []QuotePreviewDtoOutputsInner) *QuotePreviewDto { +func NewQuotePreviewDto(inputs []InputDto, outputs []OutputDto) *QuotePreviewDto { this := QuotePreviewDto{} this.Inputs = inputs this.Outputs = outputs @@ -49,9 +49,9 @@ func NewQuotePreviewDtoWithDefaults() *QuotePreviewDto { } // GetInputs returns the Inputs field value -func (o *QuotePreviewDto) GetInputs() []QuotePreviewDtoInputsInner { +func (o *QuotePreviewDto) GetInputs() []InputDto { if o == nil { - var ret []QuotePreviewDtoInputsInner + var ret []InputDto return ret } @@ -60,7 +60,7 @@ func (o *QuotePreviewDto) GetInputs() []QuotePreviewDtoInputsInner { // GetInputsOk returns a tuple with the Inputs field value // and a boolean to check if the value has been set. -func (o *QuotePreviewDto) GetInputsOk() ([]QuotePreviewDtoInputsInner, bool) { +func (o *QuotePreviewDto) GetInputsOk() ([]InputDto, bool) { if o == nil { return nil, false } @@ -68,14 +68,14 @@ func (o *QuotePreviewDto) GetInputsOk() ([]QuotePreviewDtoInputsInner, bool) { } // SetInputs sets field value -func (o *QuotePreviewDto) SetInputs(v []QuotePreviewDtoInputsInner) { +func (o *QuotePreviewDto) SetInputs(v []InputDto) { o.Inputs = v } // GetOutputs returns the Outputs field value -func (o *QuotePreviewDto) GetOutputs() []QuotePreviewDtoOutputsInner { +func (o *QuotePreviewDto) GetOutputs() []OutputDto { if o == nil { - var ret []QuotePreviewDtoOutputsInner + var ret []OutputDto return ret } @@ -84,7 +84,7 @@ func (o *QuotePreviewDto) GetOutputs() []QuotePreviewDtoOutputsInner { // GetOutputsOk returns a tuple with the Outputs field value // and a boolean to check if the value has been set. -func (o *QuotePreviewDto) GetOutputsOk() ([]QuotePreviewDtoOutputsInner, bool) { +func (o *QuotePreviewDto) GetOutputsOk() ([]OutputDto, bool) { if o == nil { return nil, false } @@ -92,7 +92,7 @@ func (o *QuotePreviewDto) GetOutputsOk() ([]QuotePreviewDtoOutputsInner, bool) { } // SetOutputs sets field value -func (o *QuotePreviewDto) SetOutputs(v []QuotePreviewDtoOutputsInner) { +func (o *QuotePreviewDto) SetOutputs(v []OutputDto) { o.Outputs = v } diff --git a/api/lifiorder/model_quote_request_dto_intent.go b/api/lifiorder/model_quote_request_dto_intent.go index e007380e..84691201 100644 --- a/api/lifiorder/model_quote_request_dto_intent.go +++ b/api/lifiorder/model_quote_request_dto_intent.go @@ -32,8 +32,7 @@ type QuoteRequestDtoIntent struct { // Minimum validity timestamp in unix timestamp (seconds). Only select solver quotes with longer TTL. MinValidUntil *float32 `json:"minValidUntil,omitempty"` // Quote preference (unsupported, ignored if provided) - Preference *string `json:"preference,omitempty"` - // Explicit preference for submission responsibility and acceptable auth schemes. Shape: { mode: \"user\" | \"protocol\", auth?: string[] }. Unsupported, ignored for now - needs gasless feature. + Preference *string `json:"preference,omitempty"` OriginSubmission interface{} `json:"originSubmission,omitempty"` // Failure handling policy for execution that the integrator supports (unsupported, ignored) FailureHandling []string `json:"failureHandling,omitempty"` diff --git a/api/lifiorder/model_quote_request_dto_intent_inputs_inner.go b/api/lifiorder/model_quote_request_dto_intent_inputs_inner.go index f968ac6b..f4ef7ec1 100644 --- a/api/lifiorder/model_quote_request_dto_intent_inputs_inner.go +++ b/api/lifiorder/model_quote_request_dto_intent_inputs_inner.go @@ -26,11 +26,10 @@ type QuoteRequestDtoIntentInputsInner struct { // Native address of the user providing the input assets User string `json:"user"` // Native address of the token/asset being provided as input - Asset string `json:"asset"` - // Amount available. For exact-input: exact amount user will provide and is used for quoting. For exact-output: ignored by the quote decoder and not used for quoting + Asset string `json:"asset"` Amount NullableString `json:"amount,omitempty"` // Optional lock reference for securing the input assets. Shape: { kind: \"the-compact\" | \"rhinestone\", params: object }. Currently ignored by the quote decoder. - Lock interface{} `json:"lock,omitempty"` + Lock map[string]interface{} `json:"lock,omitempty"` } type _QuoteRequestDtoIntentInputsInner QuoteRequestDtoIntentInputsInner @@ -170,10 +169,10 @@ func (o *QuoteRequestDtoIntentInputsInner) UnsetAmount() { o.Amount.Unset() } -// GetLock returns the Lock field value if set, zero value otherwise (both if not set or set to explicit null). -func (o *QuoteRequestDtoIntentInputsInner) GetLock() interface{} { - if o == nil { - var ret interface{} +// GetLock returns the Lock field value if set, zero value otherwise. +func (o *QuoteRequestDtoIntentInputsInner) GetLock() map[string]interface{} { + if o == nil || IsNil(o.Lock) { + var ret map[string]interface{} return ret } return o.Lock @@ -181,12 +180,11 @@ func (o *QuoteRequestDtoIntentInputsInner) GetLock() interface{} { // GetLockOk returns a tuple with the Lock field value if set, nil otherwise // and a boolean to check if the value has been set. -// NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *QuoteRequestDtoIntentInputsInner) GetLockOk() (*interface{}, bool) { +func (o *QuoteRequestDtoIntentInputsInner) GetLockOk() (map[string]interface{}, bool) { if o == nil || IsNil(o.Lock) { - return nil, false + return map[string]interface{}{}, false } - return &o.Lock, true + return o.Lock, true } // HasLock returns a boolean if a field has been set. @@ -198,8 +196,8 @@ func (o *QuoteRequestDtoIntentInputsInner) HasLock() bool { return false } -// SetLock gets a reference to the given interface{} and assigns it to the Lock field. -func (o *QuoteRequestDtoIntentInputsInner) SetLock(v interface{}) { +// SetLock gets a reference to the given map[string]interface{} and assigns it to the Lock field. +func (o *QuoteRequestDtoIntentInputsInner) SetLock(v map[string]interface{}) { o.Lock = v } @@ -219,7 +217,7 @@ func (o QuoteRequestDtoIntentInputsInner) ToMap() (map[string]interface{}, error if o.Amount.IsSet() { toSerialize["amount"] = o.Amount.Get() } - if o.Lock != nil { + if !IsNil(o.Lock) { toSerialize["lock"] = o.Lock } return toSerialize, nil diff --git a/api/lifiorder/model_quote_request_dto_intent_metadata.go b/api/lifiorder/model_quote_request_dto_intent_metadata.go index efa12441..f9f185dc 100644 --- a/api/lifiorder/model_quote_request_dto_intent_metadata.go +++ b/api/lifiorder/model_quote_request_dto_intent_metadata.go @@ -20,6 +20,12 @@ var _ MappedNullable = &QuoteRequestDtoIntentMetadata{} // QuoteRequestDtoIntentMetadata Metadata for the order, never required, potentially contains provider specific data type QuoteRequestDtoIntentMetadata struct { ExclusiveFor *QuoteRequestDtoIntentMetadataExclusiveFor `json:"exclusiveFor,omitempty"` + // Accepted cross-chain verifier (oracle) contracts, each a { chain, address } object. When provided, only solvers that support one of these oracles can answer, and the returned order is built to settle against an accepted oracle. Omitted or empty means any oracle is acceptable. Ignored for same-chain swaps. + Oracle []QuoteRequestDtoIntentMetadataOracleInner `json:"oracle,omitempty"` + // Accepted input settler contracts, each a { chain, address } object. When provided, a quote is only returned if the order is built with one of these input settlers and the winning solver supports it. Omitted or empty means any input settler is acceptable. + InputSettler []QuoteRequestDtoIntentMetadataOracleInner `json:"inputSettler,omitempty"` + // Accepted output settler contracts, each a { chain, address } object. When provided, a quote is only returned if the order is built with one of these output settlers and the winning solver supports it. Omitted or empty means any output settler is acceptable. + OutputSettler []QuoteRequestDtoIntentMetadataOracleInner `json:"outputSettler,omitempty"` } // NewQuoteRequestDtoIntentMetadata instantiates a new QuoteRequestDtoIntentMetadata object @@ -71,6 +77,102 @@ func (o *QuoteRequestDtoIntentMetadata) SetExclusiveFor(v QuoteRequestDtoIntentM o.ExclusiveFor = &v } +// GetOracle returns the Oracle field value if set, zero value otherwise. +func (o *QuoteRequestDtoIntentMetadata) GetOracle() []QuoteRequestDtoIntentMetadataOracleInner { + if o == nil || IsNil(o.Oracle) { + var ret []QuoteRequestDtoIntentMetadataOracleInner + return ret + } + return o.Oracle +} + +// GetOracleOk returns a tuple with the Oracle field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *QuoteRequestDtoIntentMetadata) GetOracleOk() ([]QuoteRequestDtoIntentMetadataOracleInner, bool) { + if o == nil || IsNil(o.Oracle) { + return nil, false + } + return o.Oracle, true +} + +// HasOracle returns a boolean if a field has been set. +func (o *QuoteRequestDtoIntentMetadata) HasOracle() bool { + if o != nil && !IsNil(o.Oracle) { + return true + } + + return false +} + +// SetOracle gets a reference to the given []QuoteRequestDtoIntentMetadataOracleInner and assigns it to the Oracle field. +func (o *QuoteRequestDtoIntentMetadata) SetOracle(v []QuoteRequestDtoIntentMetadataOracleInner) { + o.Oracle = v +} + +// GetInputSettler returns the InputSettler field value if set, zero value otherwise. +func (o *QuoteRequestDtoIntentMetadata) GetInputSettler() []QuoteRequestDtoIntentMetadataOracleInner { + if o == nil || IsNil(o.InputSettler) { + var ret []QuoteRequestDtoIntentMetadataOracleInner + return ret + } + return o.InputSettler +} + +// GetInputSettlerOk returns a tuple with the InputSettler field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *QuoteRequestDtoIntentMetadata) GetInputSettlerOk() ([]QuoteRequestDtoIntentMetadataOracleInner, bool) { + if o == nil || IsNil(o.InputSettler) { + return nil, false + } + return o.InputSettler, true +} + +// HasInputSettler returns a boolean if a field has been set. +func (o *QuoteRequestDtoIntentMetadata) HasInputSettler() bool { + if o != nil && !IsNil(o.InputSettler) { + return true + } + + return false +} + +// SetInputSettler gets a reference to the given []QuoteRequestDtoIntentMetadataOracleInner and assigns it to the InputSettler field. +func (o *QuoteRequestDtoIntentMetadata) SetInputSettler(v []QuoteRequestDtoIntentMetadataOracleInner) { + o.InputSettler = v +} + +// GetOutputSettler returns the OutputSettler field value if set, zero value otherwise. +func (o *QuoteRequestDtoIntentMetadata) GetOutputSettler() []QuoteRequestDtoIntentMetadataOracleInner { + if o == nil || IsNil(o.OutputSettler) { + var ret []QuoteRequestDtoIntentMetadataOracleInner + return ret + } + return o.OutputSettler +} + +// GetOutputSettlerOk returns a tuple with the OutputSettler field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *QuoteRequestDtoIntentMetadata) GetOutputSettlerOk() ([]QuoteRequestDtoIntentMetadataOracleInner, bool) { + if o == nil || IsNil(o.OutputSettler) { + return nil, false + } + return o.OutputSettler, true +} + +// HasOutputSettler returns a boolean if a field has been set. +func (o *QuoteRequestDtoIntentMetadata) HasOutputSettler() bool { + if o != nil && !IsNil(o.OutputSettler) { + return true + } + + return false +} + +// SetOutputSettler gets a reference to the given []QuoteRequestDtoIntentMetadataOracleInner and assigns it to the OutputSettler field. +func (o *QuoteRequestDtoIntentMetadata) SetOutputSettler(v []QuoteRequestDtoIntentMetadataOracleInner) { + o.OutputSettler = v +} + func (o QuoteRequestDtoIntentMetadata) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -84,6 +186,15 @@ func (o QuoteRequestDtoIntentMetadata) ToMap() (map[string]interface{}, error) { if !IsNil(o.ExclusiveFor) { toSerialize["exclusiveFor"] = o.ExclusiveFor } + if !IsNil(o.Oracle) { + toSerialize["oracle"] = o.Oracle + } + if !IsNil(o.InputSettler) { + toSerialize["inputSettler"] = o.InputSettler + } + if !IsNil(o.OutputSettler) { + toSerialize["outputSettler"] = o.OutputSettler + } return toSerialize, nil } diff --git a/api/lifiorder/model_quote_request_dto_intent_metadata_oracle_inner.go b/api/lifiorder/model_quote_request_dto_intent_metadata_oracle_inner.go new file mode 100644 index 00000000..9915f8b0 --- /dev/null +++ b/api/lifiorder/model_quote_request_dto_intent_metadata_oracle_inner.go @@ -0,0 +1,186 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the QuoteRequestDtoIntentMetadataOracleInner type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &QuoteRequestDtoIntentMetadataOracleInner{} + +// QuoteRequestDtoIntentMetadataOracleInner struct for QuoteRequestDtoIntentMetadataOracleInner +type QuoteRequestDtoIntentMetadataOracleInner struct { + // CAIP-2 chain identifier, e.g. \"eip155:1\" + Chain string `json:"chain"` + // Native contract address for the chain + Address string `json:"address"` +} + +type _QuoteRequestDtoIntentMetadataOracleInner QuoteRequestDtoIntentMetadataOracleInner + +// NewQuoteRequestDtoIntentMetadataOracleInner instantiates a new QuoteRequestDtoIntentMetadataOracleInner object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewQuoteRequestDtoIntentMetadataOracleInner(chain string, address string) *QuoteRequestDtoIntentMetadataOracleInner { + this := QuoteRequestDtoIntentMetadataOracleInner{} + this.Chain = chain + this.Address = address + return &this +} + +// NewQuoteRequestDtoIntentMetadataOracleInnerWithDefaults instantiates a new QuoteRequestDtoIntentMetadataOracleInner object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewQuoteRequestDtoIntentMetadataOracleInnerWithDefaults() *QuoteRequestDtoIntentMetadataOracleInner { + this := QuoteRequestDtoIntentMetadataOracleInner{} + return &this +} + +// GetChain returns the Chain field value +func (o *QuoteRequestDtoIntentMetadataOracleInner) GetChain() string { + if o == nil { + var ret string + return ret + } + + return o.Chain +} + +// GetChainOk returns a tuple with the Chain field value +// and a boolean to check if the value has been set. +func (o *QuoteRequestDtoIntentMetadataOracleInner) GetChainOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Chain, true +} + +// SetChain sets field value +func (o *QuoteRequestDtoIntentMetadataOracleInner) SetChain(v string) { + o.Chain = v +} + +// GetAddress returns the Address field value +func (o *QuoteRequestDtoIntentMetadataOracleInner) GetAddress() string { + if o == nil { + var ret string + return ret + } + + return o.Address +} + +// GetAddressOk returns a tuple with the Address field value +// and a boolean to check if the value has been set. +func (o *QuoteRequestDtoIntentMetadataOracleInner) GetAddressOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Address, true +} + +// SetAddress sets field value +func (o *QuoteRequestDtoIntentMetadataOracleInner) SetAddress(v string) { + o.Address = v +} + +func (o QuoteRequestDtoIntentMetadataOracleInner) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o QuoteRequestDtoIntentMetadataOracleInner) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["chain"] = o.Chain + toSerialize["address"] = o.Address + return toSerialize, nil +} + +func (o *QuoteRequestDtoIntentMetadataOracleInner) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "chain", + "address", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varQuoteRequestDtoIntentMetadataOracleInner := _QuoteRequestDtoIntentMetadataOracleInner{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varQuoteRequestDtoIntentMetadataOracleInner) + + if err != nil { + return err + } + + *o = QuoteRequestDtoIntentMetadataOracleInner(varQuoteRequestDtoIntentMetadataOracleInner) + + return err +} + +type NullableQuoteRequestDtoIntentMetadataOracleInner struct { + value *QuoteRequestDtoIntentMetadataOracleInner + isSet bool +} + +func (v NullableQuoteRequestDtoIntentMetadataOracleInner) Get() *QuoteRequestDtoIntentMetadataOracleInner { + return v.value +} + +func (v *NullableQuoteRequestDtoIntentMetadataOracleInner) Set(val *QuoteRequestDtoIntentMetadataOracleInner) { + v.value = val + v.isSet = true +} + +func (v NullableQuoteRequestDtoIntentMetadataOracleInner) IsSet() bool { + return v.isSet +} + +func (v *NullableQuoteRequestDtoIntentMetadataOracleInner) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableQuoteRequestDtoIntentMetadataOracleInner(val *QuoteRequestDtoIntentMetadataOracleInner) *NullableQuoteRequestDtoIntentMetadataOracleInner { + return &NullableQuoteRequestDtoIntentMetadataOracleInner{value: val, isSet: true} +} + +func (v NullableQuoteRequestDtoIntentMetadataOracleInner) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableQuoteRequestDtoIntentMetadataOracleInner) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_quote_request_dto_intent_outputs_inner.go b/api/lifiorder/model_quote_request_dto_intent_outputs_inner.go index 4607dc0b..e5105f7c 100644 --- a/api/lifiorder/model_quote_request_dto_intent_outputs_inner.go +++ b/api/lifiorder/model_quote_request_dto_intent_outputs_inner.go @@ -26,8 +26,7 @@ type QuoteRequestDtoIntentOutputsInner struct { // Native address that will receive the output assets Receiver string `json:"receiver"` // Native address of the token/asset to be received as output - Asset string `json:"asset"` - // For exact-input: ignored by the quote decoder and not used for quoting. For exact-output: exact amount user wants to receive and is used for quoting + Asset string `json:"asset"` Amount NullableString `json:"amount,omitempty"` // Optional calldata describing how the receiver will consume the output. Enables composability with other protocols Calldata *string `json:"calldata,omitempty"` diff --git a/api/lifiorder/model_solver_quote_dto.go b/api/lifiorder/model_solver_quote_dto.go index 0f54897e..4a814ba2 100644 --- a/api/lifiorder/model_solver_quote_dto.go +++ b/api/lifiorder/model_solver_quote_dto.go @@ -52,19 +52,19 @@ type SolverQuoteDto struct { // Maximum amount for this quote range MaxAmount string `json:"maxAmount"` // Exclusive for address - ExclusiveFor map[string]interface{} `json:"exclusiveFor"` + ExclusiveFor NullableString `json:"exclusiveFor"` // Source asset record ID - FromAssetRecordId map[string]interface{} `json:"fromAssetRecordId"` + FromAssetRecordId NullableFloat32 `json:"fromAssetRecordId"` // Destination asset record ID - ToAssetRecordId map[string]interface{} `json:"toAssetRecordId"` + ToAssetRecordId NullableFloat32 `json:"toAssetRecordId"` // Source chain record ID - FromChainRecordId map[string]interface{} `json:"fromChainRecordId"` + FromChainRecordId NullableFloat32 `json:"fromChainRecordId"` // Destination chain record ID - ToChainRecordId map[string]interface{} `json:"toChainRecordId"` + ToChainRecordId NullableFloat32 `json:"toChainRecordId"` // Associated solver ID SolverId float32 `json:"solverId"` // Integrator key hash this quote is tagged for, or null for open-market quotes - IntegratorKeyHash map[string]interface{} `json:"integratorKeyHash,omitempty"` + IntegratorKeyHash NullableString `json:"integratorKeyHash,omitempty"` } type _SolverQuoteDto SolverQuoteDto @@ -73,7 +73,7 @@ type _SolverQuoteDto SolverQuoteDto // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewSolverQuoteDto(id string, createdAt string, updatedAt string, fromChainNetworkId string, toChainNetworkId string, fromAssetAddress string, toAssetAddress string, fromAssetDecimals float32, toAssetDecimals float32, fromDecimals float32, toDecimals float32, expiry string, quote string, minAmount string, maxAmount string, exclusiveFor map[string]interface{}, fromAssetRecordId map[string]interface{}, toAssetRecordId map[string]interface{}, fromChainRecordId map[string]interface{}, toChainRecordId map[string]interface{}, solverId float32) *SolverQuoteDto { +func NewSolverQuoteDto(id string, createdAt string, updatedAt string, fromChainNetworkId string, toChainNetworkId string, fromAssetAddress string, toAssetAddress string, fromAssetDecimals float32, toAssetDecimals float32, fromDecimals float32, toDecimals float32, expiry string, quote string, minAmount string, maxAmount string, exclusiveFor NullableString, fromAssetRecordId NullableFloat32, toAssetRecordId NullableFloat32, fromChainRecordId NullableFloat32, toChainRecordId NullableFloat32, solverId float32) *SolverQuoteDto { this := SolverQuoteDto{} this.Id = id this.CreatedAt = createdAt @@ -468,133 +468,133 @@ func (o *SolverQuoteDto) SetMaxAmount(v string) { } // GetExclusiveFor returns the ExclusiveFor field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *SolverQuoteDto) GetExclusiveFor() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *SolverQuoteDto) GetExclusiveFor() string { + if o == nil || o.ExclusiveFor.Get() == nil { + var ret string return ret } - return o.ExclusiveFor + return *o.ExclusiveFor.Get() } // GetExclusiveForOk returns a tuple with the ExclusiveFor field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *SolverQuoteDto) GetExclusiveForOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.ExclusiveFor) { - return map[string]interface{}{}, false +func (o *SolverQuoteDto) GetExclusiveForOk() (*string, bool) { + if o == nil { + return nil, false } - return o.ExclusiveFor, true + return o.ExclusiveFor.Get(), o.ExclusiveFor.IsSet() } // SetExclusiveFor sets field value -func (o *SolverQuoteDto) SetExclusiveFor(v map[string]interface{}) { - o.ExclusiveFor = v +func (o *SolverQuoteDto) SetExclusiveFor(v string) { + o.ExclusiveFor.Set(&v) } // GetFromAssetRecordId returns the FromAssetRecordId field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *SolverQuoteDto) GetFromAssetRecordId() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for float32 will be returned +func (o *SolverQuoteDto) GetFromAssetRecordId() float32 { + if o == nil || o.FromAssetRecordId.Get() == nil { + var ret float32 return ret } - return o.FromAssetRecordId + return *o.FromAssetRecordId.Get() } // GetFromAssetRecordIdOk returns a tuple with the FromAssetRecordId field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *SolverQuoteDto) GetFromAssetRecordIdOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.FromAssetRecordId) { - return map[string]interface{}{}, false +func (o *SolverQuoteDto) GetFromAssetRecordIdOk() (*float32, bool) { + if o == nil { + return nil, false } - return o.FromAssetRecordId, true + return o.FromAssetRecordId.Get(), o.FromAssetRecordId.IsSet() } // SetFromAssetRecordId sets field value -func (o *SolverQuoteDto) SetFromAssetRecordId(v map[string]interface{}) { - o.FromAssetRecordId = v +func (o *SolverQuoteDto) SetFromAssetRecordId(v float32) { + o.FromAssetRecordId.Set(&v) } // GetToAssetRecordId returns the ToAssetRecordId field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *SolverQuoteDto) GetToAssetRecordId() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for float32 will be returned +func (o *SolverQuoteDto) GetToAssetRecordId() float32 { + if o == nil || o.ToAssetRecordId.Get() == nil { + var ret float32 return ret } - return o.ToAssetRecordId + return *o.ToAssetRecordId.Get() } // GetToAssetRecordIdOk returns a tuple with the ToAssetRecordId field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *SolverQuoteDto) GetToAssetRecordIdOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.ToAssetRecordId) { - return map[string]interface{}{}, false +func (o *SolverQuoteDto) GetToAssetRecordIdOk() (*float32, bool) { + if o == nil { + return nil, false } - return o.ToAssetRecordId, true + return o.ToAssetRecordId.Get(), o.ToAssetRecordId.IsSet() } // SetToAssetRecordId sets field value -func (o *SolverQuoteDto) SetToAssetRecordId(v map[string]interface{}) { - o.ToAssetRecordId = v +func (o *SolverQuoteDto) SetToAssetRecordId(v float32) { + o.ToAssetRecordId.Set(&v) } // GetFromChainRecordId returns the FromChainRecordId field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *SolverQuoteDto) GetFromChainRecordId() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for float32 will be returned +func (o *SolverQuoteDto) GetFromChainRecordId() float32 { + if o == nil || o.FromChainRecordId.Get() == nil { + var ret float32 return ret } - return o.FromChainRecordId + return *o.FromChainRecordId.Get() } // GetFromChainRecordIdOk returns a tuple with the FromChainRecordId field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *SolverQuoteDto) GetFromChainRecordIdOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.FromChainRecordId) { - return map[string]interface{}{}, false +func (o *SolverQuoteDto) GetFromChainRecordIdOk() (*float32, bool) { + if o == nil { + return nil, false } - return o.FromChainRecordId, true + return o.FromChainRecordId.Get(), o.FromChainRecordId.IsSet() } // SetFromChainRecordId sets field value -func (o *SolverQuoteDto) SetFromChainRecordId(v map[string]interface{}) { - o.FromChainRecordId = v +func (o *SolverQuoteDto) SetFromChainRecordId(v float32) { + o.FromChainRecordId.Set(&v) } // GetToChainRecordId returns the ToChainRecordId field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *SolverQuoteDto) GetToChainRecordId() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for float32 will be returned +func (o *SolverQuoteDto) GetToChainRecordId() float32 { + if o == nil || o.ToChainRecordId.Get() == nil { + var ret float32 return ret } - return o.ToChainRecordId + return *o.ToChainRecordId.Get() } // GetToChainRecordIdOk returns a tuple with the ToChainRecordId field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *SolverQuoteDto) GetToChainRecordIdOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.ToChainRecordId) { - return map[string]interface{}{}, false +func (o *SolverQuoteDto) GetToChainRecordIdOk() (*float32, bool) { + if o == nil { + return nil, false } - return o.ToChainRecordId, true + return o.ToChainRecordId.Get(), o.ToChainRecordId.IsSet() } // SetToChainRecordId sets field value -func (o *SolverQuoteDto) SetToChainRecordId(v map[string]interface{}) { - o.ToChainRecordId = v +func (o *SolverQuoteDto) SetToChainRecordId(v float32) { + o.ToChainRecordId.Set(&v) } // GetSolverId returns the SolverId field value @@ -622,36 +622,46 @@ func (o *SolverQuoteDto) SetSolverId(v float32) { } // GetIntegratorKeyHash returns the IntegratorKeyHash field value if set, zero value otherwise (both if not set or set to explicit null). -func (o *SolverQuoteDto) GetIntegratorKeyHash() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +func (o *SolverQuoteDto) GetIntegratorKeyHash() string { + if o == nil || IsNil(o.IntegratorKeyHash.Get()) { + var ret string return ret } - return o.IntegratorKeyHash + return *o.IntegratorKeyHash.Get() } // GetIntegratorKeyHashOk returns a tuple with the IntegratorKeyHash field value if set, nil otherwise // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *SolverQuoteDto) GetIntegratorKeyHashOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.IntegratorKeyHash) { - return map[string]interface{}{}, false +func (o *SolverQuoteDto) GetIntegratorKeyHashOk() (*string, bool) { + if o == nil { + return nil, false } - return o.IntegratorKeyHash, true + return o.IntegratorKeyHash.Get(), o.IntegratorKeyHash.IsSet() } // HasIntegratorKeyHash returns a boolean if a field has been set. func (o *SolverQuoteDto) HasIntegratorKeyHash() bool { - if o != nil && !IsNil(o.IntegratorKeyHash) { + if o != nil && o.IntegratorKeyHash.IsSet() { return true } return false } -// SetIntegratorKeyHash gets a reference to the given map[string]interface{} and assigns it to the IntegratorKeyHash field. -func (o *SolverQuoteDto) SetIntegratorKeyHash(v map[string]interface{}) { - o.IntegratorKeyHash = v +// SetIntegratorKeyHash gets a reference to the given NullableString and assigns it to the IntegratorKeyHash field. +func (o *SolverQuoteDto) SetIntegratorKeyHash(v string) { + o.IntegratorKeyHash.Set(&v) +} + +// SetIntegratorKeyHashNil sets the value for IntegratorKeyHash to be an explicit nil +func (o *SolverQuoteDto) SetIntegratorKeyHashNil() { + o.IntegratorKeyHash.Set(nil) +} + +// UnsetIntegratorKeyHash ensures that no value is present for IntegratorKeyHash, not even an explicit nil +func (o *SolverQuoteDto) UnsetIntegratorKeyHash() { + o.IntegratorKeyHash.Unset() } func (o SolverQuoteDto) MarshalJSON() ([]byte, error) { @@ -679,24 +689,14 @@ func (o SolverQuoteDto) ToMap() (map[string]interface{}, error) { toSerialize["quote"] = o.Quote toSerialize["minAmount"] = o.MinAmount toSerialize["maxAmount"] = o.MaxAmount - if o.ExclusiveFor != nil { - toSerialize["exclusiveFor"] = o.ExclusiveFor - } - if o.FromAssetRecordId != nil { - toSerialize["fromAssetRecordId"] = o.FromAssetRecordId - } - if o.ToAssetRecordId != nil { - toSerialize["toAssetRecordId"] = o.ToAssetRecordId - } - if o.FromChainRecordId != nil { - toSerialize["fromChainRecordId"] = o.FromChainRecordId - } - if o.ToChainRecordId != nil { - toSerialize["toChainRecordId"] = o.ToChainRecordId - } + toSerialize["exclusiveFor"] = o.ExclusiveFor.Get() + toSerialize["fromAssetRecordId"] = o.FromAssetRecordId.Get() + toSerialize["toAssetRecordId"] = o.ToAssetRecordId.Get() + toSerialize["fromChainRecordId"] = o.FromChainRecordId.Get() + toSerialize["toChainRecordId"] = o.ToChainRecordId.Get() toSerialize["solverId"] = o.SolverId - if o.IntegratorKeyHash != nil { - toSerialize["integratorKeyHash"] = o.IntegratorKeyHash + if o.IntegratorKeyHash.IsSet() { + toSerialize["integratorKeyHash"] = o.IntegratorKeyHash.Get() } return toSerialize, nil } diff --git a/api/lifiorder/model_submit_order_dto_order.go b/api/lifiorder/model_submit_order_dto_order.go index afa4b984..8b12ed86 100644 --- a/api/lifiorder/model_submit_order_dto_order.go +++ b/api/lifiorder/model_submit_order_dto_order.go @@ -24,17 +24,17 @@ type SubmitOrderDtoOrder struct { // User address on source chain (initiator of the intent) User string `json:"user"` // Nonce value of the intent - Nonce *string `json:"nonce,omitempty"` + Nonce string `json:"nonce"` // Origin chain ID (network id) - OriginChainId *string `json:"originChainId,omitempty"` + OriginChainId string `json:"originChainId"` // Fill deadline of the intent in seconds - FillDeadline *string `json:"fillDeadline,omitempty"` + FillDeadline string `json:"fillDeadline"` // Expiry timestamp of the intent in seconds - Expires *string `json:"expires,omitempty"` + Expires string `json:"expires"` // The local oracle address InputOracle string `json:"inputOracle"` // Input token amounts as [tokenId, amount] pairs - Inputs [][]string `json:"inputs"` + Inputs [][]interface{} `json:"inputs"` // Array of output objects Outputs []SubmitOrderDtoOrderOutputsInner `json:"outputs"` } @@ -45,9 +45,13 @@ type _SubmitOrderDtoOrder SubmitOrderDtoOrder // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewSubmitOrderDtoOrder(user string, inputOracle string, inputs [][]string, outputs []SubmitOrderDtoOrderOutputsInner) *SubmitOrderDtoOrder { +func NewSubmitOrderDtoOrder(user string, nonce string, originChainId string, fillDeadline string, expires string, inputOracle string, inputs [][]interface{}, outputs []SubmitOrderDtoOrderOutputsInner) *SubmitOrderDtoOrder { this := SubmitOrderDtoOrder{} this.User = user + this.Nonce = nonce + this.OriginChainId = originChainId + this.FillDeadline = fillDeadline + this.Expires = expires this.InputOracle = inputOracle this.Inputs = inputs this.Outputs = outputs @@ -86,132 +90,100 @@ func (o *SubmitOrderDtoOrder) SetUser(v string) { o.User = v } -// GetNonce returns the Nonce field value if set, zero value otherwise. +// GetNonce returns the Nonce field value func (o *SubmitOrderDtoOrder) GetNonce() string { - if o == nil || IsNil(o.Nonce) { + if o == nil { var ret string return ret } - return *o.Nonce + + return o.Nonce } -// GetNonceOk returns a tuple with the Nonce field value if set, nil otherwise +// GetNonceOk returns a tuple with the Nonce field value // and a boolean to check if the value has been set. func (o *SubmitOrderDtoOrder) GetNonceOk() (*string, bool) { - if o == nil || IsNil(o.Nonce) { + if o == nil { return nil, false } - return o.Nonce, true -} - -// HasNonce returns a boolean if a field has been set. -func (o *SubmitOrderDtoOrder) HasNonce() bool { - if o != nil && !IsNil(o.Nonce) { - return true - } - - return false + return &o.Nonce, true } -// SetNonce gets a reference to the given string and assigns it to the Nonce field. +// SetNonce sets field value func (o *SubmitOrderDtoOrder) SetNonce(v string) { - o.Nonce = &v + o.Nonce = v } -// GetOriginChainId returns the OriginChainId field value if set, zero value otherwise. +// GetOriginChainId returns the OriginChainId field value func (o *SubmitOrderDtoOrder) GetOriginChainId() string { - if o == nil || IsNil(o.OriginChainId) { + if o == nil { var ret string return ret } - return *o.OriginChainId + + return o.OriginChainId } -// GetOriginChainIdOk returns a tuple with the OriginChainId field value if set, nil otherwise +// GetOriginChainIdOk returns a tuple with the OriginChainId field value // and a boolean to check if the value has been set. func (o *SubmitOrderDtoOrder) GetOriginChainIdOk() (*string, bool) { - if o == nil || IsNil(o.OriginChainId) { + if o == nil { return nil, false } - return o.OriginChainId, true -} - -// HasOriginChainId returns a boolean if a field has been set. -func (o *SubmitOrderDtoOrder) HasOriginChainId() bool { - if o != nil && !IsNil(o.OriginChainId) { - return true - } - - return false + return &o.OriginChainId, true } -// SetOriginChainId gets a reference to the given string and assigns it to the OriginChainId field. +// SetOriginChainId sets field value func (o *SubmitOrderDtoOrder) SetOriginChainId(v string) { - o.OriginChainId = &v + o.OriginChainId = v } -// GetFillDeadline returns the FillDeadline field value if set, zero value otherwise. +// GetFillDeadline returns the FillDeadline field value func (o *SubmitOrderDtoOrder) GetFillDeadline() string { - if o == nil || IsNil(o.FillDeadline) { + if o == nil { var ret string return ret } - return *o.FillDeadline + + return o.FillDeadline } -// GetFillDeadlineOk returns a tuple with the FillDeadline field value if set, nil otherwise +// GetFillDeadlineOk returns a tuple with the FillDeadline field value // and a boolean to check if the value has been set. func (o *SubmitOrderDtoOrder) GetFillDeadlineOk() (*string, bool) { - if o == nil || IsNil(o.FillDeadline) { + if o == nil { return nil, false } - return o.FillDeadline, true -} - -// HasFillDeadline returns a boolean if a field has been set. -func (o *SubmitOrderDtoOrder) HasFillDeadline() bool { - if o != nil && !IsNil(o.FillDeadline) { - return true - } - - return false + return &o.FillDeadline, true } -// SetFillDeadline gets a reference to the given string and assigns it to the FillDeadline field. +// SetFillDeadline sets field value func (o *SubmitOrderDtoOrder) SetFillDeadline(v string) { - o.FillDeadline = &v + o.FillDeadline = v } -// GetExpires returns the Expires field value if set, zero value otherwise. +// GetExpires returns the Expires field value func (o *SubmitOrderDtoOrder) GetExpires() string { - if o == nil || IsNil(o.Expires) { + if o == nil { var ret string return ret } - return *o.Expires + + return o.Expires } -// GetExpiresOk returns a tuple with the Expires field value if set, nil otherwise +// GetExpiresOk returns a tuple with the Expires field value // and a boolean to check if the value has been set. func (o *SubmitOrderDtoOrder) GetExpiresOk() (*string, bool) { - if o == nil || IsNil(o.Expires) { + if o == nil { return nil, false } - return o.Expires, true + return &o.Expires, true } -// HasExpires returns a boolean if a field has been set. -func (o *SubmitOrderDtoOrder) HasExpires() bool { - if o != nil && !IsNil(o.Expires) { - return true - } - - return false -} - -// SetExpires gets a reference to the given string and assigns it to the Expires field. +// SetExpires sets field value func (o *SubmitOrderDtoOrder) SetExpires(v string) { - o.Expires = &v + o.Expires = v } // GetInputOracle returns the InputOracle field value @@ -239,9 +211,9 @@ func (o *SubmitOrderDtoOrder) SetInputOracle(v string) { } // GetInputs returns the Inputs field value -func (o *SubmitOrderDtoOrder) GetInputs() [][]string { +func (o *SubmitOrderDtoOrder) GetInputs() [][]interface{} { if o == nil { - var ret [][]string + var ret [][]interface{} return ret } @@ -250,7 +222,7 @@ func (o *SubmitOrderDtoOrder) GetInputs() [][]string { // GetInputsOk returns a tuple with the Inputs field value // and a boolean to check if the value has been set. -func (o *SubmitOrderDtoOrder) GetInputsOk() ([][]string, bool) { +func (o *SubmitOrderDtoOrder) GetInputsOk() ([][]interface{}, bool) { if o == nil { return nil, false } @@ -258,7 +230,7 @@ func (o *SubmitOrderDtoOrder) GetInputsOk() ([][]string, bool) { } // SetInputs sets field value -func (o *SubmitOrderDtoOrder) SetInputs(v [][]string) { +func (o *SubmitOrderDtoOrder) SetInputs(v [][]interface{}) { o.Inputs = v } @@ -297,18 +269,10 @@ func (o SubmitOrderDtoOrder) MarshalJSON() ([]byte, error) { func (o SubmitOrderDtoOrder) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} toSerialize["user"] = o.User - if !IsNil(o.Nonce) { - toSerialize["nonce"] = o.Nonce - } - if !IsNil(o.OriginChainId) { - toSerialize["originChainId"] = o.OriginChainId - } - if !IsNil(o.FillDeadline) { - toSerialize["fillDeadline"] = o.FillDeadline - } - if !IsNil(o.Expires) { - toSerialize["expires"] = o.Expires - } + toSerialize["nonce"] = o.Nonce + toSerialize["originChainId"] = o.OriginChainId + toSerialize["fillDeadline"] = o.FillDeadline + toSerialize["expires"] = o.Expires toSerialize["inputOracle"] = o.InputOracle toSerialize["inputs"] = o.Inputs toSerialize["outputs"] = o.Outputs @@ -321,6 +285,10 @@ func (o *SubmitOrderDtoOrder) UnmarshalJSON(data []byte) (err error) { // that every required field exists as a key in the generic map. requiredProperties := []string{ "user", + "nonce", + "originChainId", + "fillDeadline", + "expires", "inputOracle", "inputs", "outputs", diff --git a/api/lifiorder/model_submit_order_dto_order_outputs_inner.go b/api/lifiorder/model_submit_order_dto_order_outputs_inner.go index 4b90fdad..e6596f52 100644 --- a/api/lifiorder/model_submit_order_dto_order_outputs_inner.go +++ b/api/lifiorder/model_submit_order_dto_order_outputs_inner.go @@ -28,15 +28,13 @@ type SubmitOrderDtoOrderOutputsInner struct { // The token identifier Token string `json:"token"` // The amount of tokens - Amount *string `json:"amount,omitempty"` + Amount string `json:"amount"` // The recipient address Recipient string `json:"recipient"` // The chain ID - ChainId *string `json:"chainId,omitempty"` - // The remote call data + ChainId string `json:"chainId"` CallbackData NullableString `json:"callbackData,omitempty"` - // The fulfillment context - Context NullableString `json:"context,omitempty"` + Context NullableString `json:"context,omitempty"` } type _SubmitOrderDtoOrderOutputsInner SubmitOrderDtoOrderOutputsInner @@ -45,12 +43,14 @@ type _SubmitOrderDtoOrderOutputsInner SubmitOrderDtoOrderOutputsInner // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewSubmitOrderDtoOrderOutputsInner(oracle string, settler string, token string, recipient string) *SubmitOrderDtoOrderOutputsInner { +func NewSubmitOrderDtoOrderOutputsInner(oracle string, settler string, token string, amount string, recipient string, chainId string) *SubmitOrderDtoOrderOutputsInner { this := SubmitOrderDtoOrderOutputsInner{} this.Oracle = oracle this.Settler = settler this.Token = token + this.Amount = amount this.Recipient = recipient + this.ChainId = chainId return &this } @@ -134,36 +134,28 @@ func (o *SubmitOrderDtoOrderOutputsInner) SetToken(v string) { o.Token = v } -// GetAmount returns the Amount field value if set, zero value otherwise. +// GetAmount returns the Amount field value func (o *SubmitOrderDtoOrderOutputsInner) GetAmount() string { - if o == nil || IsNil(o.Amount) { + if o == nil { var ret string return ret } - return *o.Amount + + return o.Amount } -// GetAmountOk returns a tuple with the Amount field value if set, nil otherwise +// GetAmountOk returns a tuple with the Amount field value // and a boolean to check if the value has been set. func (o *SubmitOrderDtoOrderOutputsInner) GetAmountOk() (*string, bool) { - if o == nil || IsNil(o.Amount) { + if o == nil { return nil, false } - return o.Amount, true -} - -// HasAmount returns a boolean if a field has been set. -func (o *SubmitOrderDtoOrderOutputsInner) HasAmount() bool { - if o != nil && !IsNil(o.Amount) { - return true - } - - return false + return &o.Amount, true } -// SetAmount gets a reference to the given string and assigns it to the Amount field. +// SetAmount sets field value func (o *SubmitOrderDtoOrderOutputsInner) SetAmount(v string) { - o.Amount = &v + o.Amount = v } // GetRecipient returns the Recipient field value @@ -190,36 +182,28 @@ func (o *SubmitOrderDtoOrderOutputsInner) SetRecipient(v string) { o.Recipient = v } -// GetChainId returns the ChainId field value if set, zero value otherwise. +// GetChainId returns the ChainId field value func (o *SubmitOrderDtoOrderOutputsInner) GetChainId() string { - if o == nil || IsNil(o.ChainId) { + if o == nil { var ret string return ret } - return *o.ChainId + + return o.ChainId } -// GetChainIdOk returns a tuple with the ChainId field value if set, nil otherwise +// GetChainIdOk returns a tuple with the ChainId field value // and a boolean to check if the value has been set. func (o *SubmitOrderDtoOrderOutputsInner) GetChainIdOk() (*string, bool) { - if o == nil || IsNil(o.ChainId) { + if o == nil { return nil, false } - return o.ChainId, true -} - -// HasChainId returns a boolean if a field has been set. -func (o *SubmitOrderDtoOrderOutputsInner) HasChainId() bool { - if o != nil && !IsNil(o.ChainId) { - return true - } - - return false + return &o.ChainId, true } -// SetChainId gets a reference to the given string and assigns it to the ChainId field. +// SetChainId sets field value func (o *SubmitOrderDtoOrderOutputsInner) SetChainId(v string) { - o.ChainId = &v + o.ChainId = v } // GetCallbackData returns the CallbackData field value if set, zero value otherwise (both if not set or set to explicit null). @@ -321,13 +305,9 @@ func (o SubmitOrderDtoOrderOutputsInner) ToMap() (map[string]interface{}, error) toSerialize["oracle"] = o.Oracle toSerialize["settler"] = o.Settler toSerialize["token"] = o.Token - if !IsNil(o.Amount) { - toSerialize["amount"] = o.Amount - } + toSerialize["amount"] = o.Amount toSerialize["recipient"] = o.Recipient - if !IsNil(o.ChainId) { - toSerialize["chainId"] = o.ChainId - } + toSerialize["chainId"] = o.ChainId if o.CallbackData.IsSet() { toSerialize["callbackData"] = o.CallbackData.Get() } @@ -345,7 +325,9 @@ func (o *SubmitOrderDtoOrderOutputsInner) UnmarshalJSON(data []byte) (err error) "oracle", "settler", "token", + "amount", "recipient", + "chainId", } allProperties := make(map[string]interface{}) diff --git a/api/lifiorder/model_submit_order_response_dto.go b/api/lifiorder/model_submit_order_response_dto.go index ffde9d3c..ee254711 100644 --- a/api/lifiorder/model_submit_order_response_dto.go +++ b/api/lifiorder/model_submit_order_response_dto.go @@ -22,13 +22,12 @@ var _ MappedNullable = &SubmitOrderResponseDto{} // SubmitOrderResponseDto struct for SubmitOrderResponseDto type SubmitOrderResponseDto struct { // The order details - Order CompactOrderResponseDto `json:"order"` - // The quote details - Quote NullableQuoteResponseDto `json:"quote"` + Order CompactOrderResponseDto `json:"order"` + Quote NullableSubmittedOrderQuoteDto `json:"quote"` // Sponsor signature - SponsorSignature map[string]interface{} `json:"sponsorSignature,omitempty"` + SponsorSignature NullableString `json:"sponsorSignature,omitempty"` // Allocator signature - AllocatorSignature map[string]interface{} `json:"allocatorSignature,omitempty"` + AllocatorSignature NullableString `json:"allocatorSignature,omitempty"` // Input settler address InputSettler string `json:"inputSettler"` // Order metadata @@ -41,7 +40,7 @@ type _SubmitOrderResponseDto SubmitOrderResponseDto // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewSubmitOrderResponseDto(order CompactOrderResponseDto, quote NullableQuoteResponseDto, inputSettler string, meta OrderMetaDto) *SubmitOrderResponseDto { +func NewSubmitOrderResponseDto(order CompactOrderResponseDto, quote NullableSubmittedOrderQuoteDto, inputSettler string, meta OrderMetaDto) *SubmitOrderResponseDto { this := SubmitOrderResponseDto{} this.Order = order this.Quote = quote @@ -83,10 +82,10 @@ func (o *SubmitOrderResponseDto) SetOrder(v CompactOrderResponseDto) { } // GetQuote returns the Quote field value -// If the value is explicit nil, the zero value for QuoteResponseDto will be returned -func (o *SubmitOrderResponseDto) GetQuote() QuoteResponseDto { +// If the value is explicit nil, the zero value for SubmittedOrderQuoteDto will be returned +func (o *SubmitOrderResponseDto) GetQuote() SubmittedOrderQuoteDto { if o == nil || o.Quote.Get() == nil { - var ret QuoteResponseDto + var ret SubmittedOrderQuoteDto return ret } @@ -96,7 +95,7 @@ func (o *SubmitOrderResponseDto) GetQuote() QuoteResponseDto { // GetQuoteOk returns a tuple with the Quote field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *SubmitOrderResponseDto) GetQuoteOk() (*QuoteResponseDto, bool) { +func (o *SubmitOrderResponseDto) GetQuoteOk() (*SubmittedOrderQuoteDto, bool) { if o == nil { return nil, false } @@ -104,74 +103,94 @@ func (o *SubmitOrderResponseDto) GetQuoteOk() (*QuoteResponseDto, bool) { } // SetQuote sets field value -func (o *SubmitOrderResponseDto) SetQuote(v QuoteResponseDto) { +func (o *SubmitOrderResponseDto) SetQuote(v SubmittedOrderQuoteDto) { o.Quote.Set(&v) } // GetSponsorSignature returns the SponsorSignature field value if set, zero value otherwise (both if not set or set to explicit null). -func (o *SubmitOrderResponseDto) GetSponsorSignature() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +func (o *SubmitOrderResponseDto) GetSponsorSignature() string { + if o == nil || IsNil(o.SponsorSignature.Get()) { + var ret string return ret } - return o.SponsorSignature + return *o.SponsorSignature.Get() } // GetSponsorSignatureOk returns a tuple with the SponsorSignature field value if set, nil otherwise // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *SubmitOrderResponseDto) GetSponsorSignatureOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.SponsorSignature) { - return map[string]interface{}{}, false +func (o *SubmitOrderResponseDto) GetSponsorSignatureOk() (*string, bool) { + if o == nil { + return nil, false } - return o.SponsorSignature, true + return o.SponsorSignature.Get(), o.SponsorSignature.IsSet() } // HasSponsorSignature returns a boolean if a field has been set. func (o *SubmitOrderResponseDto) HasSponsorSignature() bool { - if o != nil && !IsNil(o.SponsorSignature) { + if o != nil && o.SponsorSignature.IsSet() { return true } return false } -// SetSponsorSignature gets a reference to the given map[string]interface{} and assigns it to the SponsorSignature field. -func (o *SubmitOrderResponseDto) SetSponsorSignature(v map[string]interface{}) { - o.SponsorSignature = v +// SetSponsorSignature gets a reference to the given NullableString and assigns it to the SponsorSignature field. +func (o *SubmitOrderResponseDto) SetSponsorSignature(v string) { + o.SponsorSignature.Set(&v) +} + +// SetSponsorSignatureNil sets the value for SponsorSignature to be an explicit nil +func (o *SubmitOrderResponseDto) SetSponsorSignatureNil() { + o.SponsorSignature.Set(nil) +} + +// UnsetSponsorSignature ensures that no value is present for SponsorSignature, not even an explicit nil +func (o *SubmitOrderResponseDto) UnsetSponsorSignature() { + o.SponsorSignature.Unset() } // GetAllocatorSignature returns the AllocatorSignature field value if set, zero value otherwise (both if not set or set to explicit null). -func (o *SubmitOrderResponseDto) GetAllocatorSignature() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +func (o *SubmitOrderResponseDto) GetAllocatorSignature() string { + if o == nil || IsNil(o.AllocatorSignature.Get()) { + var ret string return ret } - return o.AllocatorSignature + return *o.AllocatorSignature.Get() } // GetAllocatorSignatureOk returns a tuple with the AllocatorSignature field value if set, nil otherwise // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *SubmitOrderResponseDto) GetAllocatorSignatureOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.AllocatorSignature) { - return map[string]interface{}{}, false +func (o *SubmitOrderResponseDto) GetAllocatorSignatureOk() (*string, bool) { + if o == nil { + return nil, false } - return o.AllocatorSignature, true + return o.AllocatorSignature.Get(), o.AllocatorSignature.IsSet() } // HasAllocatorSignature returns a boolean if a field has been set. func (o *SubmitOrderResponseDto) HasAllocatorSignature() bool { - if o != nil && !IsNil(o.AllocatorSignature) { + if o != nil && o.AllocatorSignature.IsSet() { return true } return false } -// SetAllocatorSignature gets a reference to the given map[string]interface{} and assigns it to the AllocatorSignature field. -func (o *SubmitOrderResponseDto) SetAllocatorSignature(v map[string]interface{}) { - o.AllocatorSignature = v +// SetAllocatorSignature gets a reference to the given NullableString and assigns it to the AllocatorSignature field. +func (o *SubmitOrderResponseDto) SetAllocatorSignature(v string) { + o.AllocatorSignature.Set(&v) +} + +// SetAllocatorSignatureNil sets the value for AllocatorSignature to be an explicit nil +func (o *SubmitOrderResponseDto) SetAllocatorSignatureNil() { + o.AllocatorSignature.Set(nil) +} + +// UnsetAllocatorSignature ensures that no value is present for AllocatorSignature, not even an explicit nil +func (o *SubmitOrderResponseDto) UnsetAllocatorSignature() { + o.AllocatorSignature.Unset() } // GetInputSettler returns the InputSettler field value @@ -234,11 +253,11 @@ func (o SubmitOrderResponseDto) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} toSerialize["order"] = o.Order toSerialize["quote"] = o.Quote.Get() - if o.SponsorSignature != nil { - toSerialize["sponsorSignature"] = o.SponsorSignature + if o.SponsorSignature.IsSet() { + toSerialize["sponsorSignature"] = o.SponsorSignature.Get() } - if o.AllocatorSignature != nil { - toSerialize["allocatorSignature"] = o.AllocatorSignature + if o.AllocatorSignature.IsSet() { + toSerialize["allocatorSignature"] = o.AllocatorSignature.Get() } toSerialize["inputSettler"] = o.InputSettler toSerialize["meta"] = o.Meta diff --git a/api/lifiorder/model_submit_quotes_dto_quotes_inner.go b/api/lifiorder/model_submit_quotes_dto_quotes_inner.go index acdd3f2c..84ef77a4 100644 --- a/api/lifiorder/model_submit_quotes_dto_quotes_inner.go +++ b/api/lifiorder/model_submit_quotes_dto_quotes_inner.go @@ -33,13 +33,13 @@ type SubmitQuotesDtoQuotesInner struct { FromDecimals int32 `json:"fromDecimals"` // Decimals of the destination token ToDecimals int32 `json:"toDecimals"` - // Array of quote ranges with different price tiers + // Array of quote ranges with different price tiers. At most 1000 ranges per quote. Ranges []SubmitQuotesDtoQuotesInnerRangesInner `json:"ranges"` // Expiry timestamp of the quote in seconds Expiry int32 `json:"expiry"` // Exclusive solver address allowed to fill this quote. EVM (eip155): 0x-prefixed 40-char hex. Solana: 32–44 char base58. Tron: base58check, T-prefixed, 34 chars. ExclusiveFor *string `json:"exclusiveFor,omitempty"` - // Integrator key hash identifying the integrator this quote is tagged for. If provided, the quote will only be served to the specific integrator. If omitted, the quote is treated as an open-market quote available to all integrators. + // Integrator key hash this quote is tagged for. If provided, the quote will only be served to the specific integrator. If omitted, the quote is treated as an open-market quote available to all integrators. IntegratorKeyHash *string `json:"integratorKeyHash,omitempty" validate:"regexp=^[a-f0-9]{64}$"` } diff --git a/api/lifiorder/model_submitted_order_quote_dto.go b/api/lifiorder/model_submitted_order_quote_dto.go new file mode 100644 index 00000000..b9778102 --- /dev/null +++ b/api/lifiorder/model_submitted_order_quote_dto.go @@ -0,0 +1,654 @@ +/* +Lifi Intents API Reference + +No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + +API version: 0.0.19 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package lifiorder + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the SubmittedOrderQuoteDto type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &SubmittedOrderQuoteDto{} + +// SubmittedOrderQuoteDto struct for SubmittedOrderQuoteDto +type SubmittedOrderQuoteDto struct { + // Quote ID + Id string `json:"id"` + // Quote creation timestamp + CreatedAt string `json:"createdAt"` + // Quote last update timestamp + UpdatedAt string `json:"updatedAt"` + // Unique quote identifier + QuoteId string `json:"quoteId"` + // Source chain network ID + FromChainNetworkId string `json:"fromChainNetworkId"` + // Destination chain network ID + ToChainNetworkId string `json:"toChainNetworkId"` + // Source asset address + FromAssetAddress string `json:"fromAssetAddress"` + // Destination asset address + ToAssetAddress string `json:"toAssetAddress"` + // Source asset decimals + FromAssetDecimals float32 `json:"fromAssetDecimals"` + // Destination asset decimals + ToAssetDecimals float32 `json:"toAssetDecimals"` + // Quote rate + Quote string `json:"quote"` + // Input amount + InputAmount string `json:"inputAmount"` + // Output amount + OutputAmount string `json:"outputAmount"` + // Quote expiry timestamp + Expiry string `json:"expiry"` + // Exclusive for address + ExclusiveFor NullableString `json:"exclusiveFor"` + // Quote owner address + User string `json:"user"` + // Associated order ID + OrderId NullableFloat32 `json:"orderId"` + // Solver ID + SolverId float32 `json:"solverId"` +} + +type _SubmittedOrderQuoteDto SubmittedOrderQuoteDto + +// NewSubmittedOrderQuoteDto instantiates a new SubmittedOrderQuoteDto object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewSubmittedOrderQuoteDto(id string, createdAt string, updatedAt string, quoteId string, fromChainNetworkId string, toChainNetworkId string, fromAssetAddress string, toAssetAddress string, fromAssetDecimals float32, toAssetDecimals float32, quote string, inputAmount string, outputAmount string, expiry string, exclusiveFor NullableString, user string, orderId NullableFloat32, solverId float32) *SubmittedOrderQuoteDto { + this := SubmittedOrderQuoteDto{} + this.Id = id + this.CreatedAt = createdAt + this.UpdatedAt = updatedAt + this.QuoteId = quoteId + this.FromChainNetworkId = fromChainNetworkId + this.ToChainNetworkId = toChainNetworkId + this.FromAssetAddress = fromAssetAddress + this.ToAssetAddress = toAssetAddress + this.FromAssetDecimals = fromAssetDecimals + this.ToAssetDecimals = toAssetDecimals + this.Quote = quote + this.InputAmount = inputAmount + this.OutputAmount = outputAmount + this.Expiry = expiry + this.ExclusiveFor = exclusiveFor + this.User = user + this.OrderId = orderId + this.SolverId = solverId + return &this +} + +// NewSubmittedOrderQuoteDtoWithDefaults instantiates a new SubmittedOrderQuoteDto object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewSubmittedOrderQuoteDtoWithDefaults() *SubmittedOrderQuoteDto { + this := SubmittedOrderQuoteDto{} + return &this +} + +// GetId returns the Id field value +func (o *SubmittedOrderQuoteDto) GetId() string { + if o == nil { + var ret string + return ret + } + + return o.Id +} + +// GetIdOk returns a tuple with the Id field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Id, true +} + +// SetId sets field value +func (o *SubmittedOrderQuoteDto) SetId(v string) { + o.Id = v +} + +// GetCreatedAt returns the CreatedAt field value +func (o *SubmittedOrderQuoteDto) GetCreatedAt() string { + if o == nil { + var ret string + return ret + } + + return o.CreatedAt +} + +// GetCreatedAtOk returns a tuple with the CreatedAt field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetCreatedAtOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.CreatedAt, true +} + +// SetCreatedAt sets field value +func (o *SubmittedOrderQuoteDto) SetCreatedAt(v string) { + o.CreatedAt = v +} + +// GetUpdatedAt returns the UpdatedAt field value +func (o *SubmittedOrderQuoteDto) GetUpdatedAt() string { + if o == nil { + var ret string + return ret + } + + return o.UpdatedAt +} + +// GetUpdatedAtOk returns a tuple with the UpdatedAt field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetUpdatedAtOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.UpdatedAt, true +} + +// SetUpdatedAt sets field value +func (o *SubmittedOrderQuoteDto) SetUpdatedAt(v string) { + o.UpdatedAt = v +} + +// GetQuoteId returns the QuoteId field value +func (o *SubmittedOrderQuoteDto) GetQuoteId() string { + if o == nil { + var ret string + return ret + } + + return o.QuoteId +} + +// GetQuoteIdOk returns a tuple with the QuoteId field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetQuoteIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.QuoteId, true +} + +// SetQuoteId sets field value +func (o *SubmittedOrderQuoteDto) SetQuoteId(v string) { + o.QuoteId = v +} + +// GetFromChainNetworkId returns the FromChainNetworkId field value +func (o *SubmittedOrderQuoteDto) GetFromChainNetworkId() string { + if o == nil { + var ret string + return ret + } + + return o.FromChainNetworkId +} + +// GetFromChainNetworkIdOk returns a tuple with the FromChainNetworkId field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetFromChainNetworkIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.FromChainNetworkId, true +} + +// SetFromChainNetworkId sets field value +func (o *SubmittedOrderQuoteDto) SetFromChainNetworkId(v string) { + o.FromChainNetworkId = v +} + +// GetToChainNetworkId returns the ToChainNetworkId field value +func (o *SubmittedOrderQuoteDto) GetToChainNetworkId() string { + if o == nil { + var ret string + return ret + } + + return o.ToChainNetworkId +} + +// GetToChainNetworkIdOk returns a tuple with the ToChainNetworkId field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetToChainNetworkIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.ToChainNetworkId, true +} + +// SetToChainNetworkId sets field value +func (o *SubmittedOrderQuoteDto) SetToChainNetworkId(v string) { + o.ToChainNetworkId = v +} + +// GetFromAssetAddress returns the FromAssetAddress field value +func (o *SubmittedOrderQuoteDto) GetFromAssetAddress() string { + if o == nil { + var ret string + return ret + } + + return o.FromAssetAddress +} + +// GetFromAssetAddressOk returns a tuple with the FromAssetAddress field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetFromAssetAddressOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.FromAssetAddress, true +} + +// SetFromAssetAddress sets field value +func (o *SubmittedOrderQuoteDto) SetFromAssetAddress(v string) { + o.FromAssetAddress = v +} + +// GetToAssetAddress returns the ToAssetAddress field value +func (o *SubmittedOrderQuoteDto) GetToAssetAddress() string { + if o == nil { + var ret string + return ret + } + + return o.ToAssetAddress +} + +// GetToAssetAddressOk returns a tuple with the ToAssetAddress field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetToAssetAddressOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.ToAssetAddress, true +} + +// SetToAssetAddress sets field value +func (o *SubmittedOrderQuoteDto) SetToAssetAddress(v string) { + o.ToAssetAddress = v +} + +// GetFromAssetDecimals returns the FromAssetDecimals field value +func (o *SubmittedOrderQuoteDto) GetFromAssetDecimals() float32 { + if o == nil { + var ret float32 + return ret + } + + return o.FromAssetDecimals +} + +// GetFromAssetDecimalsOk returns a tuple with the FromAssetDecimals field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetFromAssetDecimalsOk() (*float32, bool) { + if o == nil { + return nil, false + } + return &o.FromAssetDecimals, true +} + +// SetFromAssetDecimals sets field value +func (o *SubmittedOrderQuoteDto) SetFromAssetDecimals(v float32) { + o.FromAssetDecimals = v +} + +// GetToAssetDecimals returns the ToAssetDecimals field value +func (o *SubmittedOrderQuoteDto) GetToAssetDecimals() float32 { + if o == nil { + var ret float32 + return ret + } + + return o.ToAssetDecimals +} + +// GetToAssetDecimalsOk returns a tuple with the ToAssetDecimals field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetToAssetDecimalsOk() (*float32, bool) { + if o == nil { + return nil, false + } + return &o.ToAssetDecimals, true +} + +// SetToAssetDecimals sets field value +func (o *SubmittedOrderQuoteDto) SetToAssetDecimals(v float32) { + o.ToAssetDecimals = v +} + +// GetQuote returns the Quote field value +func (o *SubmittedOrderQuoteDto) GetQuote() string { + if o == nil { + var ret string + return ret + } + + return o.Quote +} + +// GetQuoteOk returns a tuple with the Quote field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetQuoteOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Quote, true +} + +// SetQuote sets field value +func (o *SubmittedOrderQuoteDto) SetQuote(v string) { + o.Quote = v +} + +// GetInputAmount returns the InputAmount field value +func (o *SubmittedOrderQuoteDto) GetInputAmount() string { + if o == nil { + var ret string + return ret + } + + return o.InputAmount +} + +// GetInputAmountOk returns a tuple with the InputAmount field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetInputAmountOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.InputAmount, true +} + +// SetInputAmount sets field value +func (o *SubmittedOrderQuoteDto) SetInputAmount(v string) { + o.InputAmount = v +} + +// GetOutputAmount returns the OutputAmount field value +func (o *SubmittedOrderQuoteDto) GetOutputAmount() string { + if o == nil { + var ret string + return ret + } + + return o.OutputAmount +} + +// GetOutputAmountOk returns a tuple with the OutputAmount field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetOutputAmountOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.OutputAmount, true +} + +// SetOutputAmount sets field value +func (o *SubmittedOrderQuoteDto) SetOutputAmount(v string) { + o.OutputAmount = v +} + +// GetExpiry returns the Expiry field value +func (o *SubmittedOrderQuoteDto) GetExpiry() string { + if o == nil { + var ret string + return ret + } + + return o.Expiry +} + +// GetExpiryOk returns a tuple with the Expiry field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetExpiryOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Expiry, true +} + +// SetExpiry sets field value +func (o *SubmittedOrderQuoteDto) SetExpiry(v string) { + o.Expiry = v +} + +// GetExclusiveFor returns the ExclusiveFor field value +// If the value is explicit nil, the zero value for string will be returned +func (o *SubmittedOrderQuoteDto) GetExclusiveFor() string { + if o == nil || o.ExclusiveFor.Get() == nil { + var ret string + return ret + } + + return *o.ExclusiveFor.Get() +} + +// GetExclusiveForOk returns a tuple with the ExclusiveFor field value +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *SubmittedOrderQuoteDto) GetExclusiveForOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.ExclusiveFor.Get(), o.ExclusiveFor.IsSet() +} + +// SetExclusiveFor sets field value +func (o *SubmittedOrderQuoteDto) SetExclusiveFor(v string) { + o.ExclusiveFor.Set(&v) +} + +// GetUser returns the User field value +func (o *SubmittedOrderQuoteDto) GetUser() string { + if o == nil { + var ret string + return ret + } + + return o.User +} + +// GetUserOk returns a tuple with the User field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetUserOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.User, true +} + +// SetUser sets field value +func (o *SubmittedOrderQuoteDto) SetUser(v string) { + o.User = v +} + +// GetOrderId returns the OrderId field value +// If the value is explicit nil, the zero value for float32 will be returned +func (o *SubmittedOrderQuoteDto) GetOrderId() float32 { + if o == nil || o.OrderId.Get() == nil { + var ret float32 + return ret + } + + return *o.OrderId.Get() +} + +// GetOrderIdOk returns a tuple with the OrderId field value +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *SubmittedOrderQuoteDto) GetOrderIdOk() (*float32, bool) { + if o == nil { + return nil, false + } + return o.OrderId.Get(), o.OrderId.IsSet() +} + +// SetOrderId sets field value +func (o *SubmittedOrderQuoteDto) SetOrderId(v float32) { + o.OrderId.Set(&v) +} + +// GetSolverId returns the SolverId field value +func (o *SubmittedOrderQuoteDto) GetSolverId() float32 { + if o == nil { + var ret float32 + return ret + } + + return o.SolverId +} + +// GetSolverIdOk returns a tuple with the SolverId field value +// and a boolean to check if the value has been set. +func (o *SubmittedOrderQuoteDto) GetSolverIdOk() (*float32, bool) { + if o == nil { + return nil, false + } + return &o.SolverId, true +} + +// SetSolverId sets field value +func (o *SubmittedOrderQuoteDto) SetSolverId(v float32) { + o.SolverId = v +} + +func (o SubmittedOrderQuoteDto) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o SubmittedOrderQuoteDto) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["id"] = o.Id + toSerialize["createdAt"] = o.CreatedAt + toSerialize["updatedAt"] = o.UpdatedAt + toSerialize["quoteId"] = o.QuoteId + toSerialize["fromChainNetworkId"] = o.FromChainNetworkId + toSerialize["toChainNetworkId"] = o.ToChainNetworkId + toSerialize["fromAssetAddress"] = o.FromAssetAddress + toSerialize["toAssetAddress"] = o.ToAssetAddress + toSerialize["fromAssetDecimals"] = o.FromAssetDecimals + toSerialize["toAssetDecimals"] = o.ToAssetDecimals + toSerialize["quote"] = o.Quote + toSerialize["inputAmount"] = o.InputAmount + toSerialize["outputAmount"] = o.OutputAmount + toSerialize["expiry"] = o.Expiry + toSerialize["exclusiveFor"] = o.ExclusiveFor.Get() + toSerialize["user"] = o.User + toSerialize["orderId"] = o.OrderId.Get() + toSerialize["solverId"] = o.SolverId + return toSerialize, nil +} + +func (o *SubmittedOrderQuoteDto) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "id", + "createdAt", + "updatedAt", + "quoteId", + "fromChainNetworkId", + "toChainNetworkId", + "fromAssetAddress", + "toAssetAddress", + "fromAssetDecimals", + "toAssetDecimals", + "quote", + "inputAmount", + "outputAmount", + "expiry", + "exclusiveFor", + "user", + "orderId", + "solverId", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varSubmittedOrderQuoteDto := _SubmittedOrderQuoteDto{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varSubmittedOrderQuoteDto) + + if err != nil { + return err + } + + *o = SubmittedOrderQuoteDto(varSubmittedOrderQuoteDto) + + return err +} + +type NullableSubmittedOrderQuoteDto struct { + value *SubmittedOrderQuoteDto + isSet bool +} + +func (v NullableSubmittedOrderQuoteDto) Get() *SubmittedOrderQuoteDto { + return v.value +} + +func (v *NullableSubmittedOrderQuoteDto) Set(val *SubmittedOrderQuoteDto) { + v.value = val + v.isSet = true +} + +func (v NullableSubmittedOrderQuoteDto) IsSet() bool { + return v.isSet +} + +func (v *NullableSubmittedOrderQuoteDto) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableSubmittedOrderQuoteDto(val *SubmittedOrderQuoteDto) *NullableSubmittedOrderQuoteDto { + return &NullableSubmittedOrderQuoteDto{value: val, isSet: true} +} + +func (v NullableSubmittedOrderQuoteDto) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableSubmittedOrderQuoteDto) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/lifiorder/model_supported_route_dto.go b/api/lifiorder/model_supported_route_dto.go index 3ae9ed79..a1c34d23 100644 --- a/api/lifiorder/model_supported_route_dto.go +++ b/api/lifiorder/model_supported_route_dto.go @@ -36,19 +36,17 @@ type SupportedRouteDto struct { // Gas fee for the route (in token units) GasFee float32 `json:"gasFee"` // Source chain record ID - FromChainRecordId map[string]interface{} `json:"fromChainRecordId"` + FromChainRecordId NullableFloat32 `json:"fromChainRecordId"` // Destination chain record ID - ToChainRecordId map[string]interface{} `json:"toChainRecordId"` + ToChainRecordId NullableFloat32 `json:"toChainRecordId"` // Source token record ID - FromTokenId map[string]interface{} `json:"fromTokenId"` + FromTokenId NullableFloat32 `json:"fromTokenId"` // Destination token record ID - ToTokenId map[string]interface{} `json:"toTokenId"` + ToTokenId NullableFloat32 `json:"toTokenId"` // Whether the route is currently active - IsActive bool `json:"isActive"` - // Source chain information + IsActive bool `json:"isActive"` FromChain NullableRouteChainInfoDto `json:"fromChain"` - // Destination chain information - ToChain NullableRouteChainInfoDto `json:"toChain"` + ToChain NullableRouteChainInfoDto `json:"toChain"` // Source token information for this route FromToken TokenInfoDto `json:"fromToken"` // Destination token information for this route @@ -61,7 +59,7 @@ type _SupportedRouteDto SupportedRouteDto // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewSupportedRouteDto(id string, createdAt string, updatedAt string, minAmount float32, maxAmount float32, fee float32, gasFee float32, fromChainRecordId map[string]interface{}, toChainRecordId map[string]interface{}, fromTokenId map[string]interface{}, toTokenId map[string]interface{}, isActive bool, fromChain NullableRouteChainInfoDto, toChain NullableRouteChainInfoDto, fromToken TokenInfoDto, toToken TokenInfoDto) *SupportedRouteDto { +func NewSupportedRouteDto(id string, createdAt string, updatedAt string, minAmount float32, maxAmount float32, fee float32, gasFee float32, fromChainRecordId NullableFloat32, toChainRecordId NullableFloat32, fromTokenId NullableFloat32, toTokenId NullableFloat32, isActive bool, fromChain NullableRouteChainInfoDto, toChain NullableRouteChainInfoDto, fromToken TokenInfoDto, toToken TokenInfoDto) *SupportedRouteDto { this := SupportedRouteDto{} this.Id = id this.CreatedAt = createdAt @@ -259,107 +257,107 @@ func (o *SupportedRouteDto) SetGasFee(v float32) { } // GetFromChainRecordId returns the FromChainRecordId field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *SupportedRouteDto) GetFromChainRecordId() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for float32 will be returned +func (o *SupportedRouteDto) GetFromChainRecordId() float32 { + if o == nil || o.FromChainRecordId.Get() == nil { + var ret float32 return ret } - return o.FromChainRecordId + return *o.FromChainRecordId.Get() } // GetFromChainRecordIdOk returns a tuple with the FromChainRecordId field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *SupportedRouteDto) GetFromChainRecordIdOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.FromChainRecordId) { - return map[string]interface{}{}, false +func (o *SupportedRouteDto) GetFromChainRecordIdOk() (*float32, bool) { + if o == nil { + return nil, false } - return o.FromChainRecordId, true + return o.FromChainRecordId.Get(), o.FromChainRecordId.IsSet() } // SetFromChainRecordId sets field value -func (o *SupportedRouteDto) SetFromChainRecordId(v map[string]interface{}) { - o.FromChainRecordId = v +func (o *SupportedRouteDto) SetFromChainRecordId(v float32) { + o.FromChainRecordId.Set(&v) } // GetToChainRecordId returns the ToChainRecordId field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *SupportedRouteDto) GetToChainRecordId() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for float32 will be returned +func (o *SupportedRouteDto) GetToChainRecordId() float32 { + if o == nil || o.ToChainRecordId.Get() == nil { + var ret float32 return ret } - return o.ToChainRecordId + return *o.ToChainRecordId.Get() } // GetToChainRecordIdOk returns a tuple with the ToChainRecordId field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *SupportedRouteDto) GetToChainRecordIdOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.ToChainRecordId) { - return map[string]interface{}{}, false +func (o *SupportedRouteDto) GetToChainRecordIdOk() (*float32, bool) { + if o == nil { + return nil, false } - return o.ToChainRecordId, true + return o.ToChainRecordId.Get(), o.ToChainRecordId.IsSet() } // SetToChainRecordId sets field value -func (o *SupportedRouteDto) SetToChainRecordId(v map[string]interface{}) { - o.ToChainRecordId = v +func (o *SupportedRouteDto) SetToChainRecordId(v float32) { + o.ToChainRecordId.Set(&v) } // GetFromTokenId returns the FromTokenId field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *SupportedRouteDto) GetFromTokenId() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for float32 will be returned +func (o *SupportedRouteDto) GetFromTokenId() float32 { + if o == nil || o.FromTokenId.Get() == nil { + var ret float32 return ret } - return o.FromTokenId + return *o.FromTokenId.Get() } // GetFromTokenIdOk returns a tuple with the FromTokenId field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *SupportedRouteDto) GetFromTokenIdOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.FromTokenId) { - return map[string]interface{}{}, false +func (o *SupportedRouteDto) GetFromTokenIdOk() (*float32, bool) { + if o == nil { + return nil, false } - return o.FromTokenId, true + return o.FromTokenId.Get(), o.FromTokenId.IsSet() } // SetFromTokenId sets field value -func (o *SupportedRouteDto) SetFromTokenId(v map[string]interface{}) { - o.FromTokenId = v +func (o *SupportedRouteDto) SetFromTokenId(v float32) { + o.FromTokenId.Set(&v) } // GetToTokenId returns the ToTokenId field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *SupportedRouteDto) GetToTokenId() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for float32 will be returned +func (o *SupportedRouteDto) GetToTokenId() float32 { + if o == nil || o.ToTokenId.Get() == nil { + var ret float32 return ret } - return o.ToTokenId + return *o.ToTokenId.Get() } // GetToTokenIdOk returns a tuple with the ToTokenId field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *SupportedRouteDto) GetToTokenIdOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.ToTokenId) { - return map[string]interface{}{}, false +func (o *SupportedRouteDto) GetToTokenIdOk() (*float32, bool) { + if o == nil { + return nil, false } - return o.ToTokenId, true + return o.ToTokenId.Get(), o.ToTokenId.IsSet() } // SetToTokenId sets field value -func (o *SupportedRouteDto) SetToTokenId(v map[string]interface{}) { - o.ToTokenId = v +func (o *SupportedRouteDto) SetToTokenId(v float32) { + o.ToTokenId.Set(&v) } // GetIsActive returns the IsActive field value @@ -503,18 +501,10 @@ func (o SupportedRouteDto) ToMap() (map[string]interface{}, error) { toSerialize["maxAmount"] = o.MaxAmount toSerialize["fee"] = o.Fee toSerialize["gasFee"] = o.GasFee - if o.FromChainRecordId != nil { - toSerialize["fromChainRecordId"] = o.FromChainRecordId - } - if o.ToChainRecordId != nil { - toSerialize["toChainRecordId"] = o.ToChainRecordId - } - if o.FromTokenId != nil { - toSerialize["fromTokenId"] = o.FromTokenId - } - if o.ToTokenId != nil { - toSerialize["toTokenId"] = o.ToTokenId - } + toSerialize["fromChainRecordId"] = o.FromChainRecordId.Get() + toSerialize["toChainRecordId"] = o.ToChainRecordId.Get() + toSerialize["fromTokenId"] = o.FromTokenId.Get() + toSerialize["toTokenId"] = o.ToTokenId.Get() toSerialize["isActive"] = o.IsActive toSerialize["fromChain"] = o.FromChain.Get() toSerialize["toChain"] = o.ToChain.Get() diff --git a/api/lifiorder/model_token_info_dto.go b/api/lifiorder/model_token_info_dto.go index f3c5f48b..a7067a38 100644 --- a/api/lifiorder/model_token_info_dto.go +++ b/api/lifiorder/model_token_info_dto.go @@ -22,9 +22,9 @@ var _ MappedNullable = &TokenInfoDto{} // TokenInfoDto struct for TokenInfoDto type TokenInfoDto struct { // Token symbol (null if token not registered in system) - Symbol map[string]interface{} `json:"symbol"` + Symbol NullableString `json:"symbol"` // Token name (null if token not registered in system) - Name map[string]interface{} `json:"name"` + Name NullableString `json:"name"` // Token contract address Address string `json:"address"` // Token decimals @@ -37,7 +37,7 @@ type _TokenInfoDto TokenInfoDto // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewTokenInfoDto(symbol map[string]interface{}, name map[string]interface{}, address string, decimals float32) *TokenInfoDto { +func NewTokenInfoDto(symbol NullableString, name NullableString, address string, decimals float32) *TokenInfoDto { this := TokenInfoDto{} this.Symbol = symbol this.Name = name @@ -55,55 +55,55 @@ func NewTokenInfoDtoWithDefaults() *TokenInfoDto { } // GetSymbol returns the Symbol field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *TokenInfoDto) GetSymbol() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *TokenInfoDto) GetSymbol() string { + if o == nil || o.Symbol.Get() == nil { + var ret string return ret } - return o.Symbol + return *o.Symbol.Get() } // GetSymbolOk returns a tuple with the Symbol field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *TokenInfoDto) GetSymbolOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.Symbol) { - return map[string]interface{}{}, false +func (o *TokenInfoDto) GetSymbolOk() (*string, bool) { + if o == nil { + return nil, false } - return o.Symbol, true + return o.Symbol.Get(), o.Symbol.IsSet() } // SetSymbol sets field value -func (o *TokenInfoDto) SetSymbol(v map[string]interface{}) { - o.Symbol = v +func (o *TokenInfoDto) SetSymbol(v string) { + o.Symbol.Set(&v) } // GetName returns the Name field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *TokenInfoDto) GetName() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// If the value is explicit nil, the zero value for string will be returned +func (o *TokenInfoDto) GetName() string { + if o == nil || o.Name.Get() == nil { + var ret string return ret } - return o.Name + return *o.Name.Get() } // GetNameOk returns a tuple with the Name field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *TokenInfoDto) GetNameOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.Name) { - return map[string]interface{}{}, false +func (o *TokenInfoDto) GetNameOk() (*string, bool) { + if o == nil { + return nil, false } - return o.Name, true + return o.Name.Get(), o.Name.IsSet() } // SetName sets field value -func (o *TokenInfoDto) SetName(v map[string]interface{}) { - o.Name = v +func (o *TokenInfoDto) SetName(v string) { + o.Name.Set(&v) } // GetAddress returns the Address field value @@ -164,12 +164,8 @@ func (o TokenInfoDto) MarshalJSON() ([]byte, error) { func (o TokenInfoDto) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} - if o.Symbol != nil { - toSerialize["symbol"] = o.Symbol - } - if o.Name != nil { - toSerialize["name"] = o.Name - } + toSerialize["symbol"] = o.Symbol.Get() + toSerialize["name"] = o.Name.Get() toSerialize["address"] = o.Address toSerialize["decimals"] = o.Decimals return toSerialize, nil diff --git a/api/lifiorder/model_token_info_v1_dto.go b/api/lifiorder/model_token_info_v1_dto.go index e82733d3..dc1feddd 100644 --- a/api/lifiorder/model_token_info_v1_dto.go +++ b/api/lifiorder/model_token_info_v1_dto.go @@ -21,14 +21,14 @@ var _ MappedNullable = &TokenInfoV1Dto{} // TokenInfoV1Dto struct for TokenInfoV1Dto type TokenInfoV1Dto struct { + // Token symbol (null if token not registered in system) + Symbol NullableString `json:"symbol"` + // Token name (null if token not registered in system) + Name NullableString `json:"name"` // Token contract address Address string `json:"address"` - // Token symbol (null if token not registered in system) - Symbol map[string]interface{} `json:"symbol"` // Token decimals Decimals float32 `json:"decimals"` - // Token name (null if token not registered in system) - Name map[string]interface{} `json:"name"` } type _TokenInfoV1Dto TokenInfoV1Dto @@ -37,12 +37,12 @@ type _TokenInfoV1Dto TokenInfoV1Dto // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewTokenInfoV1Dto(address string, symbol map[string]interface{}, decimals float32, name map[string]interface{}) *TokenInfoV1Dto { +func NewTokenInfoV1Dto(symbol NullableString, name NullableString, address string, decimals float32) *TokenInfoV1Dto { this := TokenInfoV1Dto{} - this.Address = address this.Symbol = symbol - this.Decimals = decimals this.Name = name + this.Address = address + this.Decimals = decimals return &this } @@ -54,104 +54,104 @@ func NewTokenInfoV1DtoWithDefaults() *TokenInfoV1Dto { return &this } -// GetAddress returns the Address field value -func (o *TokenInfoV1Dto) GetAddress() string { - if o == nil { +// GetSymbol returns the Symbol field value +// If the value is explicit nil, the zero value for string will be returned +func (o *TokenInfoV1Dto) GetSymbol() string { + if o == nil || o.Symbol.Get() == nil { var ret string return ret } - return o.Address + return *o.Symbol.Get() } -// GetAddressOk returns a tuple with the Address field value +// GetSymbolOk returns a tuple with the Symbol field value // and a boolean to check if the value has been set. -func (o *TokenInfoV1Dto) GetAddressOk() (*string, bool) { +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *TokenInfoV1Dto) GetSymbolOk() (*string, bool) { if o == nil { return nil, false } - return &o.Address, true + return o.Symbol.Get(), o.Symbol.IsSet() } -// SetAddress sets field value -func (o *TokenInfoV1Dto) SetAddress(v string) { - o.Address = v +// SetSymbol sets field value +func (o *TokenInfoV1Dto) SetSymbol(v string) { + o.Symbol.Set(&v) } -// GetSymbol returns the Symbol field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *TokenInfoV1Dto) GetSymbol() map[string]interface{} { - if o == nil { - var ret map[string]interface{} +// GetName returns the Name field value +// If the value is explicit nil, the zero value for string will be returned +func (o *TokenInfoV1Dto) GetName() string { + if o == nil || o.Name.Get() == nil { + var ret string return ret } - return o.Symbol + return *o.Name.Get() } -// GetSymbolOk returns a tuple with the Symbol field value +// GetNameOk returns a tuple with the Name field value // and a boolean to check if the value has been set. // NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *TokenInfoV1Dto) GetSymbolOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.Symbol) { - return map[string]interface{}{}, false +func (o *TokenInfoV1Dto) GetNameOk() (*string, bool) { + if o == nil { + return nil, false } - return o.Symbol, true + return o.Name.Get(), o.Name.IsSet() } -// SetSymbol sets field value -func (o *TokenInfoV1Dto) SetSymbol(v map[string]interface{}) { - o.Symbol = v +// SetName sets field value +func (o *TokenInfoV1Dto) SetName(v string) { + o.Name.Set(&v) } -// GetDecimals returns the Decimals field value -func (o *TokenInfoV1Dto) GetDecimals() float32 { +// GetAddress returns the Address field value +func (o *TokenInfoV1Dto) GetAddress() string { if o == nil { - var ret float32 + var ret string return ret } - return o.Decimals + return o.Address } -// GetDecimalsOk returns a tuple with the Decimals field value +// GetAddressOk returns a tuple with the Address field value // and a boolean to check if the value has been set. -func (o *TokenInfoV1Dto) GetDecimalsOk() (*float32, bool) { +func (o *TokenInfoV1Dto) GetAddressOk() (*string, bool) { if o == nil { return nil, false } - return &o.Decimals, true + return &o.Address, true } -// SetDecimals sets field value -func (o *TokenInfoV1Dto) SetDecimals(v float32) { - o.Decimals = v +// SetAddress sets field value +func (o *TokenInfoV1Dto) SetAddress(v string) { + o.Address = v } -// GetName returns the Name field value -// If the value is explicit nil, the zero value for map[string]interface{} will be returned -func (o *TokenInfoV1Dto) GetName() map[string]interface{} { +// GetDecimals returns the Decimals field value +func (o *TokenInfoV1Dto) GetDecimals() float32 { if o == nil { - var ret map[string]interface{} + var ret float32 return ret } - return o.Name + return o.Decimals } -// GetNameOk returns a tuple with the Name field value +// GetDecimalsOk returns a tuple with the Decimals field value // and a boolean to check if the value has been set. -// NOTE: If the value is an explicit nil, `nil, true` will be returned -func (o *TokenInfoV1Dto) GetNameOk() (map[string]interface{}, bool) { - if o == nil || IsNil(o.Name) { - return map[string]interface{}{}, false +func (o *TokenInfoV1Dto) GetDecimalsOk() (*float32, bool) { + if o == nil { + return nil, false } - return o.Name, true + return &o.Decimals, true } -// SetName sets field value -func (o *TokenInfoV1Dto) SetName(v map[string]interface{}) { - o.Name = v +// SetDecimals sets field value +func (o *TokenInfoV1Dto) SetDecimals(v float32) { + o.Decimals = v } func (o TokenInfoV1Dto) MarshalJSON() ([]byte, error) { @@ -164,14 +164,10 @@ func (o TokenInfoV1Dto) MarshalJSON() ([]byte, error) { func (o TokenInfoV1Dto) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} + toSerialize["symbol"] = o.Symbol.Get() + toSerialize["name"] = o.Name.Get() toSerialize["address"] = o.Address - if o.Symbol != nil { - toSerialize["symbol"] = o.Symbol - } toSerialize["decimals"] = o.Decimals - if o.Name != nil { - toSerialize["name"] = o.Name - } return toSerialize, nil } @@ -180,10 +176,10 @@ func (o *TokenInfoV1Dto) UnmarshalJSON(data []byte) (err error) { // by unmarshalling the object into a generic map with string keys and checking // that every required field exists as a key in the generic map. requiredProperties := []string{ - "address", "symbol", - "decimals", "name", + "address", + "decimals", } allProperties := make(map[string]interface{}) diff --git a/api/uniswapxservice/api_limit_orders.go b/api/uniswapxservice/api_limit_orders.go new file mode 100644 index 00000000..3f3ff2ba --- /dev/null +++ b/api/uniswapxservice/api_limit_orders.go @@ -0,0 +1,294 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" +) + +// LimitOrdersAPIService LimitOrdersAPI service +type LimitOrdersAPIService service + +type ApiLimitOrdersGetRequest struct { + ctx context.Context + ApiService *LimitOrdersAPIService + limit *float32 + orderStatus *OrderStatus + orderHash *string + orderHashes *string + swapper *string + filler *string + executeAddress *string + orderType *OrderTypeQuery + pair *string + sortKey *SortKey + sort *string + desc *bool + cursor *string + chainId *ChainId +} + +// Maximum number of orders to return. +func (r ApiLimitOrdersGetRequest) Limit(limit float32) ApiLimitOrdersGetRequest { + r.limit = &limit + return r +} + +// Filter by order status. A comma-separated list of statuses is also accepted. +func (r ApiLimitOrdersGetRequest) OrderStatus(orderStatus OrderStatus) ApiLimitOrdersGetRequest { + r.orderStatus = &orderStatus + return r +} + +// Filter by order hash. +func (r ApiLimitOrdersGetRequest) OrderHash(orderHash string) ApiLimitOrdersGetRequest { + r.orderHash = &orderHash + return r +} + +// Filter by comma-separated order hashes (maximum 50). Cannot be combined with sortKey. +func (r ApiLimitOrdersGetRequest) OrderHashes(orderHashes string) ApiLimitOrdersGetRequest { + r.orderHashes = &orderHashes + return r +} + +// Filter by swapper address. Cannot be combined with chainId. +func (r ApiLimitOrdersGetRequest) Swapper(swapper string) ApiLimitOrdersGetRequest { + r.swapper = &swapper + return r +} + +// Filter by filler address. +func (r ApiLimitOrdersGetRequest) Filler(filler string) ApiLimitOrdersGetRequest { + r.filler = &filler + return r +} + +// Filter by execution address. +func (r ApiLimitOrdersGetRequest) ExecuteAddress(executeAddress string) ApiLimitOrdersGetRequest { + r.executeAddress = &executeAddress + return r +} + +// Filter by order type. Determines the entity shape of the returned orders. +func (r ApiLimitOrdersGetRequest) OrderType(orderType OrderTypeQuery) ApiLimitOrdersGetRequest { + r.orderType = &orderType + return r +} + +// Filter by token pair, formatted as `<tokenAddress>-<tokenAddress>-<chainId>`. +func (r ApiLimitOrdersGetRequest) Pair(pair string) ApiLimitOrdersGetRequest { + r.pair = &pair + return r +} + +// Order the query results by the sort key. Required when sort or desc is provided. +func (r ApiLimitOrdersGetRequest) SortKey(sortKey SortKey) ApiLimitOrdersGetRequest { + r.sortKey = &sortKey + return r +} + +// Sort query. For example: `sort=gt(UNIX_TIMESTAMP)`, `sort=between(1675872827, 1675872930)`, or `lt(1675872930)`. +func (r ApiLimitOrdersGetRequest) Sort(sort string) ApiLimitOrdersGetRequest { + r.sort = &sort + return r +} + +// Boolean to sort query results by descending sort key. +func (r ApiLimitOrdersGetRequest) Desc(desc bool) ApiLimitOrdersGetRequest { + r.desc = &desc + return r +} + +// Cursor param to page through results. This will be returned in the previous query if the results have been paginated. +func (r ApiLimitOrdersGetRequest) Cursor(cursor string) ApiLimitOrdersGetRequest { + r.cursor = &cursor + return r +} + +// Filter by chain id. Cannot be combined with swapper. +func (r ApiLimitOrdersGetRequest) ChainId(chainId ChainId) ApiLimitOrdersGetRequest { + r.chainId = &chainId + return r +} + +func (r ApiLimitOrdersGetRequest) Execute() (*GetOrdersResponse, *http.Response, error) { + return r.ApiService.LimitOrdersGetExecute(r) +} + +/* +LimitOrdersGet Retrieve UniswapX limit orders + +Retrieve limit orders filtered by query parameter(s). Query semantics are identical to /orders. Limit orders are returned with the Dutch order shape without decay (input and output startAmount equals endAmount). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiLimitOrdersGetRequest +*/ +func (a *LimitOrdersAPIService) LimitOrdersGet(ctx context.Context) ApiLimitOrdersGetRequest { + return ApiLimitOrdersGetRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// +// @return GetOrdersResponse +func (a *LimitOrdersAPIService) LimitOrdersGetExecute(r ApiLimitOrdersGetRequest) (*GetOrdersResponse, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *GetOrdersResponse + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "LimitOrdersAPIService.LimitOrdersGet") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/limit-orders" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + if r.limit != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "limit", r.limit, "form", "") + } + if r.orderStatus != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "orderStatus", r.orderStatus, "form", "") + } + if r.orderHash != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "orderHash", r.orderHash, "form", "") + } + if r.orderHashes != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "orderHashes", r.orderHashes, "form", "") + } + if r.swapper != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "swapper", r.swapper, "form", "") + } + if r.filler != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "filler", r.filler, "form", "") + } + if r.executeAddress != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "executeAddress", r.executeAddress, "form", "") + } + if r.orderType != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "orderType", r.orderType, "form", "") + } + if r.pair != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "pair", r.pair, "form", "") + } + if r.sortKey != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "sortKey", r.sortKey, "form", "") + } + if r.sort != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "sort", r.sort, "form", "") + } + if r.desc != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "desc", r.desc, "form", "") + } + if r.cursor != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "cursor", r.cursor, "form", "") + } + if r.chainId != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "chainId", r.chainId, "form", "") + } + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v ErrorResponse + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 429 { + var v ErrorResponse + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v ErrorResponse + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/api/uniswapxservice/api_orders.go b/api/uniswapxservice/api_orders.go new file mode 100644 index 00000000..c910e1da --- /dev/null +++ b/api/uniswapxservice/api_orders.go @@ -0,0 +1,294 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" +) + +// OrdersAPIService OrdersAPI service +type OrdersAPIService service + +type ApiOrdersGetRequest struct { + ctx context.Context + ApiService *OrdersAPIService + limit *float32 + orderStatus *OrderStatus + orderHash *string + orderHashes *string + swapper *string + filler *string + executeAddress *string + orderType *OrderTypeQuery + pair *string + sortKey *SortKey + sort *string + desc *bool + cursor *string + chainId *ChainId +} + +// Maximum number of orders to return. +func (r ApiOrdersGetRequest) Limit(limit float32) ApiOrdersGetRequest { + r.limit = &limit + return r +} + +// Filter by order status. A comma-separated list of statuses is also accepted. +func (r ApiOrdersGetRequest) OrderStatus(orderStatus OrderStatus) ApiOrdersGetRequest { + r.orderStatus = &orderStatus + return r +} + +// Filter by order hash. +func (r ApiOrdersGetRequest) OrderHash(orderHash string) ApiOrdersGetRequest { + r.orderHash = &orderHash + return r +} + +// Filter by comma-separated order hashes (maximum 50). Cannot be combined with sortKey. +func (r ApiOrdersGetRequest) OrderHashes(orderHashes string) ApiOrdersGetRequest { + r.orderHashes = &orderHashes + return r +} + +// Filter by swapper address. Cannot be combined with chainId. +func (r ApiOrdersGetRequest) Swapper(swapper string) ApiOrdersGetRequest { + r.swapper = &swapper + return r +} + +// Filter by filler address. +func (r ApiOrdersGetRequest) Filler(filler string) ApiOrdersGetRequest { + r.filler = &filler + return r +} + +// Filter by execution address. +func (r ApiOrdersGetRequest) ExecuteAddress(executeAddress string) ApiOrdersGetRequest { + r.executeAddress = &executeAddress + return r +} + +// Filter by order type. Determines the entity shape of the returned orders. +func (r ApiOrdersGetRequest) OrderType(orderType OrderTypeQuery) ApiOrdersGetRequest { + r.orderType = &orderType + return r +} + +// Filter by token pair, formatted as `<tokenAddress>-<tokenAddress>-<chainId>`. +func (r ApiOrdersGetRequest) Pair(pair string) ApiOrdersGetRequest { + r.pair = &pair + return r +} + +// Order the query results by the sort key. Required when sort or desc is provided. +func (r ApiOrdersGetRequest) SortKey(sortKey SortKey) ApiOrdersGetRequest { + r.sortKey = &sortKey + return r +} + +// Sort query. For example: `sort=gt(UNIX_TIMESTAMP)`, `sort=between(1675872827, 1675872930)`, or `lt(1675872930)`. +func (r ApiOrdersGetRequest) Sort(sort string) ApiOrdersGetRequest { + r.sort = &sort + return r +} + +// Boolean to sort query results by descending sort key. +func (r ApiOrdersGetRequest) Desc(desc bool) ApiOrdersGetRequest { + r.desc = &desc + return r +} + +// Cursor param to page through results. This will be returned in the previous query if the results have been paginated. +func (r ApiOrdersGetRequest) Cursor(cursor string) ApiOrdersGetRequest { + r.cursor = &cursor + return r +} + +// Filter by chain id. Cannot be combined with swapper. +func (r ApiOrdersGetRequest) ChainId(chainId ChainId) ApiOrdersGetRequest { + r.chainId = &chainId + return r +} + +func (r ApiOrdersGetRequest) Execute() (*GetOrdersResponse, *http.Response, error) { + return r.ApiService.OrdersGetExecute(r) +} + +/* +OrdersGet Retrieve UniswapX orders + +Retrieve orders filtered by query parameter(s). At least one of `orderHash`, `orderHashes`, `chainId`, `orderStatus`, `swapper`, `filler`, or `pair` must be provided. Not supported in combination: `swapper` with `chainId`; `orderHashes` with `sortKey`. `sortKey` is required whenever `sort` or `desc` is provided. The shape of each entry in `orders` depends on the order's type; filter with `orderType` to receive a single shape. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiOrdersGetRequest +*/ +func (a *OrdersAPIService) OrdersGet(ctx context.Context) ApiOrdersGetRequest { + return ApiOrdersGetRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// +// @return GetOrdersResponse +func (a *OrdersAPIService) OrdersGetExecute(r ApiOrdersGetRequest) (*GetOrdersResponse, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *GetOrdersResponse + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "OrdersAPIService.OrdersGet") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/orders" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + if r.limit != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "limit", r.limit, "form", "") + } + if r.orderStatus != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "orderStatus", r.orderStatus, "form", "") + } + if r.orderHash != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "orderHash", r.orderHash, "form", "") + } + if r.orderHashes != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "orderHashes", r.orderHashes, "form", "") + } + if r.swapper != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "swapper", r.swapper, "form", "") + } + if r.filler != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "filler", r.filler, "form", "") + } + if r.executeAddress != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "executeAddress", r.executeAddress, "form", "") + } + if r.orderType != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "orderType", r.orderType, "form", "") + } + if r.pair != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "pair", r.pair, "form", "") + } + if r.sortKey != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "sortKey", r.sortKey, "form", "") + } + if r.sort != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "sort", r.sort, "form", "") + } + if r.desc != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "desc", r.desc, "form", "") + } + if r.cursor != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "cursor", r.cursor, "form", "") + } + if r.chainId != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "chainId", r.chainId, "form", "") + } + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v ErrorResponse + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 429 { + var v ErrorResponse + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v ErrorResponse + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/api/uniswapxservice/client.go b/api/uniswapxservice/client.go new file mode 100644 index 00000000..5139637f --- /dev/null +++ b/api/uniswapxservice/client.go @@ -0,0 +1,658 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "context" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "log" + "mime/multipart" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "reflect" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +var ( + JsonCheck = regexp.MustCompile(`(?i:(?:application|text)/(?:[^;]+\+)?json)`) + XmlCheck = regexp.MustCompile(`(?i:(?:application|text)/(?:[^;]+\+)?xml)`) + queryParamSplit = regexp.MustCompile(`(^|&)([^&]+)`) + queryDescape = strings.NewReplacer("%5B", "[", "%5D", "]") +) + +// APIClient manages communication with the UniswapX API v2.0.0 +// In most cases there should be only one, shared, APIClient. +type APIClient struct { + cfg *Configuration + common service // Reuse a single struct instead of allocating one for each service on the heap. + + // API Services + + LimitOrdersAPI *LimitOrdersAPIService + + OrdersAPI *OrdersAPIService +} + +type service struct { + client *APIClient +} + +// NewAPIClient creates a new API client. Requires a userAgent string describing your application. +// optionally a custom http.Client to allow for advanced features such as caching. +func NewAPIClient(cfg *Configuration) *APIClient { + if cfg.HTTPClient == nil { + cfg.HTTPClient = http.DefaultClient + } + + c := &APIClient{} + c.cfg = cfg + c.common.client = c + + // API Services + c.LimitOrdersAPI = (*LimitOrdersAPIService)(&c.common) + c.OrdersAPI = (*OrdersAPIService)(&c.common) + + return c +} + +func atoi(in string) (int, error) { + return strconv.Atoi(in) +} + +// selectHeaderContentType select a content type from the available list. +func selectHeaderContentType(contentTypes []string) string { + if len(contentTypes) == 0 { + return "" + } + if contains(contentTypes, "application/json") { + return "application/json" + } + return contentTypes[0] // use the first content type specified in 'consumes' +} + +// selectHeaderAccept join all accept types and return +func selectHeaderAccept(accepts []string) string { + if len(accepts) == 0 { + return "" + } + + if contains(accepts, "application/json") { + return "application/json" + } + + return strings.Join(accepts, ",") +} + +// contains is a case insensitive match, finding needle in a haystack +func contains(haystack []string, needle string) bool { + for _, a := range haystack { + if strings.EqualFold(a, needle) { + return true + } + } + return false +} + +// Verify optional parameters are of the correct type. +func typeCheckParameter(obj interface{}, expected string, name string) error { + // Make sure there is an object. + if obj == nil { + return nil + } + + // Check the type is as expected. + if reflect.TypeOf(obj).String() != expected { + return fmt.Errorf("expected %s to be of type %s but received %s", name, expected, reflect.TypeOf(obj).String()) + } + return nil +} + +func parameterValueToString(obj interface{}, key string) string { + if reflect.TypeOf(obj).Kind() != reflect.Ptr { + if actualObj, ok := obj.(interface{ GetActualInstanceValue() interface{} }); ok { + return fmt.Sprintf("%v", actualObj.GetActualInstanceValue()) + } + + return fmt.Sprintf("%v", obj) + } + var param, ok = obj.(MappedNullable) + if !ok { + return "" + } + dataMap, err := param.ToMap() + if err != nil { + return "" + } + return fmt.Sprintf("%v", dataMap[key]) +} + +// parameterAddToHeaderOrQuery adds the provided object to the request header or url query +// supporting deep object syntax +func parameterAddToHeaderOrQuery(headerOrQueryParams interface{}, keyPrefix string, obj interface{}, style string, collectionType string) { + var v = reflect.ValueOf(obj) + var value = "" + if v == reflect.ValueOf(nil) { + value = "null" + } else { + switch v.Kind() { + case reflect.Invalid: + value = "invalid" + + case reflect.Struct: + if t, ok := obj.(MappedNullable); ok { + dataMap, err := t.ToMap() + if err != nil { + return + } + parameterAddToHeaderOrQuery(headerOrQueryParams, keyPrefix, dataMap, style, collectionType) + return + } + if t, ok := obj.(time.Time); ok { + parameterAddToHeaderOrQuery(headerOrQueryParams, keyPrefix, t.Format(time.RFC3339Nano), style, collectionType) + return + } + value = v.Type().String() + " value" + case reflect.Slice: + var indValue = reflect.ValueOf(obj) + if indValue == reflect.ValueOf(nil) { + return + } + var lenIndValue = indValue.Len() + for i := 0; i < lenIndValue; i++ { + var arrayValue = indValue.Index(i) + var keyPrefixForCollectionType = keyPrefix + if style == "deepObject" { + keyPrefixForCollectionType = keyPrefix + "[" + strconv.Itoa(i) + "]" + } + parameterAddToHeaderOrQuery(headerOrQueryParams, keyPrefixForCollectionType, arrayValue.Interface(), style, collectionType) + } + return + + case reflect.Map: + var indValue = reflect.ValueOf(obj) + if indValue == reflect.ValueOf(nil) { + return + } + iter := indValue.MapRange() + for iter.Next() { + k, v := iter.Key(), iter.Value() + parameterAddToHeaderOrQuery(headerOrQueryParams, fmt.Sprintf("%s[%s]", keyPrefix, k.String()), v.Interface(), style, collectionType) + } + return + + case reflect.Interface: + fallthrough + case reflect.Ptr: + parameterAddToHeaderOrQuery(headerOrQueryParams, keyPrefix, v.Elem().Interface(), style, collectionType) + return + + case reflect.Int, reflect.Int8, reflect.Int16, + reflect.Int32, reflect.Int64: + value = strconv.FormatInt(v.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, + reflect.Uint32, reflect.Uint64, reflect.Uintptr: + value = strconv.FormatUint(v.Uint(), 10) + case reflect.Float32, reflect.Float64: + value = strconv.FormatFloat(v.Float(), 'g', -1, 32) + case reflect.Bool: + value = strconv.FormatBool(v.Bool()) + case reflect.String: + value = v.String() + default: + value = v.Type().String() + " value" + } + } + + switch valuesMap := headerOrQueryParams.(type) { + case url.Values: + if collectionType == "csv" && valuesMap.Get(keyPrefix) != "" { + valuesMap.Set(keyPrefix, valuesMap.Get(keyPrefix)+","+value) + } else { + valuesMap.Add(keyPrefix, value) + } + break + case map[string]string: + valuesMap[keyPrefix] = value + break + } +} + +// helper for converting interface{} parameters to json strings +func parameterToJson(obj interface{}) (string, error) { + jsonBuf, err := json.Marshal(obj) + if err != nil { + return "", err + } + return string(jsonBuf), err +} + +// callAPI do the request. +func (c *APIClient) callAPI(request *http.Request) (*http.Response, error) { + if c.cfg.Debug { + dump, err := httputil.DumpRequestOut(request, true) + if err != nil { + return nil, err + } + log.Printf("\n%s\n", string(dump)) + } + + resp, err := c.cfg.HTTPClient.Do(request) + if err != nil { + return resp, err + } + + if c.cfg.Debug { + dump, err := httputil.DumpResponse(resp, true) + if err != nil { + return resp, err + } + log.Printf("\n%s\n", string(dump)) + } + return resp, err +} + +// Allow modification of underlying config for alternate implementations and testing +// Caution: modifying the configuration while live can cause data races and potentially unwanted behavior +func (c *APIClient) GetConfig() *Configuration { + return c.cfg +} + +type formFile struct { + fileBytes []byte + fileName string + formFileName string +} + +// prepareRequest build the request +func (c *APIClient) prepareRequest( + ctx context.Context, + path string, method string, + postBody interface{}, + headerParams map[string]string, + queryParams url.Values, + formParams url.Values, + formFiles []formFile) (localVarRequest *http.Request, err error) { + + var body *bytes.Buffer + + // Detect postBody type and post. + if postBody != nil { + contentType := headerParams["Content-Type"] + if contentType == "" { + contentType = detectContentType(postBody) + headerParams["Content-Type"] = contentType + } + + body, err = setBody(postBody, contentType) + if err != nil { + return nil, err + } + } + + // add form parameters and file if available. + if strings.HasPrefix(headerParams["Content-Type"], "multipart/form-data") && len(formParams) > 0 || (len(formFiles) > 0) { + if body != nil { + return nil, errors.New("Cannot specify postBody and multipart form at the same time.") + } + body = &bytes.Buffer{} + w := multipart.NewWriter(body) + + for k, v := range formParams { + for _, iv := range v { + if strings.HasPrefix(k, "@") { // file + err = addFile(w, k[1:], iv) + if err != nil { + return nil, err + } + } else { // form value + w.WriteField(k, iv) + } + } + } + for _, formFile := range formFiles { + if len(formFile.fileBytes) > 0 && formFile.fileName != "" { + w.Boundary() + part, err := w.CreateFormFile(formFile.formFileName, filepath.Base(formFile.fileName)) + if err != nil { + return nil, err + } + _, err = part.Write(formFile.fileBytes) + if err != nil { + return nil, err + } + } + } + + // Set the Boundary in the Content-Type + headerParams["Content-Type"] = w.FormDataContentType() + + // Set Content-Length + headerParams["Content-Length"] = fmt.Sprintf("%d", body.Len()) + w.Close() + } + + if strings.HasPrefix(headerParams["Content-Type"], "application/x-www-form-urlencoded") && len(formParams) > 0 { + if body != nil { + return nil, errors.New("Cannot specify postBody and x-www-form-urlencoded form at the same time.") + } + body = &bytes.Buffer{} + body.WriteString(formParams.Encode()) + // Set Content-Length + headerParams["Content-Length"] = fmt.Sprintf("%d", body.Len()) + } + + // Setup path and query parameters + url, err := url.Parse(path) + if err != nil { + return nil, err + } + + // Override request host, if applicable + if c.cfg.Host != "" { + url.Host = c.cfg.Host + } + + // Override request scheme, if applicable + if c.cfg.Scheme != "" { + url.Scheme = c.cfg.Scheme + } + + // Adding Query Param + query := url.Query() + for k, v := range queryParams { + for _, iv := range v { + query.Add(k, iv) + } + } + + // Encode the parameters. + url.RawQuery = queryParamSplit.ReplaceAllStringFunc(query.Encode(), func(s string) string { + pieces := strings.Split(s, "=") + pieces[0] = queryDescape.Replace(pieces[0]) + return strings.Join(pieces, "=") + }) + + // Generate a new request + if body != nil { + localVarRequest, err = http.NewRequest(method, url.String(), body) + } else { + localVarRequest, err = http.NewRequest(method, url.String(), nil) + } + if err != nil { + return nil, err + } + + // add header parameters, if any + if len(headerParams) > 0 { + headers := http.Header{} + for h, v := range headerParams { + headers[h] = []string{v} + } + localVarRequest.Header = headers + } + + // Add the user agent to the request. + localVarRequest.Header.Add("User-Agent", c.cfg.UserAgent) + + if ctx != nil { + // add context to the request + localVarRequest = localVarRequest.WithContext(ctx) + + // Walk through any authentication. + + } + + for header, value := range c.cfg.DefaultHeader { + localVarRequest.Header.Add(header, value) + } + return localVarRequest, nil +} + +func (c *APIClient) decode(v interface{}, b []byte, contentType string) (err error) { + if len(b) == 0 { + return nil + } + if s, ok := v.(*string); ok { + *s = string(b) + return nil + } + if f, ok := v.(*os.File); ok { + f, err = os.CreateTemp("", "HttpClientFile") + if err != nil { + return + } + _, err = f.Write(b) + if err != nil { + return + } + _, err = f.Seek(0, io.SeekStart) + return + } + if f, ok := v.(**os.File); ok { + *f, err = os.CreateTemp("", "HttpClientFile") + if err != nil { + return + } + _, err = (*f).Write(b) + if err != nil { + return + } + _, err = (*f).Seek(0, io.SeekStart) + return + } + if XmlCheck.MatchString(contentType) { + if err = xml.Unmarshal(b, v); err != nil { + return err + } + return nil + } + if JsonCheck.MatchString(contentType) { + if actualObj, ok := v.(interface{ GetActualInstance() interface{} }); ok { // oneOf, anyOf schemas + if unmarshalObj, ok := actualObj.(interface{ UnmarshalJSON([]byte) error }); ok { // make sure it has UnmarshalJSON defined + if err = unmarshalObj.UnmarshalJSON(b); err != nil { + return err + } + } else { + return errors.New("Unknown type with GetActualInstance but no unmarshalObj.UnmarshalJSON defined") + } + } else if err = json.Unmarshal(b, v); err != nil { // simple model + return err + } + return nil + } + return errors.New("undefined response type") +} + +// Add a file to the multipart request +func addFile(w *multipart.Writer, fieldName, path string) error { + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return err + } + err = file.Close() + if err != nil { + return err + } + + part, err := w.CreateFormFile(fieldName, filepath.Base(path)) + if err != nil { + return err + } + _, err = io.Copy(part, file) + + return err +} + +// Set request body from an interface{} +func setBody(body interface{}, contentType string) (bodyBuf *bytes.Buffer, err error) { + if bodyBuf == nil { + bodyBuf = &bytes.Buffer{} + } + + if reader, ok := body.(io.Reader); ok { + _, err = bodyBuf.ReadFrom(reader) + } else if fp, ok := body.(*os.File); ok { + _, err = bodyBuf.ReadFrom(fp) + } else if b, ok := body.([]byte); ok { + _, err = bodyBuf.Write(b) + } else if s, ok := body.(string); ok { + _, err = bodyBuf.WriteString(s) + } else if s, ok := body.(*string); ok { + _, err = bodyBuf.WriteString(*s) + } else if JsonCheck.MatchString(contentType) { + err = json.NewEncoder(bodyBuf).Encode(body) + } else if XmlCheck.MatchString(contentType) { + var bs []byte + bs, err = xml.Marshal(body) + if err == nil { + bodyBuf.Write(bs) + } + } + + if err != nil { + return nil, err + } + + if bodyBuf.Len() == 0 { + err = fmt.Errorf("invalid body type %s\n", contentType) + return nil, err + } + return bodyBuf, nil +} + +// detectContentType method is used to figure out `Request.Body` content type for request header +func detectContentType(body interface{}) string { + contentType := "text/plain; charset=utf-8" + kind := reflect.TypeOf(body).Kind() + + switch kind { + case reflect.Struct, reflect.Map, reflect.Ptr: + contentType = "application/json; charset=utf-8" + case reflect.String: + contentType = "text/plain; charset=utf-8" + default: + if b, ok := body.([]byte); ok { + contentType = http.DetectContentType(b) + } else if kind == reflect.Slice { + contentType = "application/json; charset=utf-8" + } + } + + return contentType +} + +// Ripped from https://github.com/gregjones/httpcache/blob/master/httpcache.go +type cacheControl map[string]string + +func parseCacheControl(headers http.Header) cacheControl { + cc := cacheControl{} + ccHeader := headers.Get("Cache-Control") + for _, part := range strings.Split(ccHeader, ",") { + part = strings.Trim(part, " ") + if part == "" { + continue + } + if strings.ContainsRune(part, '=') { + keyval := strings.Split(part, "=") + cc[strings.Trim(keyval[0], " ")] = strings.Trim(keyval[1], ",") + } else { + cc[part] = "" + } + } + return cc +} + +// CacheExpires helper function to determine remaining time before repeating a request. +func CacheExpires(r *http.Response) time.Time { + // Figure out when the cache expires. + var expires time.Time + now, err := time.Parse(time.RFC1123, r.Header.Get("date")) + if err != nil { + return time.Now() + } + respCacheControl := parseCacheControl(r.Header) + + if maxAge, ok := respCacheControl["max-age"]; ok { + lifetime, err := time.ParseDuration(maxAge + "s") + if err != nil { + expires = now + } else { + expires = now.Add(lifetime) + } + } else { + expiresHeader := r.Header.Get("Expires") + if expiresHeader != "" { + expires, err = time.Parse(time.RFC1123, expiresHeader) + if err != nil { + expires = now + } + } + } + return expires +} + +func strlen(s string) int { + return utf8.RuneCountInString(s) +} + +// GenericOpenAPIError Provides access to the body, error and model on returned errors. +type GenericOpenAPIError struct { + body []byte + error string + model interface{} +} + +// Error returns non-empty string if there was an error. +func (e GenericOpenAPIError) Error() string { + return e.error +} + +// Body returns the raw bytes of the response +func (e GenericOpenAPIError) Body() []byte { + return e.body +} + +// Model returns the unpacked model of the error +func (e GenericOpenAPIError) Model() interface{} { + return e.model +} + +// format error message using title and detail when model implements rfc7807 +func formatErrorMessage(status string, v interface{}) string { + str := "" + metaValue := reflect.ValueOf(v).Elem() + + if metaValue.Kind() == reflect.Struct { + field := metaValue.FieldByName("Title") + if field != (reflect.Value{}) { + str = fmt.Sprintf("%s", field.Interface()) + } + + field = metaValue.FieldByName("Detail") + if field != (reflect.Value{}) { + str = fmt.Sprintf("%s (%s)", str, field.Interface()) + } + } + + return strings.TrimSpace(fmt.Sprintf("%s %s", status, str)) +} diff --git a/api/uniswapxservice/configuration.go b/api/uniswapxservice/configuration.go new file mode 100644 index 00000000..ba1e1f93 --- /dev/null +++ b/api/uniswapxservice/configuration.go @@ -0,0 +1,214 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "context" + "fmt" + "net/http" + "strings" +) + +// contextKeys are used to identify the type of value in the context. +// Since these are string, it is possible to get a short description of the +// context key for logging and debugging using key.String(). + +type contextKey string + +func (c contextKey) String() string { + return "auth " + string(c) +} + +var ( + // ContextServerIndex uses a server configuration from the index. + ContextServerIndex = contextKey("serverIndex") + + // ContextOperationServerIndices uses a server configuration from the index mapping. + ContextOperationServerIndices = contextKey("serverOperationIndices") + + // ContextServerVariables overrides a server configuration variables. + ContextServerVariables = contextKey("serverVariables") + + // ContextOperationServerVariables overrides a server configuration variables using operation specific values. + ContextOperationServerVariables = contextKey("serverOperationVariables") +) + +// BasicAuth provides basic http authentication to a request passed via context using ContextBasicAuth +type BasicAuth struct { + UserName string `json:"userName,omitempty"` + Password string `json:"password,omitempty"` +} + +// APIKey provides API key based authentication to a request passed via context using ContextAPIKey +type APIKey struct { + Key string + Prefix string +} + +// ServerVariable stores the information about a server variable +type ServerVariable struct { + Description string + DefaultValue string + EnumValues []string +} + +// ServerConfiguration stores the information about a server +type ServerConfiguration struct { + URL string + Description string + Variables map[string]ServerVariable +} + +// ServerConfigurations stores multiple ServerConfiguration items +type ServerConfigurations []ServerConfiguration + +// Configuration stores the configuration of the API client +type Configuration struct { + Host string `json:"host,omitempty"` + Scheme string `json:"scheme,omitempty"` + DefaultHeader map[string]string `json:"defaultHeader,omitempty"` + UserAgent string `json:"userAgent,omitempty"` + Debug bool `json:"debug,omitempty"` + Servers ServerConfigurations + OperationServers map[string]ServerConfigurations + HTTPClient *http.Client +} + +// NewConfiguration returns a new Configuration object +func NewConfiguration() *Configuration { + cfg := &Configuration{ + DefaultHeader: make(map[string]string), + UserAgent: "OpenAPI-Generator/1.0.0/go", + Debug: false, + Servers: ServerConfigurations{ + { + URL: "https://api.uniswap.org/v2", + Description: "UniswapX APIs", + }, + }, + OperationServers: map[string]ServerConfigurations{}, + } + return cfg +} + +// AddDefaultHeader adds a new HTTP header to the default header in the request +func (c *Configuration) AddDefaultHeader(key string, value string) { + c.DefaultHeader[key] = value +} + +// URL formats template on a index using given variables +func (sc ServerConfigurations) URL(index int, variables map[string]string) (string, error) { + if index < 0 || len(sc) <= index { + return "", fmt.Errorf("index %v out of range %v", index, len(sc)-1) + } + server := sc[index] + url := server.URL + + // go through variables and replace placeholders + for name, variable := range server.Variables { + if value, ok := variables[name]; ok { + found := bool(len(variable.EnumValues) == 0) + for _, enumValue := range variable.EnumValues { + if value == enumValue { + found = true + } + } + if !found { + return "", fmt.Errorf("the variable %s in the server URL has invalid value %v. Must be %v", name, value, variable.EnumValues) + } + url = strings.Replace(url, "{"+name+"}", value, -1) + } else { + url = strings.Replace(url, "{"+name+"}", variable.DefaultValue, -1) + } + } + return url, nil +} + +// ServerURL returns URL based on server settings +func (c *Configuration) ServerURL(index int, variables map[string]string) (string, error) { + return c.Servers.URL(index, variables) +} + +func getServerIndex(ctx context.Context) (int, error) { + si := ctx.Value(ContextServerIndex) + if si != nil { + if index, ok := si.(int); ok { + return index, nil + } + return 0, reportError("Invalid type %T should be int", si) + } + return 0, nil +} + +func getServerOperationIndex(ctx context.Context, endpoint string) (int, error) { + osi := ctx.Value(ContextOperationServerIndices) + if osi != nil { + if operationIndices, ok := osi.(map[string]int); !ok { + return 0, reportError("Invalid type %T should be map[string]int", osi) + } else { + index, ok := operationIndices[endpoint] + if ok { + return index, nil + } + } + } + return getServerIndex(ctx) +} + +func getServerVariables(ctx context.Context) (map[string]string, error) { + sv := ctx.Value(ContextServerVariables) + if sv != nil { + if variables, ok := sv.(map[string]string); ok { + return variables, nil + } + return nil, reportError("ctx value of ContextServerVariables has invalid type %T should be map[string]string", sv) + } + return nil, nil +} + +func getServerOperationVariables(ctx context.Context, endpoint string) (map[string]string, error) { + osv := ctx.Value(ContextOperationServerVariables) + if osv != nil { + if operationVariables, ok := osv.(map[string]map[string]string); !ok { + return nil, reportError("ctx value of ContextOperationServerVariables has invalid type %T should be map[string]map[string]string", osv) + } else { + variables, ok := operationVariables[endpoint] + if ok { + return variables, nil + } + } + } + return getServerVariables(ctx) +} + +// ServerURLWithContext returns a new server URL given an endpoint +func (c *Configuration) ServerURLWithContext(ctx context.Context, endpoint string) (string, error) { + sc, ok := c.OperationServers[endpoint] + if !ok { + sc = c.Servers + } + + if ctx == nil { + return sc.URL(0, nil) + } + + index, err := getServerOperationIndex(ctx, endpoint) + if err != nil { + return "", err + } + + variables, err := getServerOperationVariables(ctx, endpoint) + if err != nil { + return "", err + } + + return sc.URL(index, variables) +} diff --git a/api/uniswapxservice/model_chain_id.go b/api/uniswapxservice/model_chain_id.go new file mode 100644 index 00000000..275415e8 --- /dev/null +++ b/api/uniswapxservice/model_chain_id.go @@ -0,0 +1,148 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" + "fmt" +) + +// ChainId Chains supported by UniswapX. +type ChainId float32 + +// List of ChainId +const ( + _1 ChainId = 1 + _10 ChainId = 10 + _56 ChainId = 56 + _130 ChainId = 130 + _137 ChainId = 137 + _143 ChainId = 143 + _196 ChainId = 196 + _480 ChainId = 480 + _1301 ChainId = 1301 + _1868 ChainId = 1868 + _4217 ChainId = 4217 + _4663 ChainId = 4663 + _5042 ChainId = 5042 + _8453 ChainId = 8453 + _42161 ChainId = 42161 + _42220 ChainId = 42220 + _43114 ChainId = 43114 + _81457 ChainId = 81457 + _7777777 ChainId = 7777777 + _11155111 ChainId = 11155111 + _31337 ChainId = 31337 +) + +// All allowed values of ChainId enum +var AllowedChainIdEnumValues = []ChainId{ + 1, + 10, + 56, + 130, + 137, + 143, + 196, + 480, + 1301, + 1868, + 4217, + 4663, + 5042, + 8453, + 42161, + 42220, + 43114, + 81457, + 7777777, + 11155111, + 31337, +} + +func (v *ChainId) UnmarshalJSON(src []byte) error { + var value float32 + err := json.Unmarshal(src, &value) + if err != nil { + return err + } + enumTypeValue := ChainId(value) + for _, existing := range AllowedChainIdEnumValues { + if existing == enumTypeValue { + *v = enumTypeValue + return nil + } + } + + return fmt.Errorf("%+v is not a valid ChainId", value) +} + +// NewChainIdFromValue returns a pointer to a valid ChainId +// for the value passed as argument, or an error if the value passed is not allowed by the enum +func NewChainIdFromValue(v float32) (*ChainId, error) { + ev := ChainId(v) + if ev.IsValid() { + return &ev, nil + } else { + return nil, fmt.Errorf("invalid value '%v' for ChainId: valid values are %v", v, AllowedChainIdEnumValues) + } +} + +// IsValid return true if the value is valid for the enum, false otherwise +func (v ChainId) IsValid() bool { + for _, existing := range AllowedChainIdEnumValues { + if existing == v { + return true + } + } + return false +} + +// Ptr returns reference to ChainId value +func (v ChainId) Ptr() *ChainId { + return &v +} + +type NullableChainId struct { + value *ChainId + isSet bool +} + +func (v NullableChainId) Get() *ChainId { + return v.value +} + +func (v *NullableChainId) Set(val *ChainId) { + v.value = val + v.isSet = true +} + +func (v NullableChainId) IsSet() bool { + return v.isSet +} + +func (v *NullableChainId) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableChainId(val *ChainId) *NullableChainId { + return &NullableChainId{value: val, isSet: true} +} + +func (v NullableChainId) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableChainId) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_dutch_order_entity.go b/api/uniswapxservice/model_dutch_order_entity.go new file mode 100644 index 00000000..aa40046e --- /dev/null +++ b/api/uniswapxservice/model_dutch_order_entity.go @@ -0,0 +1,636 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" +) + +// checks if the DutchOrderEntity type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &DutchOrderEntity{} + +// DutchOrderEntity Dutch V1 and Limit orders. Legacy DutchLimit entries share this shape. +type DutchOrderEntity struct { + Type *string `json:"type,omitempty"` + // ABI-encoded order struct. Decode with the uniswapx-sdk parser for the order's type. + EncodedOrder *string `json:"encodedOrder,omitempty" validate:"regexp=^0x[0-9a-fA-F]*$"` + // EIP-712 signature over the order. + Signature *string `json:"signature,omitempty" validate:"regexp=^0x[0-9a-fA-F]{130}$"` + // Permit2 nonce, uint256 encoded as a base-10 string. + Nonce *string `json:"nonce,omitempty" validate:"regexp=^[0-9]{1,78}$"` + OrderHash *string `json:"orderHash,omitempty" validate:"regexp=^0x[0-9a-fA-F]{64}$"` + OrderStatus *OrderStatus `json:"orderStatus,omitempty"` + ChainId *ChainId `json:"chainId,omitempty"` + // EIP-55 checksummed Ethereum address. + Swapper *string `json:"swapper,omitempty"` + Input *OrderInput `json:"input,omitempty"` + Outputs []OrderOutput `json:"outputs,omitempty"` + // Unix timestamp (seconds) at which the order was recorded. + CreatedAt *float32 `json:"createdAt,omitempty"` + // Defined when the order has a quote associated with it. + QuoteId *string `json:"quoteId,omitempty"` + // Defined when the order has a quote request associated with it. + RequestId *string `json:"requestId,omitempty"` + // Transaction hash of the fill. Defined once the order has been filled. + TxHash *string `json:"txHash,omitempty" validate:"regexp=^0x[0-9a-fA-F]{64}$"` + SettledAmounts []SettledAmount `json:"settledAmounts,omitempty"` +} + +// NewDutchOrderEntity instantiates a new DutchOrderEntity object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewDutchOrderEntity() *DutchOrderEntity { + this := DutchOrderEntity{} + return &this +} + +// NewDutchOrderEntityWithDefaults instantiates a new DutchOrderEntity object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewDutchOrderEntityWithDefaults() *DutchOrderEntity { + this := DutchOrderEntity{} + return &this +} + +// GetType returns the Type field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetType() string { + if o == nil || IsNil(o.Type) { + var ret string + return ret + } + return *o.Type +} + +// GetTypeOk returns a tuple with the Type field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetTypeOk() (*string, bool) { + if o == nil || IsNil(o.Type) { + return nil, false + } + return o.Type, true +} + +// HasType returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasType() bool { + if o != nil && !IsNil(o.Type) { + return true + } + + return false +} + +// SetType gets a reference to the given string and assigns it to the Type field. +func (o *DutchOrderEntity) SetType(v string) { + o.Type = &v +} + +// GetEncodedOrder returns the EncodedOrder field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetEncodedOrder() string { + if o == nil || IsNil(o.EncodedOrder) { + var ret string + return ret + } + return *o.EncodedOrder +} + +// GetEncodedOrderOk returns a tuple with the EncodedOrder field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetEncodedOrderOk() (*string, bool) { + if o == nil || IsNil(o.EncodedOrder) { + return nil, false + } + return o.EncodedOrder, true +} + +// HasEncodedOrder returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasEncodedOrder() bool { + if o != nil && !IsNil(o.EncodedOrder) { + return true + } + + return false +} + +// SetEncodedOrder gets a reference to the given string and assigns it to the EncodedOrder field. +func (o *DutchOrderEntity) SetEncodedOrder(v string) { + o.EncodedOrder = &v +} + +// GetSignature returns the Signature field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetSignature() string { + if o == nil || IsNil(o.Signature) { + var ret string + return ret + } + return *o.Signature +} + +// GetSignatureOk returns a tuple with the Signature field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetSignatureOk() (*string, bool) { + if o == nil || IsNil(o.Signature) { + return nil, false + } + return o.Signature, true +} + +// HasSignature returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasSignature() bool { + if o != nil && !IsNil(o.Signature) { + return true + } + + return false +} + +// SetSignature gets a reference to the given string and assigns it to the Signature field. +func (o *DutchOrderEntity) SetSignature(v string) { + o.Signature = &v +} + +// GetNonce returns the Nonce field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetNonce() string { + if o == nil || IsNil(o.Nonce) { + var ret string + return ret + } + return *o.Nonce +} + +// GetNonceOk returns a tuple with the Nonce field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetNonceOk() (*string, bool) { + if o == nil || IsNil(o.Nonce) { + return nil, false + } + return o.Nonce, true +} + +// HasNonce returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasNonce() bool { + if o != nil && !IsNil(o.Nonce) { + return true + } + + return false +} + +// SetNonce gets a reference to the given string and assigns it to the Nonce field. +func (o *DutchOrderEntity) SetNonce(v string) { + o.Nonce = &v +} + +// GetOrderHash returns the OrderHash field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetOrderHash() string { + if o == nil || IsNil(o.OrderHash) { + var ret string + return ret + } + return *o.OrderHash +} + +// GetOrderHashOk returns a tuple with the OrderHash field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetOrderHashOk() (*string, bool) { + if o == nil || IsNil(o.OrderHash) { + return nil, false + } + return o.OrderHash, true +} + +// HasOrderHash returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasOrderHash() bool { + if o != nil && !IsNil(o.OrderHash) { + return true + } + + return false +} + +// SetOrderHash gets a reference to the given string and assigns it to the OrderHash field. +func (o *DutchOrderEntity) SetOrderHash(v string) { + o.OrderHash = &v +} + +// GetOrderStatus returns the OrderStatus field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetOrderStatus() OrderStatus { + if o == nil || IsNil(o.OrderStatus) { + var ret OrderStatus + return ret + } + return *o.OrderStatus +} + +// GetOrderStatusOk returns a tuple with the OrderStatus field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetOrderStatusOk() (*OrderStatus, bool) { + if o == nil || IsNil(o.OrderStatus) { + return nil, false + } + return o.OrderStatus, true +} + +// HasOrderStatus returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasOrderStatus() bool { + if o != nil && !IsNil(o.OrderStatus) { + return true + } + + return false +} + +// SetOrderStatus gets a reference to the given OrderStatus and assigns it to the OrderStatus field. +func (o *DutchOrderEntity) SetOrderStatus(v OrderStatus) { + o.OrderStatus = &v +} + +// GetChainId returns the ChainId field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetChainId() ChainId { + if o == nil || IsNil(o.ChainId) { + var ret ChainId + return ret + } + return *o.ChainId +} + +// GetChainIdOk returns a tuple with the ChainId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetChainIdOk() (*ChainId, bool) { + if o == nil || IsNil(o.ChainId) { + return nil, false + } + return o.ChainId, true +} + +// HasChainId returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasChainId() bool { + if o != nil && !IsNil(o.ChainId) { + return true + } + + return false +} + +// SetChainId gets a reference to the given ChainId and assigns it to the ChainId field. +func (o *DutchOrderEntity) SetChainId(v ChainId) { + o.ChainId = &v +} + +// GetSwapper returns the Swapper field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetSwapper() string { + if o == nil || IsNil(o.Swapper) { + var ret string + return ret + } + return *o.Swapper +} + +// GetSwapperOk returns a tuple with the Swapper field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetSwapperOk() (*string, bool) { + if o == nil || IsNil(o.Swapper) { + return nil, false + } + return o.Swapper, true +} + +// HasSwapper returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasSwapper() bool { + if o != nil && !IsNil(o.Swapper) { + return true + } + + return false +} + +// SetSwapper gets a reference to the given string and assigns it to the Swapper field. +func (o *DutchOrderEntity) SetSwapper(v string) { + o.Swapper = &v +} + +// GetInput returns the Input field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetInput() OrderInput { + if o == nil || IsNil(o.Input) { + var ret OrderInput + return ret + } + return *o.Input +} + +// GetInputOk returns a tuple with the Input field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetInputOk() (*OrderInput, bool) { + if o == nil || IsNil(o.Input) { + return nil, false + } + return o.Input, true +} + +// HasInput returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasInput() bool { + if o != nil && !IsNil(o.Input) { + return true + } + + return false +} + +// SetInput gets a reference to the given OrderInput and assigns it to the Input field. +func (o *DutchOrderEntity) SetInput(v OrderInput) { + o.Input = &v +} + +// GetOutputs returns the Outputs field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetOutputs() []OrderOutput { + if o == nil || IsNil(o.Outputs) { + var ret []OrderOutput + return ret + } + return o.Outputs +} + +// GetOutputsOk returns a tuple with the Outputs field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetOutputsOk() ([]OrderOutput, bool) { + if o == nil || IsNil(o.Outputs) { + return nil, false + } + return o.Outputs, true +} + +// HasOutputs returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasOutputs() bool { + if o != nil && !IsNil(o.Outputs) { + return true + } + + return false +} + +// SetOutputs gets a reference to the given []OrderOutput and assigns it to the Outputs field. +func (o *DutchOrderEntity) SetOutputs(v []OrderOutput) { + o.Outputs = v +} + +// GetCreatedAt returns the CreatedAt field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetCreatedAt() float32 { + if o == nil || IsNil(o.CreatedAt) { + var ret float32 + return ret + } + return *o.CreatedAt +} + +// GetCreatedAtOk returns a tuple with the CreatedAt field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetCreatedAtOk() (*float32, bool) { + if o == nil || IsNil(o.CreatedAt) { + return nil, false + } + return o.CreatedAt, true +} + +// HasCreatedAt returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasCreatedAt() bool { + if o != nil && !IsNil(o.CreatedAt) { + return true + } + + return false +} + +// SetCreatedAt gets a reference to the given float32 and assigns it to the CreatedAt field. +func (o *DutchOrderEntity) SetCreatedAt(v float32) { + o.CreatedAt = &v +} + +// GetQuoteId returns the QuoteId field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetQuoteId() string { + if o == nil || IsNil(o.QuoteId) { + var ret string + return ret + } + return *o.QuoteId +} + +// GetQuoteIdOk returns a tuple with the QuoteId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetQuoteIdOk() (*string, bool) { + if o == nil || IsNil(o.QuoteId) { + return nil, false + } + return o.QuoteId, true +} + +// HasQuoteId returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasQuoteId() bool { + if o != nil && !IsNil(o.QuoteId) { + return true + } + + return false +} + +// SetQuoteId gets a reference to the given string and assigns it to the QuoteId field. +func (o *DutchOrderEntity) SetQuoteId(v string) { + o.QuoteId = &v +} + +// GetRequestId returns the RequestId field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetRequestId() string { + if o == nil || IsNil(o.RequestId) { + var ret string + return ret + } + return *o.RequestId +} + +// GetRequestIdOk returns a tuple with the RequestId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetRequestIdOk() (*string, bool) { + if o == nil || IsNil(o.RequestId) { + return nil, false + } + return o.RequestId, true +} + +// HasRequestId returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasRequestId() bool { + if o != nil && !IsNil(o.RequestId) { + return true + } + + return false +} + +// SetRequestId gets a reference to the given string and assigns it to the RequestId field. +func (o *DutchOrderEntity) SetRequestId(v string) { + o.RequestId = &v +} + +// GetTxHash returns the TxHash field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetTxHash() string { + if o == nil || IsNil(o.TxHash) { + var ret string + return ret + } + return *o.TxHash +} + +// GetTxHashOk returns a tuple with the TxHash field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetTxHashOk() (*string, bool) { + if o == nil || IsNil(o.TxHash) { + return nil, false + } + return o.TxHash, true +} + +// HasTxHash returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasTxHash() bool { + if o != nil && !IsNil(o.TxHash) { + return true + } + + return false +} + +// SetTxHash gets a reference to the given string and assigns it to the TxHash field. +func (o *DutchOrderEntity) SetTxHash(v string) { + o.TxHash = &v +} + +// GetSettledAmounts returns the SettledAmounts field value if set, zero value otherwise. +func (o *DutchOrderEntity) GetSettledAmounts() []SettledAmount { + if o == nil || IsNil(o.SettledAmounts) { + var ret []SettledAmount + return ret + } + return o.SettledAmounts +} + +// GetSettledAmountsOk returns a tuple with the SettledAmounts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchOrderEntity) GetSettledAmountsOk() ([]SettledAmount, bool) { + if o == nil || IsNil(o.SettledAmounts) { + return nil, false + } + return o.SettledAmounts, true +} + +// HasSettledAmounts returns a boolean if a field has been set. +func (o *DutchOrderEntity) HasSettledAmounts() bool { + if o != nil && !IsNil(o.SettledAmounts) { + return true + } + + return false +} + +// SetSettledAmounts gets a reference to the given []SettledAmount and assigns it to the SettledAmounts field. +func (o *DutchOrderEntity) SetSettledAmounts(v []SettledAmount) { + o.SettledAmounts = v +} + +func (o DutchOrderEntity) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o DutchOrderEntity) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Type) { + toSerialize["type"] = o.Type + } + if !IsNil(o.EncodedOrder) { + toSerialize["encodedOrder"] = o.EncodedOrder + } + if !IsNil(o.Signature) { + toSerialize["signature"] = o.Signature + } + if !IsNil(o.Nonce) { + toSerialize["nonce"] = o.Nonce + } + if !IsNil(o.OrderHash) { + toSerialize["orderHash"] = o.OrderHash + } + if !IsNil(o.OrderStatus) { + toSerialize["orderStatus"] = o.OrderStatus + } + if !IsNil(o.ChainId) { + toSerialize["chainId"] = o.ChainId + } + if !IsNil(o.Swapper) { + toSerialize["swapper"] = o.Swapper + } + if !IsNil(o.Input) { + toSerialize["input"] = o.Input + } + if !IsNil(o.Outputs) { + toSerialize["outputs"] = o.Outputs + } + if !IsNil(o.CreatedAt) { + toSerialize["createdAt"] = o.CreatedAt + } + if !IsNil(o.QuoteId) { + toSerialize["quoteId"] = o.QuoteId + } + if !IsNil(o.RequestId) { + toSerialize["requestId"] = o.RequestId + } + if !IsNil(o.TxHash) { + toSerialize["txHash"] = o.TxHash + } + if !IsNil(o.SettledAmounts) { + toSerialize["settledAmounts"] = o.SettledAmounts + } + return toSerialize, nil +} + +type NullableDutchOrderEntity struct { + value *DutchOrderEntity + isSet bool +} + +func (v NullableDutchOrderEntity) Get() *DutchOrderEntity { + return v.value +} + +func (v *NullableDutchOrderEntity) Set(val *DutchOrderEntity) { + v.value = val + v.isSet = true +} + +func (v NullableDutchOrderEntity) IsSet() bool { + return v.isSet +} + +func (v *NullableDutchOrderEntity) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableDutchOrderEntity(val *DutchOrderEntity) *NullableDutchOrderEntity { + return &NullableDutchOrderEntity{value: val, isSet: true} +} + +func (v NullableDutchOrderEntity) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableDutchOrderEntity) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_dutch_v2_order_entity.go b/api/uniswapxservice/model_dutch_v2_order_entity.go new file mode 100644 index 00000000..25b879b0 --- /dev/null +++ b/api/uniswapxservice/model_dutch_v2_order_entity.go @@ -0,0 +1,728 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the DutchV2OrderEntity type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &DutchV2OrderEntity{} + +// DutchV2OrderEntity Dutch V2 orders: cosigned Dutch auctions with time-based decay. +type DutchV2OrderEntity struct { + Type string `json:"type"` + // ABI-encoded order struct. Decode with the uniswapx-sdk parser for the order's type. + EncodedOrder string `json:"encodedOrder" validate:"regexp=^0x[0-9a-fA-F]*$"` + // EIP-712 signature over the order. + Signature string `json:"signature" validate:"regexp=^0x[0-9a-fA-F]{130}$"` + // Permit2 nonce, uint256 encoded as a base-10 string. + Nonce *string `json:"nonce,omitempty" validate:"regexp=^[0-9]{1,78}$"` + OrderHash string `json:"orderHash" validate:"regexp=^0x[0-9a-fA-F]{64}$"` + OrderStatus OrderStatus `json:"orderStatus"` + ChainId ChainId `json:"chainId"` + // EIP-55 checksummed Ethereum address. + Swapper string `json:"swapper"` + Input *DutchV2OrderEntityInput `json:"input,omitempty"` + Outputs []OrderOutput `json:"outputs,omitempty"` + CosignerData *DutchV2OrderEntityCosignerData `json:"cosignerData,omitempty"` + Cosignature *string `json:"cosignature,omitempty"` + // Unix timestamp (seconds) at which the order was recorded. + CreatedAt *float32 `json:"createdAt,omitempty"` + // Defined when the order has a quote associated with it. + QuoteId *string `json:"quoteId,omitempty"` + // Defined when the order has a quote request associated with it. + RequestId *string `json:"requestId,omitempty"` + // Transaction hash of the fill. Defined once the order has been filled. + TxHash *string `json:"txHash,omitempty" validate:"regexp=^0x[0-9a-fA-F]{64}$"` + SettledAmounts []SettledAmount `json:"settledAmounts,omitempty"` + Route *Route `json:"route,omitempty"` +} + +type _DutchV2OrderEntity DutchV2OrderEntity + +// NewDutchV2OrderEntity instantiates a new DutchV2OrderEntity object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewDutchV2OrderEntity(type_ string, encodedOrder string, signature string, orderHash string, orderStatus OrderStatus, chainId ChainId, swapper string) *DutchV2OrderEntity { + this := DutchV2OrderEntity{} + this.Type = type_ + this.EncodedOrder = encodedOrder + this.Signature = signature + this.OrderHash = orderHash + this.OrderStatus = orderStatus + this.ChainId = chainId + this.Swapper = swapper + return &this +} + +// NewDutchV2OrderEntityWithDefaults instantiates a new DutchV2OrderEntity object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewDutchV2OrderEntityWithDefaults() *DutchV2OrderEntity { + this := DutchV2OrderEntity{} + return &this +} + +// GetType returns the Type field value +func (o *DutchV2OrderEntity) GetType() string { + if o == nil { + var ret string + return ret + } + + return o.Type +} + +// GetTypeOk returns a tuple with the Type field value +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Type, true +} + +// SetType sets field value +func (o *DutchV2OrderEntity) SetType(v string) { + o.Type = v +} + +// GetEncodedOrder returns the EncodedOrder field value +func (o *DutchV2OrderEntity) GetEncodedOrder() string { + if o == nil { + var ret string + return ret + } + + return o.EncodedOrder +} + +// GetEncodedOrderOk returns a tuple with the EncodedOrder field value +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetEncodedOrderOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.EncodedOrder, true +} + +// SetEncodedOrder sets field value +func (o *DutchV2OrderEntity) SetEncodedOrder(v string) { + o.EncodedOrder = v +} + +// GetSignature returns the Signature field value +func (o *DutchV2OrderEntity) GetSignature() string { + if o == nil { + var ret string + return ret + } + + return o.Signature +} + +// GetSignatureOk returns a tuple with the Signature field value +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetSignatureOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Signature, true +} + +// SetSignature sets field value +func (o *DutchV2OrderEntity) SetSignature(v string) { + o.Signature = v +} + +// GetNonce returns the Nonce field value if set, zero value otherwise. +func (o *DutchV2OrderEntity) GetNonce() string { + if o == nil || IsNil(o.Nonce) { + var ret string + return ret + } + return *o.Nonce +} + +// GetNonceOk returns a tuple with the Nonce field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetNonceOk() (*string, bool) { + if o == nil || IsNil(o.Nonce) { + return nil, false + } + return o.Nonce, true +} + +// HasNonce returns a boolean if a field has been set. +func (o *DutchV2OrderEntity) HasNonce() bool { + if o != nil && !IsNil(o.Nonce) { + return true + } + + return false +} + +// SetNonce gets a reference to the given string and assigns it to the Nonce field. +func (o *DutchV2OrderEntity) SetNonce(v string) { + o.Nonce = &v +} + +// GetOrderHash returns the OrderHash field value +func (o *DutchV2OrderEntity) GetOrderHash() string { + if o == nil { + var ret string + return ret + } + + return o.OrderHash +} + +// GetOrderHashOk returns a tuple with the OrderHash field value +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetOrderHashOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.OrderHash, true +} + +// SetOrderHash sets field value +func (o *DutchV2OrderEntity) SetOrderHash(v string) { + o.OrderHash = v +} + +// GetOrderStatus returns the OrderStatus field value +func (o *DutchV2OrderEntity) GetOrderStatus() OrderStatus { + if o == nil { + var ret OrderStatus + return ret + } + + return o.OrderStatus +} + +// GetOrderStatusOk returns a tuple with the OrderStatus field value +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetOrderStatusOk() (*OrderStatus, bool) { + if o == nil { + return nil, false + } + return &o.OrderStatus, true +} + +// SetOrderStatus sets field value +func (o *DutchV2OrderEntity) SetOrderStatus(v OrderStatus) { + o.OrderStatus = v +} + +// GetChainId returns the ChainId field value +func (o *DutchV2OrderEntity) GetChainId() ChainId { + if o == nil { + var ret ChainId + return ret + } + + return o.ChainId +} + +// GetChainIdOk returns a tuple with the ChainId field value +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetChainIdOk() (*ChainId, bool) { + if o == nil { + return nil, false + } + return &o.ChainId, true +} + +// SetChainId sets field value +func (o *DutchV2OrderEntity) SetChainId(v ChainId) { + o.ChainId = v +} + +// GetSwapper returns the Swapper field value +func (o *DutchV2OrderEntity) GetSwapper() string { + if o == nil { + var ret string + return ret + } + + return o.Swapper +} + +// GetSwapperOk returns a tuple with the Swapper field value +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetSwapperOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Swapper, true +} + +// SetSwapper sets field value +func (o *DutchV2OrderEntity) SetSwapper(v string) { + o.Swapper = v +} + +// GetInput returns the Input field value if set, zero value otherwise. +func (o *DutchV2OrderEntity) GetInput() DutchV2OrderEntityInput { + if o == nil || IsNil(o.Input) { + var ret DutchV2OrderEntityInput + return ret + } + return *o.Input +} + +// GetInputOk returns a tuple with the Input field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetInputOk() (*DutchV2OrderEntityInput, bool) { + if o == nil || IsNil(o.Input) { + return nil, false + } + return o.Input, true +} + +// HasInput returns a boolean if a field has been set. +func (o *DutchV2OrderEntity) HasInput() bool { + if o != nil && !IsNil(o.Input) { + return true + } + + return false +} + +// SetInput gets a reference to the given DutchV2OrderEntityInput and assigns it to the Input field. +func (o *DutchV2OrderEntity) SetInput(v DutchV2OrderEntityInput) { + o.Input = &v +} + +// GetOutputs returns the Outputs field value if set, zero value otherwise. +func (o *DutchV2OrderEntity) GetOutputs() []OrderOutput { + if o == nil || IsNil(o.Outputs) { + var ret []OrderOutput + return ret + } + return o.Outputs +} + +// GetOutputsOk returns a tuple with the Outputs field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetOutputsOk() ([]OrderOutput, bool) { + if o == nil || IsNil(o.Outputs) { + return nil, false + } + return o.Outputs, true +} + +// HasOutputs returns a boolean if a field has been set. +func (o *DutchV2OrderEntity) HasOutputs() bool { + if o != nil && !IsNil(o.Outputs) { + return true + } + + return false +} + +// SetOutputs gets a reference to the given []OrderOutput and assigns it to the Outputs field. +func (o *DutchV2OrderEntity) SetOutputs(v []OrderOutput) { + o.Outputs = v +} + +// GetCosignerData returns the CosignerData field value if set, zero value otherwise. +func (o *DutchV2OrderEntity) GetCosignerData() DutchV2OrderEntityCosignerData { + if o == nil || IsNil(o.CosignerData) { + var ret DutchV2OrderEntityCosignerData + return ret + } + return *o.CosignerData +} + +// GetCosignerDataOk returns a tuple with the CosignerData field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetCosignerDataOk() (*DutchV2OrderEntityCosignerData, bool) { + if o == nil || IsNil(o.CosignerData) { + return nil, false + } + return o.CosignerData, true +} + +// HasCosignerData returns a boolean if a field has been set. +func (o *DutchV2OrderEntity) HasCosignerData() bool { + if o != nil && !IsNil(o.CosignerData) { + return true + } + + return false +} + +// SetCosignerData gets a reference to the given DutchV2OrderEntityCosignerData and assigns it to the CosignerData field. +func (o *DutchV2OrderEntity) SetCosignerData(v DutchV2OrderEntityCosignerData) { + o.CosignerData = &v +} + +// GetCosignature returns the Cosignature field value if set, zero value otherwise. +func (o *DutchV2OrderEntity) GetCosignature() string { + if o == nil || IsNil(o.Cosignature) { + var ret string + return ret + } + return *o.Cosignature +} + +// GetCosignatureOk returns a tuple with the Cosignature field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetCosignatureOk() (*string, bool) { + if o == nil || IsNil(o.Cosignature) { + return nil, false + } + return o.Cosignature, true +} + +// HasCosignature returns a boolean if a field has been set. +func (o *DutchV2OrderEntity) HasCosignature() bool { + if o != nil && !IsNil(o.Cosignature) { + return true + } + + return false +} + +// SetCosignature gets a reference to the given string and assigns it to the Cosignature field. +func (o *DutchV2OrderEntity) SetCosignature(v string) { + o.Cosignature = &v +} + +// GetCreatedAt returns the CreatedAt field value if set, zero value otherwise. +func (o *DutchV2OrderEntity) GetCreatedAt() float32 { + if o == nil || IsNil(o.CreatedAt) { + var ret float32 + return ret + } + return *o.CreatedAt +} + +// GetCreatedAtOk returns a tuple with the CreatedAt field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetCreatedAtOk() (*float32, bool) { + if o == nil || IsNil(o.CreatedAt) { + return nil, false + } + return o.CreatedAt, true +} + +// HasCreatedAt returns a boolean if a field has been set. +func (o *DutchV2OrderEntity) HasCreatedAt() bool { + if o != nil && !IsNil(o.CreatedAt) { + return true + } + + return false +} + +// SetCreatedAt gets a reference to the given float32 and assigns it to the CreatedAt field. +func (o *DutchV2OrderEntity) SetCreatedAt(v float32) { + o.CreatedAt = &v +} + +// GetQuoteId returns the QuoteId field value if set, zero value otherwise. +func (o *DutchV2OrderEntity) GetQuoteId() string { + if o == nil || IsNil(o.QuoteId) { + var ret string + return ret + } + return *o.QuoteId +} + +// GetQuoteIdOk returns a tuple with the QuoteId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetQuoteIdOk() (*string, bool) { + if o == nil || IsNil(o.QuoteId) { + return nil, false + } + return o.QuoteId, true +} + +// HasQuoteId returns a boolean if a field has been set. +func (o *DutchV2OrderEntity) HasQuoteId() bool { + if o != nil && !IsNil(o.QuoteId) { + return true + } + + return false +} + +// SetQuoteId gets a reference to the given string and assigns it to the QuoteId field. +func (o *DutchV2OrderEntity) SetQuoteId(v string) { + o.QuoteId = &v +} + +// GetRequestId returns the RequestId field value if set, zero value otherwise. +func (o *DutchV2OrderEntity) GetRequestId() string { + if o == nil || IsNil(o.RequestId) { + var ret string + return ret + } + return *o.RequestId +} + +// GetRequestIdOk returns a tuple with the RequestId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetRequestIdOk() (*string, bool) { + if o == nil || IsNil(o.RequestId) { + return nil, false + } + return o.RequestId, true +} + +// HasRequestId returns a boolean if a field has been set. +func (o *DutchV2OrderEntity) HasRequestId() bool { + if o != nil && !IsNil(o.RequestId) { + return true + } + + return false +} + +// SetRequestId gets a reference to the given string and assigns it to the RequestId field. +func (o *DutchV2OrderEntity) SetRequestId(v string) { + o.RequestId = &v +} + +// GetTxHash returns the TxHash field value if set, zero value otherwise. +func (o *DutchV2OrderEntity) GetTxHash() string { + if o == nil || IsNil(o.TxHash) { + var ret string + return ret + } + return *o.TxHash +} + +// GetTxHashOk returns a tuple with the TxHash field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetTxHashOk() (*string, bool) { + if o == nil || IsNil(o.TxHash) { + return nil, false + } + return o.TxHash, true +} + +// HasTxHash returns a boolean if a field has been set. +func (o *DutchV2OrderEntity) HasTxHash() bool { + if o != nil && !IsNil(o.TxHash) { + return true + } + + return false +} + +// SetTxHash gets a reference to the given string and assigns it to the TxHash field. +func (o *DutchV2OrderEntity) SetTxHash(v string) { + o.TxHash = &v +} + +// GetSettledAmounts returns the SettledAmounts field value if set, zero value otherwise. +func (o *DutchV2OrderEntity) GetSettledAmounts() []SettledAmount { + if o == nil || IsNil(o.SettledAmounts) { + var ret []SettledAmount + return ret + } + return o.SettledAmounts +} + +// GetSettledAmountsOk returns a tuple with the SettledAmounts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetSettledAmountsOk() ([]SettledAmount, bool) { + if o == nil || IsNil(o.SettledAmounts) { + return nil, false + } + return o.SettledAmounts, true +} + +// HasSettledAmounts returns a boolean if a field has been set. +func (o *DutchV2OrderEntity) HasSettledAmounts() bool { + if o != nil && !IsNil(o.SettledAmounts) { + return true + } + + return false +} + +// SetSettledAmounts gets a reference to the given []SettledAmount and assigns it to the SettledAmounts field. +func (o *DutchV2OrderEntity) SetSettledAmounts(v []SettledAmount) { + o.SettledAmounts = v +} + +// GetRoute returns the Route field value if set, zero value otherwise. +func (o *DutchV2OrderEntity) GetRoute() Route { + if o == nil || IsNil(o.Route) { + var ret Route + return ret + } + return *o.Route +} + +// GetRouteOk returns a tuple with the Route field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntity) GetRouteOk() (*Route, bool) { + if o == nil || IsNil(o.Route) { + return nil, false + } + return o.Route, true +} + +// HasRoute returns a boolean if a field has been set. +func (o *DutchV2OrderEntity) HasRoute() bool { + if o != nil && !IsNil(o.Route) { + return true + } + + return false +} + +// SetRoute gets a reference to the given Route and assigns it to the Route field. +func (o *DutchV2OrderEntity) SetRoute(v Route) { + o.Route = &v +} + +func (o DutchV2OrderEntity) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o DutchV2OrderEntity) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["type"] = o.Type + toSerialize["encodedOrder"] = o.EncodedOrder + toSerialize["signature"] = o.Signature + if !IsNil(o.Nonce) { + toSerialize["nonce"] = o.Nonce + } + toSerialize["orderHash"] = o.OrderHash + toSerialize["orderStatus"] = o.OrderStatus + toSerialize["chainId"] = o.ChainId + toSerialize["swapper"] = o.Swapper + if !IsNil(o.Input) { + toSerialize["input"] = o.Input + } + if !IsNil(o.Outputs) { + toSerialize["outputs"] = o.Outputs + } + if !IsNil(o.CosignerData) { + toSerialize["cosignerData"] = o.CosignerData + } + if !IsNil(o.Cosignature) { + toSerialize["cosignature"] = o.Cosignature + } + if !IsNil(o.CreatedAt) { + toSerialize["createdAt"] = o.CreatedAt + } + if !IsNil(o.QuoteId) { + toSerialize["quoteId"] = o.QuoteId + } + if !IsNil(o.RequestId) { + toSerialize["requestId"] = o.RequestId + } + if !IsNil(o.TxHash) { + toSerialize["txHash"] = o.TxHash + } + if !IsNil(o.SettledAmounts) { + toSerialize["settledAmounts"] = o.SettledAmounts + } + if !IsNil(o.Route) { + toSerialize["route"] = o.Route + } + return toSerialize, nil +} + +func (o *DutchV2OrderEntity) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "type", + "encodedOrder", + "signature", + "orderHash", + "orderStatus", + "chainId", + "swapper", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varDutchV2OrderEntity := _DutchV2OrderEntity{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varDutchV2OrderEntity) + + if err != nil { + return err + } + + *o = DutchV2OrderEntity(varDutchV2OrderEntity) + + return err +} + +type NullableDutchV2OrderEntity struct { + value *DutchV2OrderEntity + isSet bool +} + +func (v NullableDutchV2OrderEntity) Get() *DutchV2OrderEntity { + return v.value +} + +func (v *NullableDutchV2OrderEntity) Set(val *DutchV2OrderEntity) { + v.value = val + v.isSet = true +} + +func (v NullableDutchV2OrderEntity) IsSet() bool { + return v.isSet +} + +func (v *NullableDutchV2OrderEntity) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableDutchV2OrderEntity(val *DutchV2OrderEntity) *NullableDutchV2OrderEntity { + return &NullableDutchV2OrderEntity{value: val, isSet: true} +} + +func (v NullableDutchV2OrderEntity) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableDutchV2OrderEntity) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_dutch_v2_order_entity_cosigner_data.go b/api/uniswapxservice/model_dutch_v2_order_entity_cosigner_data.go new file mode 100644 index 00000000..36da1bb1 --- /dev/null +++ b/api/uniswapxservice/model_dutch_v2_order_entity_cosigner_data.go @@ -0,0 +1,270 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" +) + +// checks if the DutchV2OrderEntityCosignerData type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &DutchV2OrderEntityCosignerData{} + +// DutchV2OrderEntityCosignerData struct for DutchV2OrderEntityCosignerData +type DutchV2OrderEntityCosignerData struct { + DecayStartTime *float32 `json:"decayStartTime,omitempty"` + DecayEndTime *float32 `json:"decayEndTime,omitempty"` + // EIP-55 checksummed Ethereum address. + ExclusiveFiller *string `json:"exclusiveFiller,omitempty"` + // uint256 encoded as a base-10 string. + InputOverride *string `json:"inputOverride,omitempty" validate:"regexp=^[0-9]{1,78}$"` + OutputOverrides []string `json:"outputOverrides,omitempty"` +} + +// NewDutchV2OrderEntityCosignerData instantiates a new DutchV2OrderEntityCosignerData object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewDutchV2OrderEntityCosignerData() *DutchV2OrderEntityCosignerData { + this := DutchV2OrderEntityCosignerData{} + return &this +} + +// NewDutchV2OrderEntityCosignerDataWithDefaults instantiates a new DutchV2OrderEntityCosignerData object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewDutchV2OrderEntityCosignerDataWithDefaults() *DutchV2OrderEntityCosignerData { + this := DutchV2OrderEntityCosignerData{} + return &this +} + +// GetDecayStartTime returns the DecayStartTime field value if set, zero value otherwise. +func (o *DutchV2OrderEntityCosignerData) GetDecayStartTime() float32 { + if o == nil || IsNil(o.DecayStartTime) { + var ret float32 + return ret + } + return *o.DecayStartTime +} + +// GetDecayStartTimeOk returns a tuple with the DecayStartTime field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntityCosignerData) GetDecayStartTimeOk() (*float32, bool) { + if o == nil || IsNil(o.DecayStartTime) { + return nil, false + } + return o.DecayStartTime, true +} + +// HasDecayStartTime returns a boolean if a field has been set. +func (o *DutchV2OrderEntityCosignerData) HasDecayStartTime() bool { + if o != nil && !IsNil(o.DecayStartTime) { + return true + } + + return false +} + +// SetDecayStartTime gets a reference to the given float32 and assigns it to the DecayStartTime field. +func (o *DutchV2OrderEntityCosignerData) SetDecayStartTime(v float32) { + o.DecayStartTime = &v +} + +// GetDecayEndTime returns the DecayEndTime field value if set, zero value otherwise. +func (o *DutchV2OrderEntityCosignerData) GetDecayEndTime() float32 { + if o == nil || IsNil(o.DecayEndTime) { + var ret float32 + return ret + } + return *o.DecayEndTime +} + +// GetDecayEndTimeOk returns a tuple with the DecayEndTime field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntityCosignerData) GetDecayEndTimeOk() (*float32, bool) { + if o == nil || IsNil(o.DecayEndTime) { + return nil, false + } + return o.DecayEndTime, true +} + +// HasDecayEndTime returns a boolean if a field has been set. +func (o *DutchV2OrderEntityCosignerData) HasDecayEndTime() bool { + if o != nil && !IsNil(o.DecayEndTime) { + return true + } + + return false +} + +// SetDecayEndTime gets a reference to the given float32 and assigns it to the DecayEndTime field. +func (o *DutchV2OrderEntityCosignerData) SetDecayEndTime(v float32) { + o.DecayEndTime = &v +} + +// GetExclusiveFiller returns the ExclusiveFiller field value if set, zero value otherwise. +func (o *DutchV2OrderEntityCosignerData) GetExclusiveFiller() string { + if o == nil || IsNil(o.ExclusiveFiller) { + var ret string + return ret + } + return *o.ExclusiveFiller +} + +// GetExclusiveFillerOk returns a tuple with the ExclusiveFiller field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntityCosignerData) GetExclusiveFillerOk() (*string, bool) { + if o == nil || IsNil(o.ExclusiveFiller) { + return nil, false + } + return o.ExclusiveFiller, true +} + +// HasExclusiveFiller returns a boolean if a field has been set. +func (o *DutchV2OrderEntityCosignerData) HasExclusiveFiller() bool { + if o != nil && !IsNil(o.ExclusiveFiller) { + return true + } + + return false +} + +// SetExclusiveFiller gets a reference to the given string and assigns it to the ExclusiveFiller field. +func (o *DutchV2OrderEntityCosignerData) SetExclusiveFiller(v string) { + o.ExclusiveFiller = &v +} + +// GetInputOverride returns the InputOverride field value if set, zero value otherwise. +func (o *DutchV2OrderEntityCosignerData) GetInputOverride() string { + if o == nil || IsNil(o.InputOverride) { + var ret string + return ret + } + return *o.InputOverride +} + +// GetInputOverrideOk returns a tuple with the InputOverride field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntityCosignerData) GetInputOverrideOk() (*string, bool) { + if o == nil || IsNil(o.InputOverride) { + return nil, false + } + return o.InputOverride, true +} + +// HasInputOverride returns a boolean if a field has been set. +func (o *DutchV2OrderEntityCosignerData) HasInputOverride() bool { + if o != nil && !IsNil(o.InputOverride) { + return true + } + + return false +} + +// SetInputOverride gets a reference to the given string and assigns it to the InputOverride field. +func (o *DutchV2OrderEntityCosignerData) SetInputOverride(v string) { + o.InputOverride = &v +} + +// GetOutputOverrides returns the OutputOverrides field value if set, zero value otherwise. +func (o *DutchV2OrderEntityCosignerData) GetOutputOverrides() []string { + if o == nil || IsNil(o.OutputOverrides) { + var ret []string + return ret + } + return o.OutputOverrides +} + +// GetOutputOverridesOk returns a tuple with the OutputOverrides field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntityCosignerData) GetOutputOverridesOk() ([]string, bool) { + if o == nil || IsNil(o.OutputOverrides) { + return nil, false + } + return o.OutputOverrides, true +} + +// HasOutputOverrides returns a boolean if a field has been set. +func (o *DutchV2OrderEntityCosignerData) HasOutputOverrides() bool { + if o != nil && !IsNil(o.OutputOverrides) { + return true + } + + return false +} + +// SetOutputOverrides gets a reference to the given []string and assigns it to the OutputOverrides field. +func (o *DutchV2OrderEntityCosignerData) SetOutputOverrides(v []string) { + o.OutputOverrides = v +} + +func (o DutchV2OrderEntityCosignerData) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o DutchV2OrderEntityCosignerData) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.DecayStartTime) { + toSerialize["decayStartTime"] = o.DecayStartTime + } + if !IsNil(o.DecayEndTime) { + toSerialize["decayEndTime"] = o.DecayEndTime + } + if !IsNil(o.ExclusiveFiller) { + toSerialize["exclusiveFiller"] = o.ExclusiveFiller + } + if !IsNil(o.InputOverride) { + toSerialize["inputOverride"] = o.InputOverride + } + if !IsNil(o.OutputOverrides) { + toSerialize["outputOverrides"] = o.OutputOverrides + } + return toSerialize, nil +} + +type NullableDutchV2OrderEntityCosignerData struct { + value *DutchV2OrderEntityCosignerData + isSet bool +} + +func (v NullableDutchV2OrderEntityCosignerData) Get() *DutchV2OrderEntityCosignerData { + return v.value +} + +func (v *NullableDutchV2OrderEntityCosignerData) Set(val *DutchV2OrderEntityCosignerData) { + v.value = val + v.isSet = true +} + +func (v NullableDutchV2OrderEntityCosignerData) IsSet() bool { + return v.isSet +} + +func (v *NullableDutchV2OrderEntityCosignerData) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableDutchV2OrderEntityCosignerData(val *DutchV2OrderEntityCosignerData) *NullableDutchV2OrderEntityCosignerData { + return &NullableDutchV2OrderEntityCosignerData{value: val, isSet: true} +} + +func (v NullableDutchV2OrderEntityCosignerData) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableDutchV2OrderEntityCosignerData) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_dutch_v2_order_entity_input.go b/api/uniswapxservice/model_dutch_v2_order_entity_input.go new file mode 100644 index 00000000..abd19a3d --- /dev/null +++ b/api/uniswapxservice/model_dutch_v2_order_entity_input.go @@ -0,0 +1,215 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the DutchV2OrderEntityInput type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &DutchV2OrderEntityInput{} + +// DutchV2OrderEntityInput struct for DutchV2OrderEntityInput +type DutchV2OrderEntityInput struct { + // EIP-55 checksummed Ethereum address. + Token string `json:"token"` + // uint256 encoded as a base-10 string. + StartAmount string `json:"startAmount" validate:"regexp=^[0-9]{1,78}$"` + // uint256 encoded as a base-10 string. + EndAmount string `json:"endAmount" validate:"regexp=^[0-9]{1,78}$"` +} + +type _DutchV2OrderEntityInput DutchV2OrderEntityInput + +// NewDutchV2OrderEntityInput instantiates a new DutchV2OrderEntityInput object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewDutchV2OrderEntityInput(token string, startAmount string, endAmount string) *DutchV2OrderEntityInput { + this := DutchV2OrderEntityInput{} + this.Token = token + this.StartAmount = startAmount + this.EndAmount = endAmount + return &this +} + +// NewDutchV2OrderEntityInputWithDefaults instantiates a new DutchV2OrderEntityInput object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewDutchV2OrderEntityInputWithDefaults() *DutchV2OrderEntityInput { + this := DutchV2OrderEntityInput{} + return &this +} + +// GetToken returns the Token field value +func (o *DutchV2OrderEntityInput) GetToken() string { + if o == nil { + var ret string + return ret + } + + return o.Token +} + +// GetTokenOk returns a tuple with the Token field value +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntityInput) GetTokenOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Token, true +} + +// SetToken sets field value +func (o *DutchV2OrderEntityInput) SetToken(v string) { + o.Token = v +} + +// GetStartAmount returns the StartAmount field value +func (o *DutchV2OrderEntityInput) GetStartAmount() string { + if o == nil { + var ret string + return ret + } + + return o.StartAmount +} + +// GetStartAmountOk returns a tuple with the StartAmount field value +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntityInput) GetStartAmountOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.StartAmount, true +} + +// SetStartAmount sets field value +func (o *DutchV2OrderEntityInput) SetStartAmount(v string) { + o.StartAmount = v +} + +// GetEndAmount returns the EndAmount field value +func (o *DutchV2OrderEntityInput) GetEndAmount() string { + if o == nil { + var ret string + return ret + } + + return o.EndAmount +} + +// GetEndAmountOk returns a tuple with the EndAmount field value +// and a boolean to check if the value has been set. +func (o *DutchV2OrderEntityInput) GetEndAmountOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.EndAmount, true +} + +// SetEndAmount sets field value +func (o *DutchV2OrderEntityInput) SetEndAmount(v string) { + o.EndAmount = v +} + +func (o DutchV2OrderEntityInput) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o DutchV2OrderEntityInput) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["token"] = o.Token + toSerialize["startAmount"] = o.StartAmount + toSerialize["endAmount"] = o.EndAmount + return toSerialize, nil +} + +func (o *DutchV2OrderEntityInput) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "token", + "startAmount", + "endAmount", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varDutchV2OrderEntityInput := _DutchV2OrderEntityInput{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varDutchV2OrderEntityInput) + + if err != nil { + return err + } + + *o = DutchV2OrderEntityInput(varDutchV2OrderEntityInput) + + return err +} + +type NullableDutchV2OrderEntityInput struct { + value *DutchV2OrderEntityInput + isSet bool +} + +func (v NullableDutchV2OrderEntityInput) Get() *DutchV2OrderEntityInput { + return v.value +} + +func (v *NullableDutchV2OrderEntityInput) Set(val *DutchV2OrderEntityInput) { + v.value = val + v.isSet = true +} + +func (v NullableDutchV2OrderEntityInput) IsSet() bool { + return v.isSet +} + +func (v *NullableDutchV2OrderEntityInput) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableDutchV2OrderEntityInput(val *DutchV2OrderEntityInput) *NullableDutchV2OrderEntityInput { + return &NullableDutchV2OrderEntityInput{value: val, isSet: true} +} + +func (v NullableDutchV2OrderEntityInput) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableDutchV2OrderEntityInput) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_dutch_v3_order_entity.go b/api/uniswapxservice/model_dutch_v3_order_entity.go new file mode 100644 index 00000000..1869df4f --- /dev/null +++ b/api/uniswapxservice/model_dutch_v3_order_entity.go @@ -0,0 +1,802 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the DutchV3OrderEntity type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &DutchV3OrderEntity{} + +// DutchV3OrderEntity Dutch V3 orders: cosigned Dutch auctions with block-based nonlinear decay, used on fast chains. +type DutchV3OrderEntity struct { + Type string `json:"type"` + // ABI-encoded order struct. Decode with the uniswapx-sdk parser for the order's type. + EncodedOrder string `json:"encodedOrder" validate:"regexp=^0x[0-9a-fA-F]*$"` + // EIP-712 signature over the order. + Signature string `json:"signature" validate:"regexp=^0x[0-9a-fA-F]{130}$"` + // Permit2 nonce, uint256 encoded as a base-10 string. + Nonce *string `json:"nonce,omitempty" validate:"regexp=^[0-9]{1,78}$"` + OrderHash string `json:"orderHash" validate:"regexp=^0x[0-9a-fA-F]{64}$"` + OrderStatus OrderStatus `json:"orderStatus"` + ChainId ChainId `json:"chainId"` + // EIP-55 checksummed Ethereum address. + Swapper string `json:"swapper"` + // uint256 encoded as a base-10 string. + StartingBaseFee *string `json:"startingBaseFee,omitempty" validate:"regexp=^[0-9]{1,78}$"` + Input *DutchV3OrderEntityInput `json:"input,omitempty"` + Outputs []DutchV3OrderEntityOutputsInner `json:"outputs,omitempty"` + CosignerData *DutchV3OrderEntityCosignerData `json:"cosignerData,omitempty"` + Cosignature *string `json:"cosignature,omitempty"` + // Block in which the order was filled. Defined once the fill has been recorded. + FillBlock *float32 `json:"fillBlock,omitempty"` + // Unix timestamp (seconds) at which the order was recorded. + CreatedAt *float32 `json:"createdAt,omitempty"` + // Defined when the order has a quote associated with it. + QuoteId *string `json:"quoteId,omitempty"` + // Defined when the order has a quote request associated with it. + RequestId *string `json:"requestId,omitempty"` + // Transaction hash of the fill. Defined once the order has been filled. + TxHash *string `json:"txHash,omitempty" validate:"regexp=^0x[0-9a-fA-F]{64}$"` + SettledAmounts []SettledAmount `json:"settledAmounts,omitempty"` + Route *Route `json:"route,omitempty"` +} + +type _DutchV3OrderEntity DutchV3OrderEntity + +// NewDutchV3OrderEntity instantiates a new DutchV3OrderEntity object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewDutchV3OrderEntity(type_ string, encodedOrder string, signature string, orderHash string, orderStatus OrderStatus, chainId ChainId, swapper string) *DutchV3OrderEntity { + this := DutchV3OrderEntity{} + this.Type = type_ + this.EncodedOrder = encodedOrder + this.Signature = signature + this.OrderHash = orderHash + this.OrderStatus = orderStatus + this.ChainId = chainId + this.Swapper = swapper + return &this +} + +// NewDutchV3OrderEntityWithDefaults instantiates a new DutchV3OrderEntity object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewDutchV3OrderEntityWithDefaults() *DutchV3OrderEntity { + this := DutchV3OrderEntity{} + return &this +} + +// GetType returns the Type field value +func (o *DutchV3OrderEntity) GetType() string { + if o == nil { + var ret string + return ret + } + + return o.Type +} + +// GetTypeOk returns a tuple with the Type field value +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Type, true +} + +// SetType sets field value +func (o *DutchV3OrderEntity) SetType(v string) { + o.Type = v +} + +// GetEncodedOrder returns the EncodedOrder field value +func (o *DutchV3OrderEntity) GetEncodedOrder() string { + if o == nil { + var ret string + return ret + } + + return o.EncodedOrder +} + +// GetEncodedOrderOk returns a tuple with the EncodedOrder field value +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetEncodedOrderOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.EncodedOrder, true +} + +// SetEncodedOrder sets field value +func (o *DutchV3OrderEntity) SetEncodedOrder(v string) { + o.EncodedOrder = v +} + +// GetSignature returns the Signature field value +func (o *DutchV3OrderEntity) GetSignature() string { + if o == nil { + var ret string + return ret + } + + return o.Signature +} + +// GetSignatureOk returns a tuple with the Signature field value +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetSignatureOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Signature, true +} + +// SetSignature sets field value +func (o *DutchV3OrderEntity) SetSignature(v string) { + o.Signature = v +} + +// GetNonce returns the Nonce field value if set, zero value otherwise. +func (o *DutchV3OrderEntity) GetNonce() string { + if o == nil || IsNil(o.Nonce) { + var ret string + return ret + } + return *o.Nonce +} + +// GetNonceOk returns a tuple with the Nonce field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetNonceOk() (*string, bool) { + if o == nil || IsNil(o.Nonce) { + return nil, false + } + return o.Nonce, true +} + +// HasNonce returns a boolean if a field has been set. +func (o *DutchV3OrderEntity) HasNonce() bool { + if o != nil && !IsNil(o.Nonce) { + return true + } + + return false +} + +// SetNonce gets a reference to the given string and assigns it to the Nonce field. +func (o *DutchV3OrderEntity) SetNonce(v string) { + o.Nonce = &v +} + +// GetOrderHash returns the OrderHash field value +func (o *DutchV3OrderEntity) GetOrderHash() string { + if o == nil { + var ret string + return ret + } + + return o.OrderHash +} + +// GetOrderHashOk returns a tuple with the OrderHash field value +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetOrderHashOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.OrderHash, true +} + +// SetOrderHash sets field value +func (o *DutchV3OrderEntity) SetOrderHash(v string) { + o.OrderHash = v +} + +// GetOrderStatus returns the OrderStatus field value +func (o *DutchV3OrderEntity) GetOrderStatus() OrderStatus { + if o == nil { + var ret OrderStatus + return ret + } + + return o.OrderStatus +} + +// GetOrderStatusOk returns a tuple with the OrderStatus field value +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetOrderStatusOk() (*OrderStatus, bool) { + if o == nil { + return nil, false + } + return &o.OrderStatus, true +} + +// SetOrderStatus sets field value +func (o *DutchV3OrderEntity) SetOrderStatus(v OrderStatus) { + o.OrderStatus = v +} + +// GetChainId returns the ChainId field value +func (o *DutchV3OrderEntity) GetChainId() ChainId { + if o == nil { + var ret ChainId + return ret + } + + return o.ChainId +} + +// GetChainIdOk returns a tuple with the ChainId field value +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetChainIdOk() (*ChainId, bool) { + if o == nil { + return nil, false + } + return &o.ChainId, true +} + +// SetChainId sets field value +func (o *DutchV3OrderEntity) SetChainId(v ChainId) { + o.ChainId = v +} + +// GetSwapper returns the Swapper field value +func (o *DutchV3OrderEntity) GetSwapper() string { + if o == nil { + var ret string + return ret + } + + return o.Swapper +} + +// GetSwapperOk returns a tuple with the Swapper field value +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetSwapperOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Swapper, true +} + +// SetSwapper sets field value +func (o *DutchV3OrderEntity) SetSwapper(v string) { + o.Swapper = v +} + +// GetStartingBaseFee returns the StartingBaseFee field value if set, zero value otherwise. +func (o *DutchV3OrderEntity) GetStartingBaseFee() string { + if o == nil || IsNil(o.StartingBaseFee) { + var ret string + return ret + } + return *o.StartingBaseFee +} + +// GetStartingBaseFeeOk returns a tuple with the StartingBaseFee field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetStartingBaseFeeOk() (*string, bool) { + if o == nil || IsNil(o.StartingBaseFee) { + return nil, false + } + return o.StartingBaseFee, true +} + +// HasStartingBaseFee returns a boolean if a field has been set. +func (o *DutchV3OrderEntity) HasStartingBaseFee() bool { + if o != nil && !IsNil(o.StartingBaseFee) { + return true + } + + return false +} + +// SetStartingBaseFee gets a reference to the given string and assigns it to the StartingBaseFee field. +func (o *DutchV3OrderEntity) SetStartingBaseFee(v string) { + o.StartingBaseFee = &v +} + +// GetInput returns the Input field value if set, zero value otherwise. +func (o *DutchV3OrderEntity) GetInput() DutchV3OrderEntityInput { + if o == nil || IsNil(o.Input) { + var ret DutchV3OrderEntityInput + return ret + } + return *o.Input +} + +// GetInputOk returns a tuple with the Input field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetInputOk() (*DutchV3OrderEntityInput, bool) { + if o == nil || IsNil(o.Input) { + return nil, false + } + return o.Input, true +} + +// HasInput returns a boolean if a field has been set. +func (o *DutchV3OrderEntity) HasInput() bool { + if o != nil && !IsNil(o.Input) { + return true + } + + return false +} + +// SetInput gets a reference to the given DutchV3OrderEntityInput and assigns it to the Input field. +func (o *DutchV3OrderEntity) SetInput(v DutchV3OrderEntityInput) { + o.Input = &v +} + +// GetOutputs returns the Outputs field value if set, zero value otherwise. +func (o *DutchV3OrderEntity) GetOutputs() []DutchV3OrderEntityOutputsInner { + if o == nil || IsNil(o.Outputs) { + var ret []DutchV3OrderEntityOutputsInner + return ret + } + return o.Outputs +} + +// GetOutputsOk returns a tuple with the Outputs field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetOutputsOk() ([]DutchV3OrderEntityOutputsInner, bool) { + if o == nil || IsNil(o.Outputs) { + return nil, false + } + return o.Outputs, true +} + +// HasOutputs returns a boolean if a field has been set. +func (o *DutchV3OrderEntity) HasOutputs() bool { + if o != nil && !IsNil(o.Outputs) { + return true + } + + return false +} + +// SetOutputs gets a reference to the given []DutchV3OrderEntityOutputsInner and assigns it to the Outputs field. +func (o *DutchV3OrderEntity) SetOutputs(v []DutchV3OrderEntityOutputsInner) { + o.Outputs = v +} + +// GetCosignerData returns the CosignerData field value if set, zero value otherwise. +func (o *DutchV3OrderEntity) GetCosignerData() DutchV3OrderEntityCosignerData { + if o == nil || IsNil(o.CosignerData) { + var ret DutchV3OrderEntityCosignerData + return ret + } + return *o.CosignerData +} + +// GetCosignerDataOk returns a tuple with the CosignerData field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetCosignerDataOk() (*DutchV3OrderEntityCosignerData, bool) { + if o == nil || IsNil(o.CosignerData) { + return nil, false + } + return o.CosignerData, true +} + +// HasCosignerData returns a boolean if a field has been set. +func (o *DutchV3OrderEntity) HasCosignerData() bool { + if o != nil && !IsNil(o.CosignerData) { + return true + } + + return false +} + +// SetCosignerData gets a reference to the given DutchV3OrderEntityCosignerData and assigns it to the CosignerData field. +func (o *DutchV3OrderEntity) SetCosignerData(v DutchV3OrderEntityCosignerData) { + o.CosignerData = &v +} + +// GetCosignature returns the Cosignature field value if set, zero value otherwise. +func (o *DutchV3OrderEntity) GetCosignature() string { + if o == nil || IsNil(o.Cosignature) { + var ret string + return ret + } + return *o.Cosignature +} + +// GetCosignatureOk returns a tuple with the Cosignature field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetCosignatureOk() (*string, bool) { + if o == nil || IsNil(o.Cosignature) { + return nil, false + } + return o.Cosignature, true +} + +// HasCosignature returns a boolean if a field has been set. +func (o *DutchV3OrderEntity) HasCosignature() bool { + if o != nil && !IsNil(o.Cosignature) { + return true + } + + return false +} + +// SetCosignature gets a reference to the given string and assigns it to the Cosignature field. +func (o *DutchV3OrderEntity) SetCosignature(v string) { + o.Cosignature = &v +} + +// GetFillBlock returns the FillBlock field value if set, zero value otherwise. +func (o *DutchV3OrderEntity) GetFillBlock() float32 { + if o == nil || IsNil(o.FillBlock) { + var ret float32 + return ret + } + return *o.FillBlock +} + +// GetFillBlockOk returns a tuple with the FillBlock field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetFillBlockOk() (*float32, bool) { + if o == nil || IsNil(o.FillBlock) { + return nil, false + } + return o.FillBlock, true +} + +// HasFillBlock returns a boolean if a field has been set. +func (o *DutchV3OrderEntity) HasFillBlock() bool { + if o != nil && !IsNil(o.FillBlock) { + return true + } + + return false +} + +// SetFillBlock gets a reference to the given float32 and assigns it to the FillBlock field. +func (o *DutchV3OrderEntity) SetFillBlock(v float32) { + o.FillBlock = &v +} + +// GetCreatedAt returns the CreatedAt field value if set, zero value otherwise. +func (o *DutchV3OrderEntity) GetCreatedAt() float32 { + if o == nil || IsNil(o.CreatedAt) { + var ret float32 + return ret + } + return *o.CreatedAt +} + +// GetCreatedAtOk returns a tuple with the CreatedAt field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetCreatedAtOk() (*float32, bool) { + if o == nil || IsNil(o.CreatedAt) { + return nil, false + } + return o.CreatedAt, true +} + +// HasCreatedAt returns a boolean if a field has been set. +func (o *DutchV3OrderEntity) HasCreatedAt() bool { + if o != nil && !IsNil(o.CreatedAt) { + return true + } + + return false +} + +// SetCreatedAt gets a reference to the given float32 and assigns it to the CreatedAt field. +func (o *DutchV3OrderEntity) SetCreatedAt(v float32) { + o.CreatedAt = &v +} + +// GetQuoteId returns the QuoteId field value if set, zero value otherwise. +func (o *DutchV3OrderEntity) GetQuoteId() string { + if o == nil || IsNil(o.QuoteId) { + var ret string + return ret + } + return *o.QuoteId +} + +// GetQuoteIdOk returns a tuple with the QuoteId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetQuoteIdOk() (*string, bool) { + if o == nil || IsNil(o.QuoteId) { + return nil, false + } + return o.QuoteId, true +} + +// HasQuoteId returns a boolean if a field has been set. +func (o *DutchV3OrderEntity) HasQuoteId() bool { + if o != nil && !IsNil(o.QuoteId) { + return true + } + + return false +} + +// SetQuoteId gets a reference to the given string and assigns it to the QuoteId field. +func (o *DutchV3OrderEntity) SetQuoteId(v string) { + o.QuoteId = &v +} + +// GetRequestId returns the RequestId field value if set, zero value otherwise. +func (o *DutchV3OrderEntity) GetRequestId() string { + if o == nil || IsNil(o.RequestId) { + var ret string + return ret + } + return *o.RequestId +} + +// GetRequestIdOk returns a tuple with the RequestId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetRequestIdOk() (*string, bool) { + if o == nil || IsNil(o.RequestId) { + return nil, false + } + return o.RequestId, true +} + +// HasRequestId returns a boolean if a field has been set. +func (o *DutchV3OrderEntity) HasRequestId() bool { + if o != nil && !IsNil(o.RequestId) { + return true + } + + return false +} + +// SetRequestId gets a reference to the given string and assigns it to the RequestId field. +func (o *DutchV3OrderEntity) SetRequestId(v string) { + o.RequestId = &v +} + +// GetTxHash returns the TxHash field value if set, zero value otherwise. +func (o *DutchV3OrderEntity) GetTxHash() string { + if o == nil || IsNil(o.TxHash) { + var ret string + return ret + } + return *o.TxHash +} + +// GetTxHashOk returns a tuple with the TxHash field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetTxHashOk() (*string, bool) { + if o == nil || IsNil(o.TxHash) { + return nil, false + } + return o.TxHash, true +} + +// HasTxHash returns a boolean if a field has been set. +func (o *DutchV3OrderEntity) HasTxHash() bool { + if o != nil && !IsNil(o.TxHash) { + return true + } + + return false +} + +// SetTxHash gets a reference to the given string and assigns it to the TxHash field. +func (o *DutchV3OrderEntity) SetTxHash(v string) { + o.TxHash = &v +} + +// GetSettledAmounts returns the SettledAmounts field value if set, zero value otherwise. +func (o *DutchV3OrderEntity) GetSettledAmounts() []SettledAmount { + if o == nil || IsNil(o.SettledAmounts) { + var ret []SettledAmount + return ret + } + return o.SettledAmounts +} + +// GetSettledAmountsOk returns a tuple with the SettledAmounts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetSettledAmountsOk() ([]SettledAmount, bool) { + if o == nil || IsNil(o.SettledAmounts) { + return nil, false + } + return o.SettledAmounts, true +} + +// HasSettledAmounts returns a boolean if a field has been set. +func (o *DutchV3OrderEntity) HasSettledAmounts() bool { + if o != nil && !IsNil(o.SettledAmounts) { + return true + } + + return false +} + +// SetSettledAmounts gets a reference to the given []SettledAmount and assigns it to the SettledAmounts field. +func (o *DutchV3OrderEntity) SetSettledAmounts(v []SettledAmount) { + o.SettledAmounts = v +} + +// GetRoute returns the Route field value if set, zero value otherwise. +func (o *DutchV3OrderEntity) GetRoute() Route { + if o == nil || IsNil(o.Route) { + var ret Route + return ret + } + return *o.Route +} + +// GetRouteOk returns a tuple with the Route field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntity) GetRouteOk() (*Route, bool) { + if o == nil || IsNil(o.Route) { + return nil, false + } + return o.Route, true +} + +// HasRoute returns a boolean if a field has been set. +func (o *DutchV3OrderEntity) HasRoute() bool { + if o != nil && !IsNil(o.Route) { + return true + } + + return false +} + +// SetRoute gets a reference to the given Route and assigns it to the Route field. +func (o *DutchV3OrderEntity) SetRoute(v Route) { + o.Route = &v +} + +func (o DutchV3OrderEntity) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o DutchV3OrderEntity) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["type"] = o.Type + toSerialize["encodedOrder"] = o.EncodedOrder + toSerialize["signature"] = o.Signature + if !IsNil(o.Nonce) { + toSerialize["nonce"] = o.Nonce + } + toSerialize["orderHash"] = o.OrderHash + toSerialize["orderStatus"] = o.OrderStatus + toSerialize["chainId"] = o.ChainId + toSerialize["swapper"] = o.Swapper + if !IsNil(o.StartingBaseFee) { + toSerialize["startingBaseFee"] = o.StartingBaseFee + } + if !IsNil(o.Input) { + toSerialize["input"] = o.Input + } + if !IsNil(o.Outputs) { + toSerialize["outputs"] = o.Outputs + } + if !IsNil(o.CosignerData) { + toSerialize["cosignerData"] = o.CosignerData + } + if !IsNil(o.Cosignature) { + toSerialize["cosignature"] = o.Cosignature + } + if !IsNil(o.FillBlock) { + toSerialize["fillBlock"] = o.FillBlock + } + if !IsNil(o.CreatedAt) { + toSerialize["createdAt"] = o.CreatedAt + } + if !IsNil(o.QuoteId) { + toSerialize["quoteId"] = o.QuoteId + } + if !IsNil(o.RequestId) { + toSerialize["requestId"] = o.RequestId + } + if !IsNil(o.TxHash) { + toSerialize["txHash"] = o.TxHash + } + if !IsNil(o.SettledAmounts) { + toSerialize["settledAmounts"] = o.SettledAmounts + } + if !IsNil(o.Route) { + toSerialize["route"] = o.Route + } + return toSerialize, nil +} + +func (o *DutchV3OrderEntity) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "type", + "encodedOrder", + "signature", + "orderHash", + "orderStatus", + "chainId", + "swapper", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varDutchV3OrderEntity := _DutchV3OrderEntity{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varDutchV3OrderEntity) + + if err != nil { + return err + } + + *o = DutchV3OrderEntity(varDutchV3OrderEntity) + + return err +} + +type NullableDutchV3OrderEntity struct { + value *DutchV3OrderEntity + isSet bool +} + +func (v NullableDutchV3OrderEntity) Get() *DutchV3OrderEntity { + return v.value +} + +func (v *NullableDutchV3OrderEntity) Set(val *DutchV3OrderEntity) { + v.value = val + v.isSet = true +} + +func (v NullableDutchV3OrderEntity) IsSet() bool { + return v.isSet +} + +func (v *NullableDutchV3OrderEntity) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableDutchV3OrderEntity(val *DutchV3OrderEntity) *NullableDutchV3OrderEntity { + return &NullableDutchV3OrderEntity{value: val, isSet: true} +} + +func (v NullableDutchV3OrderEntity) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableDutchV3OrderEntity) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_dutch_v3_order_entity_cosigner_data.go b/api/uniswapxservice/model_dutch_v3_order_entity_cosigner_data.go new file mode 100644 index 00000000..a9c21aea --- /dev/null +++ b/api/uniswapxservice/model_dutch_v3_order_entity_cosigner_data.go @@ -0,0 +1,234 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" +) + +// checks if the DutchV3OrderEntityCosignerData type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &DutchV3OrderEntityCosignerData{} + +// DutchV3OrderEntityCosignerData struct for DutchV3OrderEntityCosignerData +type DutchV3OrderEntityCosignerData struct { + DecayStartBlock *float32 `json:"decayStartBlock,omitempty"` + // EIP-55 checksummed Ethereum address. + ExclusiveFiller *string `json:"exclusiveFiller,omitempty"` + // uint256 encoded as a base-10 string. + InputOverride *string `json:"inputOverride,omitempty" validate:"regexp=^[0-9]{1,78}$"` + OutputOverrides []string `json:"outputOverrides,omitempty"` +} + +// NewDutchV3OrderEntityCosignerData instantiates a new DutchV3OrderEntityCosignerData object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewDutchV3OrderEntityCosignerData() *DutchV3OrderEntityCosignerData { + this := DutchV3OrderEntityCosignerData{} + return &this +} + +// NewDutchV3OrderEntityCosignerDataWithDefaults instantiates a new DutchV3OrderEntityCosignerData object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewDutchV3OrderEntityCosignerDataWithDefaults() *DutchV3OrderEntityCosignerData { + this := DutchV3OrderEntityCosignerData{} + return &this +} + +// GetDecayStartBlock returns the DecayStartBlock field value if set, zero value otherwise. +func (o *DutchV3OrderEntityCosignerData) GetDecayStartBlock() float32 { + if o == nil || IsNil(o.DecayStartBlock) { + var ret float32 + return ret + } + return *o.DecayStartBlock +} + +// GetDecayStartBlockOk returns a tuple with the DecayStartBlock field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityCosignerData) GetDecayStartBlockOk() (*float32, bool) { + if o == nil || IsNil(o.DecayStartBlock) { + return nil, false + } + return o.DecayStartBlock, true +} + +// HasDecayStartBlock returns a boolean if a field has been set. +func (o *DutchV3OrderEntityCosignerData) HasDecayStartBlock() bool { + if o != nil && !IsNil(o.DecayStartBlock) { + return true + } + + return false +} + +// SetDecayStartBlock gets a reference to the given float32 and assigns it to the DecayStartBlock field. +func (o *DutchV3OrderEntityCosignerData) SetDecayStartBlock(v float32) { + o.DecayStartBlock = &v +} + +// GetExclusiveFiller returns the ExclusiveFiller field value if set, zero value otherwise. +func (o *DutchV3OrderEntityCosignerData) GetExclusiveFiller() string { + if o == nil || IsNil(o.ExclusiveFiller) { + var ret string + return ret + } + return *o.ExclusiveFiller +} + +// GetExclusiveFillerOk returns a tuple with the ExclusiveFiller field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityCosignerData) GetExclusiveFillerOk() (*string, bool) { + if o == nil || IsNil(o.ExclusiveFiller) { + return nil, false + } + return o.ExclusiveFiller, true +} + +// HasExclusiveFiller returns a boolean if a field has been set. +func (o *DutchV3OrderEntityCosignerData) HasExclusiveFiller() bool { + if o != nil && !IsNil(o.ExclusiveFiller) { + return true + } + + return false +} + +// SetExclusiveFiller gets a reference to the given string and assigns it to the ExclusiveFiller field. +func (o *DutchV3OrderEntityCosignerData) SetExclusiveFiller(v string) { + o.ExclusiveFiller = &v +} + +// GetInputOverride returns the InputOverride field value if set, zero value otherwise. +func (o *DutchV3OrderEntityCosignerData) GetInputOverride() string { + if o == nil || IsNil(o.InputOverride) { + var ret string + return ret + } + return *o.InputOverride +} + +// GetInputOverrideOk returns a tuple with the InputOverride field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityCosignerData) GetInputOverrideOk() (*string, bool) { + if o == nil || IsNil(o.InputOverride) { + return nil, false + } + return o.InputOverride, true +} + +// HasInputOverride returns a boolean if a field has been set. +func (o *DutchV3OrderEntityCosignerData) HasInputOverride() bool { + if o != nil && !IsNil(o.InputOverride) { + return true + } + + return false +} + +// SetInputOverride gets a reference to the given string and assigns it to the InputOverride field. +func (o *DutchV3OrderEntityCosignerData) SetInputOverride(v string) { + o.InputOverride = &v +} + +// GetOutputOverrides returns the OutputOverrides field value if set, zero value otherwise. +func (o *DutchV3OrderEntityCosignerData) GetOutputOverrides() []string { + if o == nil || IsNil(o.OutputOverrides) { + var ret []string + return ret + } + return o.OutputOverrides +} + +// GetOutputOverridesOk returns a tuple with the OutputOverrides field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityCosignerData) GetOutputOverridesOk() ([]string, bool) { + if o == nil || IsNil(o.OutputOverrides) { + return nil, false + } + return o.OutputOverrides, true +} + +// HasOutputOverrides returns a boolean if a field has been set. +func (o *DutchV3OrderEntityCosignerData) HasOutputOverrides() bool { + if o != nil && !IsNil(o.OutputOverrides) { + return true + } + + return false +} + +// SetOutputOverrides gets a reference to the given []string and assigns it to the OutputOverrides field. +func (o *DutchV3OrderEntityCosignerData) SetOutputOverrides(v []string) { + o.OutputOverrides = v +} + +func (o DutchV3OrderEntityCosignerData) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o DutchV3OrderEntityCosignerData) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.DecayStartBlock) { + toSerialize["decayStartBlock"] = o.DecayStartBlock + } + if !IsNil(o.ExclusiveFiller) { + toSerialize["exclusiveFiller"] = o.ExclusiveFiller + } + if !IsNil(o.InputOverride) { + toSerialize["inputOverride"] = o.InputOverride + } + if !IsNil(o.OutputOverrides) { + toSerialize["outputOverrides"] = o.OutputOverrides + } + return toSerialize, nil +} + +type NullableDutchV3OrderEntityCosignerData struct { + value *DutchV3OrderEntityCosignerData + isSet bool +} + +func (v NullableDutchV3OrderEntityCosignerData) Get() *DutchV3OrderEntityCosignerData { + return v.value +} + +func (v *NullableDutchV3OrderEntityCosignerData) Set(val *DutchV3OrderEntityCosignerData) { + v.value = val + v.isSet = true +} + +func (v NullableDutchV3OrderEntityCosignerData) IsSet() bool { + return v.isSet +} + +func (v *NullableDutchV3OrderEntityCosignerData) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableDutchV3OrderEntityCosignerData(val *DutchV3OrderEntityCosignerData) *NullableDutchV3OrderEntityCosignerData { + return &NullableDutchV3OrderEntityCosignerData{value: val, isSet: true} +} + +func (v NullableDutchV3OrderEntityCosignerData) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableDutchV3OrderEntityCosignerData) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_dutch_v3_order_entity_input.go b/api/uniswapxservice/model_dutch_v3_order_entity_input.go new file mode 100644 index 00000000..0a0aa3f9 --- /dev/null +++ b/api/uniswapxservice/model_dutch_v3_order_entity_input.go @@ -0,0 +1,296 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the DutchV3OrderEntityInput type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &DutchV3OrderEntityInput{} + +// DutchV3OrderEntityInput struct for DutchV3OrderEntityInput +type DutchV3OrderEntityInput struct { + // EIP-55 checksummed Ethereum address. + Token string `json:"token"` + // uint256 encoded as a base-10 string. + StartAmount string `json:"startAmount" validate:"regexp=^[0-9]{1,78}$"` + Curve *NonlinearDutchDecayCurve `json:"curve,omitempty"` + // uint256 encoded as a base-10 string. + MaxAmount *string `json:"maxAmount,omitempty" validate:"regexp=^[0-9]{1,78}$"` + // uint256 encoded as a base-10 string. + AdjustmentPerGweiBaseFee *string `json:"adjustmentPerGweiBaseFee,omitempty" validate:"regexp=^[0-9]{1,78}$"` +} + +type _DutchV3OrderEntityInput DutchV3OrderEntityInput + +// NewDutchV3OrderEntityInput instantiates a new DutchV3OrderEntityInput object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewDutchV3OrderEntityInput(token string, startAmount string) *DutchV3OrderEntityInput { + this := DutchV3OrderEntityInput{} + this.Token = token + this.StartAmount = startAmount + return &this +} + +// NewDutchV3OrderEntityInputWithDefaults instantiates a new DutchV3OrderEntityInput object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewDutchV3OrderEntityInputWithDefaults() *DutchV3OrderEntityInput { + this := DutchV3OrderEntityInput{} + return &this +} + +// GetToken returns the Token field value +func (o *DutchV3OrderEntityInput) GetToken() string { + if o == nil { + var ret string + return ret + } + + return o.Token +} + +// GetTokenOk returns a tuple with the Token field value +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityInput) GetTokenOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Token, true +} + +// SetToken sets field value +func (o *DutchV3OrderEntityInput) SetToken(v string) { + o.Token = v +} + +// GetStartAmount returns the StartAmount field value +func (o *DutchV3OrderEntityInput) GetStartAmount() string { + if o == nil { + var ret string + return ret + } + + return o.StartAmount +} + +// GetStartAmountOk returns a tuple with the StartAmount field value +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityInput) GetStartAmountOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.StartAmount, true +} + +// SetStartAmount sets field value +func (o *DutchV3OrderEntityInput) SetStartAmount(v string) { + o.StartAmount = v +} + +// GetCurve returns the Curve field value if set, zero value otherwise. +func (o *DutchV3OrderEntityInput) GetCurve() NonlinearDutchDecayCurve { + if o == nil || IsNil(o.Curve) { + var ret NonlinearDutchDecayCurve + return ret + } + return *o.Curve +} + +// GetCurveOk returns a tuple with the Curve field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityInput) GetCurveOk() (*NonlinearDutchDecayCurve, bool) { + if o == nil || IsNil(o.Curve) { + return nil, false + } + return o.Curve, true +} + +// HasCurve returns a boolean if a field has been set. +func (o *DutchV3OrderEntityInput) HasCurve() bool { + if o != nil && !IsNil(o.Curve) { + return true + } + + return false +} + +// SetCurve gets a reference to the given NonlinearDutchDecayCurve and assigns it to the Curve field. +func (o *DutchV3OrderEntityInput) SetCurve(v NonlinearDutchDecayCurve) { + o.Curve = &v +} + +// GetMaxAmount returns the MaxAmount field value if set, zero value otherwise. +func (o *DutchV3OrderEntityInput) GetMaxAmount() string { + if o == nil || IsNil(o.MaxAmount) { + var ret string + return ret + } + return *o.MaxAmount +} + +// GetMaxAmountOk returns a tuple with the MaxAmount field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityInput) GetMaxAmountOk() (*string, bool) { + if o == nil || IsNil(o.MaxAmount) { + return nil, false + } + return o.MaxAmount, true +} + +// HasMaxAmount returns a boolean if a field has been set. +func (o *DutchV3OrderEntityInput) HasMaxAmount() bool { + if o != nil && !IsNil(o.MaxAmount) { + return true + } + + return false +} + +// SetMaxAmount gets a reference to the given string and assigns it to the MaxAmount field. +func (o *DutchV3OrderEntityInput) SetMaxAmount(v string) { + o.MaxAmount = &v +} + +// GetAdjustmentPerGweiBaseFee returns the AdjustmentPerGweiBaseFee field value if set, zero value otherwise. +func (o *DutchV3OrderEntityInput) GetAdjustmentPerGweiBaseFee() string { + if o == nil || IsNil(o.AdjustmentPerGweiBaseFee) { + var ret string + return ret + } + return *o.AdjustmentPerGweiBaseFee +} + +// GetAdjustmentPerGweiBaseFeeOk returns a tuple with the AdjustmentPerGweiBaseFee field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityInput) GetAdjustmentPerGweiBaseFeeOk() (*string, bool) { + if o == nil || IsNil(o.AdjustmentPerGweiBaseFee) { + return nil, false + } + return o.AdjustmentPerGweiBaseFee, true +} + +// HasAdjustmentPerGweiBaseFee returns a boolean if a field has been set. +func (o *DutchV3OrderEntityInput) HasAdjustmentPerGweiBaseFee() bool { + if o != nil && !IsNil(o.AdjustmentPerGweiBaseFee) { + return true + } + + return false +} + +// SetAdjustmentPerGweiBaseFee gets a reference to the given string and assigns it to the AdjustmentPerGweiBaseFee field. +func (o *DutchV3OrderEntityInput) SetAdjustmentPerGweiBaseFee(v string) { + o.AdjustmentPerGweiBaseFee = &v +} + +func (o DutchV3OrderEntityInput) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o DutchV3OrderEntityInput) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["token"] = o.Token + toSerialize["startAmount"] = o.StartAmount + if !IsNil(o.Curve) { + toSerialize["curve"] = o.Curve + } + if !IsNil(o.MaxAmount) { + toSerialize["maxAmount"] = o.MaxAmount + } + if !IsNil(o.AdjustmentPerGweiBaseFee) { + toSerialize["adjustmentPerGweiBaseFee"] = o.AdjustmentPerGweiBaseFee + } + return toSerialize, nil +} + +func (o *DutchV3OrderEntityInput) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "token", + "startAmount", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varDutchV3OrderEntityInput := _DutchV3OrderEntityInput{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varDutchV3OrderEntityInput) + + if err != nil { + return err + } + + *o = DutchV3OrderEntityInput(varDutchV3OrderEntityInput) + + return err +} + +type NullableDutchV3OrderEntityInput struct { + value *DutchV3OrderEntityInput + isSet bool +} + +func (v NullableDutchV3OrderEntityInput) Get() *DutchV3OrderEntityInput { + return v.value +} + +func (v *NullableDutchV3OrderEntityInput) Set(val *DutchV3OrderEntityInput) { + v.value = val + v.isSet = true +} + +func (v NullableDutchV3OrderEntityInput) IsSet() bool { + return v.isSet +} + +func (v *NullableDutchV3OrderEntityInput) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableDutchV3OrderEntityInput(val *DutchV3OrderEntityInput) *NullableDutchV3OrderEntityInput { + return &NullableDutchV3OrderEntityInput{value: val, isSet: true} +} + +func (v NullableDutchV3OrderEntityInput) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableDutchV3OrderEntityInput) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_dutch_v3_order_entity_outputs_inner.go b/api/uniswapxservice/model_dutch_v3_order_entity_outputs_inner.go new file mode 100644 index 00000000..5e720241 --- /dev/null +++ b/api/uniswapxservice/model_dutch_v3_order_entity_outputs_inner.go @@ -0,0 +1,325 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the DutchV3OrderEntityOutputsInner type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &DutchV3OrderEntityOutputsInner{} + +// DutchV3OrderEntityOutputsInner struct for DutchV3OrderEntityOutputsInner +type DutchV3OrderEntityOutputsInner struct { + // EIP-55 checksummed Ethereum address. + Token string `json:"token"` + // uint256 encoded as a base-10 string. + StartAmount string `json:"startAmount" validate:"regexp=^[0-9]{1,78}$"` + Curve *NonlinearDutchDecayCurve `json:"curve,omitempty"` + // EIP-55 checksummed Ethereum address. + Recipient string `json:"recipient"` + // uint256 encoded as a base-10 string. + MinAmount *string `json:"minAmount,omitempty" validate:"regexp=^[0-9]{1,78}$"` + // uint256 encoded as a base-10 string. + AdjustmentPerGweiBaseFee *string `json:"adjustmentPerGweiBaseFee,omitempty" validate:"regexp=^[0-9]{1,78}$"` +} + +type _DutchV3OrderEntityOutputsInner DutchV3OrderEntityOutputsInner + +// NewDutchV3OrderEntityOutputsInner instantiates a new DutchV3OrderEntityOutputsInner object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewDutchV3OrderEntityOutputsInner(token string, startAmount string, recipient string) *DutchV3OrderEntityOutputsInner { + this := DutchV3OrderEntityOutputsInner{} + this.Token = token + this.StartAmount = startAmount + this.Recipient = recipient + return &this +} + +// NewDutchV3OrderEntityOutputsInnerWithDefaults instantiates a new DutchV3OrderEntityOutputsInner object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewDutchV3OrderEntityOutputsInnerWithDefaults() *DutchV3OrderEntityOutputsInner { + this := DutchV3OrderEntityOutputsInner{} + return &this +} + +// GetToken returns the Token field value +func (o *DutchV3OrderEntityOutputsInner) GetToken() string { + if o == nil { + var ret string + return ret + } + + return o.Token +} + +// GetTokenOk returns a tuple with the Token field value +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityOutputsInner) GetTokenOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Token, true +} + +// SetToken sets field value +func (o *DutchV3OrderEntityOutputsInner) SetToken(v string) { + o.Token = v +} + +// GetStartAmount returns the StartAmount field value +func (o *DutchV3OrderEntityOutputsInner) GetStartAmount() string { + if o == nil { + var ret string + return ret + } + + return o.StartAmount +} + +// GetStartAmountOk returns a tuple with the StartAmount field value +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityOutputsInner) GetStartAmountOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.StartAmount, true +} + +// SetStartAmount sets field value +func (o *DutchV3OrderEntityOutputsInner) SetStartAmount(v string) { + o.StartAmount = v +} + +// GetCurve returns the Curve field value if set, zero value otherwise. +func (o *DutchV3OrderEntityOutputsInner) GetCurve() NonlinearDutchDecayCurve { + if o == nil || IsNil(o.Curve) { + var ret NonlinearDutchDecayCurve + return ret + } + return *o.Curve +} + +// GetCurveOk returns a tuple with the Curve field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityOutputsInner) GetCurveOk() (*NonlinearDutchDecayCurve, bool) { + if o == nil || IsNil(o.Curve) { + return nil, false + } + return o.Curve, true +} + +// HasCurve returns a boolean if a field has been set. +func (o *DutchV3OrderEntityOutputsInner) HasCurve() bool { + if o != nil && !IsNil(o.Curve) { + return true + } + + return false +} + +// SetCurve gets a reference to the given NonlinearDutchDecayCurve and assigns it to the Curve field. +func (o *DutchV3OrderEntityOutputsInner) SetCurve(v NonlinearDutchDecayCurve) { + o.Curve = &v +} + +// GetRecipient returns the Recipient field value +func (o *DutchV3OrderEntityOutputsInner) GetRecipient() string { + if o == nil { + var ret string + return ret + } + + return o.Recipient +} + +// GetRecipientOk returns a tuple with the Recipient field value +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityOutputsInner) GetRecipientOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Recipient, true +} + +// SetRecipient sets field value +func (o *DutchV3OrderEntityOutputsInner) SetRecipient(v string) { + o.Recipient = v +} + +// GetMinAmount returns the MinAmount field value if set, zero value otherwise. +func (o *DutchV3OrderEntityOutputsInner) GetMinAmount() string { + if o == nil || IsNil(o.MinAmount) { + var ret string + return ret + } + return *o.MinAmount +} + +// GetMinAmountOk returns a tuple with the MinAmount field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityOutputsInner) GetMinAmountOk() (*string, bool) { + if o == nil || IsNil(o.MinAmount) { + return nil, false + } + return o.MinAmount, true +} + +// HasMinAmount returns a boolean if a field has been set. +func (o *DutchV3OrderEntityOutputsInner) HasMinAmount() bool { + if o != nil && !IsNil(o.MinAmount) { + return true + } + + return false +} + +// SetMinAmount gets a reference to the given string and assigns it to the MinAmount field. +func (o *DutchV3OrderEntityOutputsInner) SetMinAmount(v string) { + o.MinAmount = &v +} + +// GetAdjustmentPerGweiBaseFee returns the AdjustmentPerGweiBaseFee field value if set, zero value otherwise. +func (o *DutchV3OrderEntityOutputsInner) GetAdjustmentPerGweiBaseFee() string { + if o == nil || IsNil(o.AdjustmentPerGweiBaseFee) { + var ret string + return ret + } + return *o.AdjustmentPerGweiBaseFee +} + +// GetAdjustmentPerGweiBaseFeeOk returns a tuple with the AdjustmentPerGweiBaseFee field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DutchV3OrderEntityOutputsInner) GetAdjustmentPerGweiBaseFeeOk() (*string, bool) { + if o == nil || IsNil(o.AdjustmentPerGweiBaseFee) { + return nil, false + } + return o.AdjustmentPerGweiBaseFee, true +} + +// HasAdjustmentPerGweiBaseFee returns a boolean if a field has been set. +func (o *DutchV3OrderEntityOutputsInner) HasAdjustmentPerGweiBaseFee() bool { + if o != nil && !IsNil(o.AdjustmentPerGweiBaseFee) { + return true + } + + return false +} + +// SetAdjustmentPerGweiBaseFee gets a reference to the given string and assigns it to the AdjustmentPerGweiBaseFee field. +func (o *DutchV3OrderEntityOutputsInner) SetAdjustmentPerGweiBaseFee(v string) { + o.AdjustmentPerGweiBaseFee = &v +} + +func (o DutchV3OrderEntityOutputsInner) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o DutchV3OrderEntityOutputsInner) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["token"] = o.Token + toSerialize["startAmount"] = o.StartAmount + if !IsNil(o.Curve) { + toSerialize["curve"] = o.Curve + } + toSerialize["recipient"] = o.Recipient + if !IsNil(o.MinAmount) { + toSerialize["minAmount"] = o.MinAmount + } + if !IsNil(o.AdjustmentPerGweiBaseFee) { + toSerialize["adjustmentPerGweiBaseFee"] = o.AdjustmentPerGweiBaseFee + } + return toSerialize, nil +} + +func (o *DutchV3OrderEntityOutputsInner) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "token", + "startAmount", + "recipient", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varDutchV3OrderEntityOutputsInner := _DutchV3OrderEntityOutputsInner{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varDutchV3OrderEntityOutputsInner) + + if err != nil { + return err + } + + *o = DutchV3OrderEntityOutputsInner(varDutchV3OrderEntityOutputsInner) + + return err +} + +type NullableDutchV3OrderEntityOutputsInner struct { + value *DutchV3OrderEntityOutputsInner + isSet bool +} + +func (v NullableDutchV3OrderEntityOutputsInner) Get() *DutchV3OrderEntityOutputsInner { + return v.value +} + +func (v *NullableDutchV3OrderEntityOutputsInner) Set(val *DutchV3OrderEntityOutputsInner) { + v.value = val + v.isSet = true +} + +func (v NullableDutchV3OrderEntityOutputsInner) IsSet() bool { + return v.isSet +} + +func (v *NullableDutchV3OrderEntityOutputsInner) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableDutchV3OrderEntityOutputsInner(val *DutchV3OrderEntityOutputsInner) *NullableDutchV3OrderEntityOutputsInner { + return &NullableDutchV3OrderEntityOutputsInner{value: val, isSet: true} +} + +func (v NullableDutchV3OrderEntityOutputsInner) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableDutchV3OrderEntityOutputsInner) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_error_code.go b/api/uniswapxservice/model_error_code.go new file mode 100644 index 00000000..e285c718 --- /dev/null +++ b/api/uniswapxservice/model_error_code.go @@ -0,0 +1,120 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" + "fmt" +) + +// ErrorCode the model 'ErrorCode' +type ErrorCode string + +// List of ErrorCode +const ( + ORDER_PARSE_FAIL ErrorCode = "ORDER_PARSE_FAIL" + INVALID_ORDER ErrorCode = "INVALID_ORDER" + TOO_MANY_OPEN_ORDERS ErrorCode = "TOO_MANY_OPEN_ORDERS" + INTERNAL_ERROR ErrorCode = "INTERNAL_ERROR" + VALIDATION_ERROR ErrorCode = "VALIDATION_ERROR" + TOO_MANY_REQUESTS ErrorCode = "TOO_MANY_REQUESTS" + INVALID_TOKEN_IN_ADDRESS ErrorCode = "INVALID_TOKEN_IN_ADDRESS" +) + +// All allowed values of ErrorCode enum +var AllowedErrorCodeEnumValues = []ErrorCode{ + "ORDER_PARSE_FAIL", + "INVALID_ORDER", + "TOO_MANY_OPEN_ORDERS", + "INTERNAL_ERROR", + "VALIDATION_ERROR", + "TOO_MANY_REQUESTS", + "INVALID_TOKEN_IN_ADDRESS", +} + +func (v *ErrorCode) UnmarshalJSON(src []byte) error { + var value string + err := json.Unmarshal(src, &value) + if err != nil { + return err + } + enumTypeValue := ErrorCode(value) + for _, existing := range AllowedErrorCodeEnumValues { + if existing == enumTypeValue { + *v = enumTypeValue + return nil + } + } + + return fmt.Errorf("%+v is not a valid ErrorCode", value) +} + +// NewErrorCodeFromValue returns a pointer to a valid ErrorCode +// for the value passed as argument, or an error if the value passed is not allowed by the enum +func NewErrorCodeFromValue(v string) (*ErrorCode, error) { + ev := ErrorCode(v) + if ev.IsValid() { + return &ev, nil + } else { + return nil, fmt.Errorf("invalid value '%v' for ErrorCode: valid values are %v", v, AllowedErrorCodeEnumValues) + } +} + +// IsValid return true if the value is valid for the enum, false otherwise +func (v ErrorCode) IsValid() bool { + for _, existing := range AllowedErrorCodeEnumValues { + if existing == v { + return true + } + } + return false +} + +// Ptr returns reference to ErrorCode value +func (v ErrorCode) Ptr() *ErrorCode { + return &v +} + +type NullableErrorCode struct { + value *ErrorCode + isSet bool +} + +func (v NullableErrorCode) Get() *ErrorCode { + return v.value +} + +func (v *NullableErrorCode) Set(val *ErrorCode) { + v.value = val + v.isSet = true +} + +func (v NullableErrorCode) IsSet() bool { + return v.isSet +} + +func (v *NullableErrorCode) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableErrorCode(val *ErrorCode) *NullableErrorCode { + return &NullableErrorCode{value: val, isSet: true} +} + +func (v NullableErrorCode) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableErrorCode) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_error_response.go b/api/uniswapxservice/model_error_response.go new file mode 100644 index 00000000..7217cb83 --- /dev/null +++ b/api/uniswapxservice/model_error_response.go @@ -0,0 +1,197 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" +) + +// checks if the ErrorResponse type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &ErrorResponse{} + +// ErrorResponse struct for ErrorResponse +type ErrorResponse struct { + ErrorCode *ErrorCode `json:"errorCode,omitempty"` + Detail *string `json:"detail,omitempty"` + // Request id for correlating the error with service logs. + Id *string `json:"id,omitempty"` +} + +// NewErrorResponse instantiates a new ErrorResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewErrorResponse() *ErrorResponse { + this := ErrorResponse{} + return &this +} + +// NewErrorResponseWithDefaults instantiates a new ErrorResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewErrorResponseWithDefaults() *ErrorResponse { + this := ErrorResponse{} + return &this +} + +// GetErrorCode returns the ErrorCode field value if set, zero value otherwise. +func (o *ErrorResponse) GetErrorCode() ErrorCode { + if o == nil || IsNil(o.ErrorCode) { + var ret ErrorCode + return ret + } + return *o.ErrorCode +} + +// GetErrorCodeOk returns a tuple with the ErrorCode field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ErrorResponse) GetErrorCodeOk() (*ErrorCode, bool) { + if o == nil || IsNil(o.ErrorCode) { + return nil, false + } + return o.ErrorCode, true +} + +// HasErrorCode returns a boolean if a field has been set. +func (o *ErrorResponse) HasErrorCode() bool { + if o != nil && !IsNil(o.ErrorCode) { + return true + } + + return false +} + +// SetErrorCode gets a reference to the given ErrorCode and assigns it to the ErrorCode field. +func (o *ErrorResponse) SetErrorCode(v ErrorCode) { + o.ErrorCode = &v +} + +// GetDetail returns the Detail field value if set, zero value otherwise. +func (o *ErrorResponse) GetDetail() string { + if o == nil || IsNil(o.Detail) { + var ret string + return ret + } + return *o.Detail +} + +// GetDetailOk returns a tuple with the Detail field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ErrorResponse) GetDetailOk() (*string, bool) { + if o == nil || IsNil(o.Detail) { + return nil, false + } + return o.Detail, true +} + +// HasDetail returns a boolean if a field has been set. +func (o *ErrorResponse) HasDetail() bool { + if o != nil && !IsNil(o.Detail) { + return true + } + + return false +} + +// SetDetail gets a reference to the given string and assigns it to the Detail field. +func (o *ErrorResponse) SetDetail(v string) { + o.Detail = &v +} + +// GetId returns the Id field value if set, zero value otherwise. +func (o *ErrorResponse) GetId() string { + if o == nil || IsNil(o.Id) { + var ret string + return ret + } + return *o.Id +} + +// GetIdOk returns a tuple with the Id field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ErrorResponse) GetIdOk() (*string, bool) { + if o == nil || IsNil(o.Id) { + return nil, false + } + return o.Id, true +} + +// HasId returns a boolean if a field has been set. +func (o *ErrorResponse) HasId() bool { + if o != nil && !IsNil(o.Id) { + return true + } + + return false +} + +// SetId gets a reference to the given string and assigns it to the Id field. +func (o *ErrorResponse) SetId(v string) { + o.Id = &v +} + +func (o ErrorResponse) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o ErrorResponse) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.ErrorCode) { + toSerialize["errorCode"] = o.ErrorCode + } + if !IsNil(o.Detail) { + toSerialize["detail"] = o.Detail + } + if !IsNil(o.Id) { + toSerialize["id"] = o.Id + } + return toSerialize, nil +} + +type NullableErrorResponse struct { + value *ErrorResponse + isSet bool +} + +func (v NullableErrorResponse) Get() *ErrorResponse { + return v.value +} + +func (v *NullableErrorResponse) Set(val *ErrorResponse) { + v.value = val + v.isSet = true +} + +func (v NullableErrorResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableErrorResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableErrorResponse(val *ErrorResponse) *NullableErrorResponse { + return &NullableErrorResponse{value: val, isSet: true} +} + +func (v NullableErrorResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableErrorResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_get_orders_response.go b/api/uniswapxservice/model_get_orders_response.go new file mode 100644 index 00000000..ac89fd0d --- /dev/null +++ b/api/uniswapxservice/model_get_orders_response.go @@ -0,0 +1,161 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" +) + +// checks if the GetOrdersResponse type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &GetOrdersResponse{} + +// GetOrdersResponse struct for GetOrdersResponse +type GetOrdersResponse struct { + Orders []GetOrdersResponseOrdersInner `json:"orders,omitempty"` + // Defined when the results are paginated. Pass back via the cursor query parameter to fetch the next page. + Cursor *string `json:"cursor,omitempty"` +} + +// NewGetOrdersResponse instantiates a new GetOrdersResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewGetOrdersResponse() *GetOrdersResponse { + this := GetOrdersResponse{} + return &this +} + +// NewGetOrdersResponseWithDefaults instantiates a new GetOrdersResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewGetOrdersResponseWithDefaults() *GetOrdersResponse { + this := GetOrdersResponse{} + return &this +} + +// GetOrders returns the Orders field value if set, zero value otherwise. +func (o *GetOrdersResponse) GetOrders() []GetOrdersResponseOrdersInner { + if o == nil || IsNil(o.Orders) { + var ret []GetOrdersResponseOrdersInner + return ret + } + return o.Orders +} + +// GetOrdersOk returns a tuple with the Orders field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *GetOrdersResponse) GetOrdersOk() ([]GetOrdersResponseOrdersInner, bool) { + if o == nil || IsNil(o.Orders) { + return nil, false + } + return o.Orders, true +} + +// HasOrders returns a boolean if a field has been set. +func (o *GetOrdersResponse) HasOrders() bool { + if o != nil && !IsNil(o.Orders) { + return true + } + + return false +} + +// SetOrders gets a reference to the given []GetOrdersResponseOrdersInner and assigns it to the Orders field. +func (o *GetOrdersResponse) SetOrders(v []GetOrdersResponseOrdersInner) { + o.Orders = v +} + +// GetCursor returns the Cursor field value if set, zero value otherwise. +func (o *GetOrdersResponse) GetCursor() string { + if o == nil || IsNil(o.Cursor) { + var ret string + return ret + } + return *o.Cursor +} + +// GetCursorOk returns a tuple with the Cursor field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *GetOrdersResponse) GetCursorOk() (*string, bool) { + if o == nil || IsNil(o.Cursor) { + return nil, false + } + return o.Cursor, true +} + +// HasCursor returns a boolean if a field has been set. +func (o *GetOrdersResponse) HasCursor() bool { + if o != nil && !IsNil(o.Cursor) { + return true + } + + return false +} + +// SetCursor gets a reference to the given string and assigns it to the Cursor field. +func (o *GetOrdersResponse) SetCursor(v string) { + o.Cursor = &v +} + +func (o GetOrdersResponse) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o GetOrdersResponse) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Orders) { + toSerialize["orders"] = o.Orders + } + if !IsNil(o.Cursor) { + toSerialize["cursor"] = o.Cursor + } + return toSerialize, nil +} + +type NullableGetOrdersResponse struct { + value *GetOrdersResponse + isSet bool +} + +func (v NullableGetOrdersResponse) Get() *GetOrdersResponse { + return v.value +} + +func (v *NullableGetOrdersResponse) Set(val *GetOrdersResponse) { + v.value = val + v.isSet = true +} + +func (v NullableGetOrdersResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableGetOrdersResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableGetOrdersResponse(val *GetOrdersResponse) *NullableGetOrdersResponse { + return &NullableGetOrdersResponse{value: val, isSet: true} +} + +func (v NullableGetOrdersResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableGetOrdersResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_get_orders_response_orders_inner.go b/api/uniswapxservice/model_get_orders_response_orders_inner.go new file mode 100644 index 00000000..ad711f62 --- /dev/null +++ b/api/uniswapxservice/model_get_orders_response_orders_inner.go @@ -0,0 +1,377 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" + "fmt" +) + +// GetOrdersResponseOrdersInner - struct for GetOrdersResponseOrdersInner +type GetOrdersResponseOrdersInner struct { + DutchOrderEntity *DutchOrderEntity + DutchV2OrderEntity *DutchV2OrderEntity + DutchV3OrderEntity *DutchV3OrderEntity + HybridOrderEntity *HybridOrderEntity + PriorityOrderEntity *PriorityOrderEntity + RelayOrderEntity *RelayOrderEntity +} + +// DutchOrderEntityAsGetOrdersResponseOrdersInner is a convenience function that returns DutchOrderEntity wrapped in GetOrdersResponseOrdersInner +func DutchOrderEntityAsGetOrdersResponseOrdersInner(v *DutchOrderEntity) GetOrdersResponseOrdersInner { + return GetOrdersResponseOrdersInner{ + DutchOrderEntity: v, + } +} + +// DutchV2OrderEntityAsGetOrdersResponseOrdersInner is a convenience function that returns DutchV2OrderEntity wrapped in GetOrdersResponseOrdersInner +func DutchV2OrderEntityAsGetOrdersResponseOrdersInner(v *DutchV2OrderEntity) GetOrdersResponseOrdersInner { + return GetOrdersResponseOrdersInner{ + DutchV2OrderEntity: v, + } +} + +// DutchV3OrderEntityAsGetOrdersResponseOrdersInner is a convenience function that returns DutchV3OrderEntity wrapped in GetOrdersResponseOrdersInner +func DutchV3OrderEntityAsGetOrdersResponseOrdersInner(v *DutchV3OrderEntity) GetOrdersResponseOrdersInner { + return GetOrdersResponseOrdersInner{ + DutchV3OrderEntity: v, + } +} + +// HybridOrderEntityAsGetOrdersResponseOrdersInner is a convenience function that returns HybridOrderEntity wrapped in GetOrdersResponseOrdersInner +func HybridOrderEntityAsGetOrdersResponseOrdersInner(v *HybridOrderEntity) GetOrdersResponseOrdersInner { + return GetOrdersResponseOrdersInner{ + HybridOrderEntity: v, + } +} + +// PriorityOrderEntityAsGetOrdersResponseOrdersInner is a convenience function that returns PriorityOrderEntity wrapped in GetOrdersResponseOrdersInner +func PriorityOrderEntityAsGetOrdersResponseOrdersInner(v *PriorityOrderEntity) GetOrdersResponseOrdersInner { + return GetOrdersResponseOrdersInner{ + PriorityOrderEntity: v, + } +} + +// RelayOrderEntityAsGetOrdersResponseOrdersInner is a convenience function that returns RelayOrderEntity wrapped in GetOrdersResponseOrdersInner +func RelayOrderEntityAsGetOrdersResponseOrdersInner(v *RelayOrderEntity) GetOrdersResponseOrdersInner { + return GetOrdersResponseOrdersInner{ + RelayOrderEntity: v, + } +} + +// Unmarshal JSON data into one of the pointers in the struct +func (dst *GetOrdersResponseOrdersInner) UnmarshalJSON(data []byte) error { + var err error + // use discriminator value to speed up the lookup + var jsonDict map[string]interface{} + err = newStrictDecoder(data).Decode(&jsonDict) + if err != nil { + return fmt.Errorf("failed to unmarshal JSON into map for the discriminator lookup") + } + + // check if the discriminator value is 'Dutch' + if jsonDict["type"] == "Dutch" { + // try to unmarshal JSON data into DutchOrderEntity + err = json.Unmarshal(data, &dst.DutchOrderEntity) + if err == nil { + return nil // data stored in dst.DutchOrderEntity, return on the first match + } else { + dst.DutchOrderEntity = nil + return fmt.Errorf("failed to unmarshal GetOrdersResponseOrdersInner as DutchOrderEntity: %s", err.Error()) + } + } + + // check if the discriminator value is 'DutchLimit' + if jsonDict["type"] == "DutchLimit" { + // try to unmarshal JSON data into DutchOrderEntity + err = json.Unmarshal(data, &dst.DutchOrderEntity) + if err == nil { + return nil // data stored in dst.DutchOrderEntity, return on the first match + } else { + dst.DutchOrderEntity = nil + return fmt.Errorf("failed to unmarshal GetOrdersResponseOrdersInner as DutchOrderEntity: %s", err.Error()) + } + } + + // check if the discriminator value is 'Dutch_V2' + if jsonDict["type"] == "Dutch_V2" { + // try to unmarshal JSON data into DutchV2OrderEntity + err = json.Unmarshal(data, &dst.DutchV2OrderEntity) + if err == nil { + return nil // data stored in dst.DutchV2OrderEntity, return on the first match + } else { + dst.DutchV2OrderEntity = nil + return fmt.Errorf("failed to unmarshal GetOrdersResponseOrdersInner as DutchV2OrderEntity: %s", err.Error()) + } + } + + // check if the discriminator value is 'Dutch_V3' + if jsonDict["type"] == "Dutch_V3" { + // try to unmarshal JSON data into DutchV3OrderEntity + err = json.Unmarshal(data, &dst.DutchV3OrderEntity) + if err == nil { + return nil // data stored in dst.DutchV3OrderEntity, return on the first match + } else { + dst.DutchV3OrderEntity = nil + return fmt.Errorf("failed to unmarshal GetOrdersResponseOrdersInner as DutchV3OrderEntity: %s", err.Error()) + } + } + + // check if the discriminator value is 'Hybrid' + if jsonDict["type"] == "Hybrid" { + // try to unmarshal JSON data into HybridOrderEntity + err = json.Unmarshal(data, &dst.HybridOrderEntity) + if err == nil { + return nil // data stored in dst.HybridOrderEntity, return on the first match + } else { + dst.HybridOrderEntity = nil + return fmt.Errorf("failed to unmarshal GetOrdersResponseOrdersInner as HybridOrderEntity: %s", err.Error()) + } + } + + // check if the discriminator value is 'Limit' + if jsonDict["type"] == "Limit" { + // try to unmarshal JSON data into DutchOrderEntity + err = json.Unmarshal(data, &dst.DutchOrderEntity) + if err == nil { + return nil // data stored in dst.DutchOrderEntity, return on the first match + } else { + dst.DutchOrderEntity = nil + return fmt.Errorf("failed to unmarshal GetOrdersResponseOrdersInner as DutchOrderEntity: %s", err.Error()) + } + } + + // check if the discriminator value is 'Priority' + if jsonDict["type"] == "Priority" { + // try to unmarshal JSON data into PriorityOrderEntity + err = json.Unmarshal(data, &dst.PriorityOrderEntity) + if err == nil { + return nil // data stored in dst.PriorityOrderEntity, return on the first match + } else { + dst.PriorityOrderEntity = nil + return fmt.Errorf("failed to unmarshal GetOrdersResponseOrdersInner as PriorityOrderEntity: %s", err.Error()) + } + } + + // check if the discriminator value is 'Relay' + if jsonDict["type"] == "Relay" { + // try to unmarshal JSON data into RelayOrderEntity + err = json.Unmarshal(data, &dst.RelayOrderEntity) + if err == nil { + return nil // data stored in dst.RelayOrderEntity, return on the first match + } else { + dst.RelayOrderEntity = nil + return fmt.Errorf("failed to unmarshal GetOrdersResponseOrdersInner as RelayOrderEntity: %s", err.Error()) + } + } + + // check if the discriminator value is 'DutchOrderEntity' + if jsonDict["type"] == "DutchOrderEntity" { + // try to unmarshal JSON data into DutchOrderEntity + err = json.Unmarshal(data, &dst.DutchOrderEntity) + if err == nil { + return nil // data stored in dst.DutchOrderEntity, return on the first match + } else { + dst.DutchOrderEntity = nil + return fmt.Errorf("failed to unmarshal GetOrdersResponseOrdersInner as DutchOrderEntity: %s", err.Error()) + } + } + + // check if the discriminator value is 'DutchV2OrderEntity' + if jsonDict["type"] == "DutchV2OrderEntity" { + // try to unmarshal JSON data into DutchV2OrderEntity + err = json.Unmarshal(data, &dst.DutchV2OrderEntity) + if err == nil { + return nil // data stored in dst.DutchV2OrderEntity, return on the first match + } else { + dst.DutchV2OrderEntity = nil + return fmt.Errorf("failed to unmarshal GetOrdersResponseOrdersInner as DutchV2OrderEntity: %s", err.Error()) + } + } + + // check if the discriminator value is 'DutchV3OrderEntity' + if jsonDict["type"] == "DutchV3OrderEntity" { + // try to unmarshal JSON data into DutchV3OrderEntity + err = json.Unmarshal(data, &dst.DutchV3OrderEntity) + if err == nil { + return nil // data stored in dst.DutchV3OrderEntity, return on the first match + } else { + dst.DutchV3OrderEntity = nil + return fmt.Errorf("failed to unmarshal GetOrdersResponseOrdersInner as DutchV3OrderEntity: %s", err.Error()) + } + } + + // check if the discriminator value is 'HybridOrderEntity' + if jsonDict["type"] == "HybridOrderEntity" { + // try to unmarshal JSON data into HybridOrderEntity + err = json.Unmarshal(data, &dst.HybridOrderEntity) + if err == nil { + return nil // data stored in dst.HybridOrderEntity, return on the first match + } else { + dst.HybridOrderEntity = nil + return fmt.Errorf("failed to unmarshal GetOrdersResponseOrdersInner as HybridOrderEntity: %s", err.Error()) + } + } + + // check if the discriminator value is 'PriorityOrderEntity' + if jsonDict["type"] == "PriorityOrderEntity" { + // try to unmarshal JSON data into PriorityOrderEntity + err = json.Unmarshal(data, &dst.PriorityOrderEntity) + if err == nil { + return nil // data stored in dst.PriorityOrderEntity, return on the first match + } else { + dst.PriorityOrderEntity = nil + return fmt.Errorf("failed to unmarshal GetOrdersResponseOrdersInner as PriorityOrderEntity: %s", err.Error()) + } + } + + // check if the discriminator value is 'RelayOrderEntity' + if jsonDict["type"] == "RelayOrderEntity" { + // try to unmarshal JSON data into RelayOrderEntity + err = json.Unmarshal(data, &dst.RelayOrderEntity) + if err == nil { + return nil // data stored in dst.RelayOrderEntity, return on the first match + } else { + dst.RelayOrderEntity = nil + return fmt.Errorf("failed to unmarshal GetOrdersResponseOrdersInner as RelayOrderEntity: %s", err.Error()) + } + } + + return nil +} + +// Marshal data from the first non-nil pointers in the struct to JSON +func (src GetOrdersResponseOrdersInner) MarshalJSON() ([]byte, error) { + if src.DutchOrderEntity != nil { + return json.Marshal(&src.DutchOrderEntity) + } + + if src.DutchV2OrderEntity != nil { + return json.Marshal(&src.DutchV2OrderEntity) + } + + if src.DutchV3OrderEntity != nil { + return json.Marshal(&src.DutchV3OrderEntity) + } + + if src.HybridOrderEntity != nil { + return json.Marshal(&src.HybridOrderEntity) + } + + if src.PriorityOrderEntity != nil { + return json.Marshal(&src.PriorityOrderEntity) + } + + if src.RelayOrderEntity != nil { + return json.Marshal(&src.RelayOrderEntity) + } + + return nil, nil // no data in oneOf schemas +} + +// Get the actual instance +func (obj *GetOrdersResponseOrdersInner) GetActualInstance() interface{} { + if obj == nil { + return nil + } + if obj.DutchOrderEntity != nil { + return obj.DutchOrderEntity + } + + if obj.DutchV2OrderEntity != nil { + return obj.DutchV2OrderEntity + } + + if obj.DutchV3OrderEntity != nil { + return obj.DutchV3OrderEntity + } + + if obj.HybridOrderEntity != nil { + return obj.HybridOrderEntity + } + + if obj.PriorityOrderEntity != nil { + return obj.PriorityOrderEntity + } + + if obj.RelayOrderEntity != nil { + return obj.RelayOrderEntity + } + + // all schemas are nil + return nil +} + +// Get the actual instance value +func (obj GetOrdersResponseOrdersInner) GetActualInstanceValue() interface{} { + if obj.DutchOrderEntity != nil { + return *obj.DutchOrderEntity + } + + if obj.DutchV2OrderEntity != nil { + return *obj.DutchV2OrderEntity + } + + if obj.DutchV3OrderEntity != nil { + return *obj.DutchV3OrderEntity + } + + if obj.HybridOrderEntity != nil { + return *obj.HybridOrderEntity + } + + if obj.PriorityOrderEntity != nil { + return *obj.PriorityOrderEntity + } + + if obj.RelayOrderEntity != nil { + return *obj.RelayOrderEntity + } + + // all schemas are nil + return nil +} + +type NullableGetOrdersResponseOrdersInner struct { + value *GetOrdersResponseOrdersInner + isSet bool +} + +func (v NullableGetOrdersResponseOrdersInner) Get() *GetOrdersResponseOrdersInner { + return v.value +} + +func (v *NullableGetOrdersResponseOrdersInner) Set(val *GetOrdersResponseOrdersInner) { + v.value = val + v.isSet = true +} + +func (v NullableGetOrdersResponseOrdersInner) IsSet() bool { + return v.isSet +} + +func (v *NullableGetOrdersResponseOrdersInner) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableGetOrdersResponseOrdersInner(val *GetOrdersResponseOrdersInner) *NullableGetOrdersResponseOrdersInner { + return &NullableGetOrdersResponseOrdersInner{value: val, isSet: true} +} + +func (v NullableGetOrdersResponseOrdersInner) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableGetOrdersResponseOrdersInner) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_hybrid_order_entity.go b/api/uniswapxservice/model_hybrid_order_entity.go new file mode 100644 index 00000000..6efae87a --- /dev/null +++ b/api/uniswapxservice/model_hybrid_order_entity.go @@ -0,0 +1,912 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the HybridOrderEntity type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &HybridOrderEntity{} + +// HybridOrderEntity Hybrid orders: support Dutch auction (price curve) or priority fee scaling mechanics, mutually exclusively. +type HybridOrderEntity struct { + Type string `json:"type"` + // ABI-encoded order struct. Decode with the uniswapx-sdk parser for the order's type. + EncodedOrder string `json:"encodedOrder" validate:"regexp=^0x[0-9a-fA-F]*$"` + // EIP-712 signature over the order. + Signature string `json:"signature" validate:"regexp=^0x[0-9a-fA-F]{130}$"` + // Permit2 nonce, uint256 encoded as a base-10 string. + Nonce *string `json:"nonce,omitempty" validate:"regexp=^[0-9]{1,78}$"` + OrderHash string `json:"orderHash" validate:"regexp=^0x[0-9a-fA-F]{64}$"` + OrderStatus OrderStatus `json:"orderStatus"` + ChainId ChainId `json:"chainId"` + // EIP-55 checksummed Ethereum address. + Swapper string `json:"swapper"` + AuctionStartBlock *float32 `json:"auctionStartBlock,omitempty"` + // uint256 encoded as a base-10 string. + BaselinePriorityFee *string `json:"baselinePriorityFee,omitempty" validate:"regexp=^[0-9]{1,78}$"` + // uint256 encoded as a base-10 string. + ScalingFactor *string `json:"scalingFactor,omitempty" validate:"regexp=^[0-9]{1,78}$"` + // 1e18-denominated multipliers; all elements are on the same side of 1e18. Empty for priority-style hybrid orders. + PriceCurve []string `json:"priceCurve,omitempty"` + Input *HybridOrderEntityInput `json:"input,omitempty"` + Outputs []HybridOrderEntityOutputsInner `json:"outputs,omitempty"` + // EIP-55 checksummed Ethereum address. + Cosigner *string `json:"cosigner,omitempty"` + CosignerData *HybridOrderEntityCosignerData `json:"cosignerData,omitempty"` + Cosignature *string `json:"cosignature,omitempty"` + // Unix timestamp (seconds) at which the order was recorded. + CreatedAt *float32 `json:"createdAt,omitempty"` + // Defined when the order has a quote associated with it. + QuoteId *string `json:"quoteId,omitempty"` + // Defined when the order has a quote request associated with it. + RequestId *string `json:"requestId,omitempty"` + // Transaction hash of the fill. Defined once the order has been filled. + TxHash *string `json:"txHash,omitempty" validate:"regexp=^0x[0-9a-fA-F]{64}$"` + SettledAmounts []SettledAmount `json:"settledAmounts,omitempty"` + Route *Route `json:"route,omitempty"` +} + +type _HybridOrderEntity HybridOrderEntity + +// NewHybridOrderEntity instantiates a new HybridOrderEntity object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewHybridOrderEntity(type_ string, encodedOrder string, signature string, orderHash string, orderStatus OrderStatus, chainId ChainId, swapper string) *HybridOrderEntity { + this := HybridOrderEntity{} + this.Type = type_ + this.EncodedOrder = encodedOrder + this.Signature = signature + this.OrderHash = orderHash + this.OrderStatus = orderStatus + this.ChainId = chainId + this.Swapper = swapper + return &this +} + +// NewHybridOrderEntityWithDefaults instantiates a new HybridOrderEntity object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewHybridOrderEntityWithDefaults() *HybridOrderEntity { + this := HybridOrderEntity{} + return &this +} + +// GetType returns the Type field value +func (o *HybridOrderEntity) GetType() string { + if o == nil { + var ret string + return ret + } + + return o.Type +} + +// GetTypeOk returns a tuple with the Type field value +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Type, true +} + +// SetType sets field value +func (o *HybridOrderEntity) SetType(v string) { + o.Type = v +} + +// GetEncodedOrder returns the EncodedOrder field value +func (o *HybridOrderEntity) GetEncodedOrder() string { + if o == nil { + var ret string + return ret + } + + return o.EncodedOrder +} + +// GetEncodedOrderOk returns a tuple with the EncodedOrder field value +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetEncodedOrderOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.EncodedOrder, true +} + +// SetEncodedOrder sets field value +func (o *HybridOrderEntity) SetEncodedOrder(v string) { + o.EncodedOrder = v +} + +// GetSignature returns the Signature field value +func (o *HybridOrderEntity) GetSignature() string { + if o == nil { + var ret string + return ret + } + + return o.Signature +} + +// GetSignatureOk returns a tuple with the Signature field value +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetSignatureOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Signature, true +} + +// SetSignature sets field value +func (o *HybridOrderEntity) SetSignature(v string) { + o.Signature = v +} + +// GetNonce returns the Nonce field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetNonce() string { + if o == nil || IsNil(o.Nonce) { + var ret string + return ret + } + return *o.Nonce +} + +// GetNonceOk returns a tuple with the Nonce field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetNonceOk() (*string, bool) { + if o == nil || IsNil(o.Nonce) { + return nil, false + } + return o.Nonce, true +} + +// HasNonce returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasNonce() bool { + if o != nil && !IsNil(o.Nonce) { + return true + } + + return false +} + +// SetNonce gets a reference to the given string and assigns it to the Nonce field. +func (o *HybridOrderEntity) SetNonce(v string) { + o.Nonce = &v +} + +// GetOrderHash returns the OrderHash field value +func (o *HybridOrderEntity) GetOrderHash() string { + if o == nil { + var ret string + return ret + } + + return o.OrderHash +} + +// GetOrderHashOk returns a tuple with the OrderHash field value +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetOrderHashOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.OrderHash, true +} + +// SetOrderHash sets field value +func (o *HybridOrderEntity) SetOrderHash(v string) { + o.OrderHash = v +} + +// GetOrderStatus returns the OrderStatus field value +func (o *HybridOrderEntity) GetOrderStatus() OrderStatus { + if o == nil { + var ret OrderStatus + return ret + } + + return o.OrderStatus +} + +// GetOrderStatusOk returns a tuple with the OrderStatus field value +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetOrderStatusOk() (*OrderStatus, bool) { + if o == nil { + return nil, false + } + return &o.OrderStatus, true +} + +// SetOrderStatus sets field value +func (o *HybridOrderEntity) SetOrderStatus(v OrderStatus) { + o.OrderStatus = v +} + +// GetChainId returns the ChainId field value +func (o *HybridOrderEntity) GetChainId() ChainId { + if o == nil { + var ret ChainId + return ret + } + + return o.ChainId +} + +// GetChainIdOk returns a tuple with the ChainId field value +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetChainIdOk() (*ChainId, bool) { + if o == nil { + return nil, false + } + return &o.ChainId, true +} + +// SetChainId sets field value +func (o *HybridOrderEntity) SetChainId(v ChainId) { + o.ChainId = v +} + +// GetSwapper returns the Swapper field value +func (o *HybridOrderEntity) GetSwapper() string { + if o == nil { + var ret string + return ret + } + + return o.Swapper +} + +// GetSwapperOk returns a tuple with the Swapper field value +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetSwapperOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Swapper, true +} + +// SetSwapper sets field value +func (o *HybridOrderEntity) SetSwapper(v string) { + o.Swapper = v +} + +// GetAuctionStartBlock returns the AuctionStartBlock field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetAuctionStartBlock() float32 { + if o == nil || IsNil(o.AuctionStartBlock) { + var ret float32 + return ret + } + return *o.AuctionStartBlock +} + +// GetAuctionStartBlockOk returns a tuple with the AuctionStartBlock field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetAuctionStartBlockOk() (*float32, bool) { + if o == nil || IsNil(o.AuctionStartBlock) { + return nil, false + } + return o.AuctionStartBlock, true +} + +// HasAuctionStartBlock returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasAuctionStartBlock() bool { + if o != nil && !IsNil(o.AuctionStartBlock) { + return true + } + + return false +} + +// SetAuctionStartBlock gets a reference to the given float32 and assigns it to the AuctionStartBlock field. +func (o *HybridOrderEntity) SetAuctionStartBlock(v float32) { + o.AuctionStartBlock = &v +} + +// GetBaselinePriorityFee returns the BaselinePriorityFee field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetBaselinePriorityFee() string { + if o == nil || IsNil(o.BaselinePriorityFee) { + var ret string + return ret + } + return *o.BaselinePriorityFee +} + +// GetBaselinePriorityFeeOk returns a tuple with the BaselinePriorityFee field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetBaselinePriorityFeeOk() (*string, bool) { + if o == nil || IsNil(o.BaselinePriorityFee) { + return nil, false + } + return o.BaselinePriorityFee, true +} + +// HasBaselinePriorityFee returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasBaselinePriorityFee() bool { + if o != nil && !IsNil(o.BaselinePriorityFee) { + return true + } + + return false +} + +// SetBaselinePriorityFee gets a reference to the given string and assigns it to the BaselinePriorityFee field. +func (o *HybridOrderEntity) SetBaselinePriorityFee(v string) { + o.BaselinePriorityFee = &v +} + +// GetScalingFactor returns the ScalingFactor field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetScalingFactor() string { + if o == nil || IsNil(o.ScalingFactor) { + var ret string + return ret + } + return *o.ScalingFactor +} + +// GetScalingFactorOk returns a tuple with the ScalingFactor field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetScalingFactorOk() (*string, bool) { + if o == nil || IsNil(o.ScalingFactor) { + return nil, false + } + return o.ScalingFactor, true +} + +// HasScalingFactor returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasScalingFactor() bool { + if o != nil && !IsNil(o.ScalingFactor) { + return true + } + + return false +} + +// SetScalingFactor gets a reference to the given string and assigns it to the ScalingFactor field. +func (o *HybridOrderEntity) SetScalingFactor(v string) { + o.ScalingFactor = &v +} + +// GetPriceCurve returns the PriceCurve field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetPriceCurve() []string { + if o == nil || IsNil(o.PriceCurve) { + var ret []string + return ret + } + return o.PriceCurve +} + +// GetPriceCurveOk returns a tuple with the PriceCurve field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetPriceCurveOk() ([]string, bool) { + if o == nil || IsNil(o.PriceCurve) { + return nil, false + } + return o.PriceCurve, true +} + +// HasPriceCurve returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasPriceCurve() bool { + if o != nil && !IsNil(o.PriceCurve) { + return true + } + + return false +} + +// SetPriceCurve gets a reference to the given []string and assigns it to the PriceCurve field. +func (o *HybridOrderEntity) SetPriceCurve(v []string) { + o.PriceCurve = v +} + +// GetInput returns the Input field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetInput() HybridOrderEntityInput { + if o == nil || IsNil(o.Input) { + var ret HybridOrderEntityInput + return ret + } + return *o.Input +} + +// GetInputOk returns a tuple with the Input field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetInputOk() (*HybridOrderEntityInput, bool) { + if o == nil || IsNil(o.Input) { + return nil, false + } + return o.Input, true +} + +// HasInput returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasInput() bool { + if o != nil && !IsNil(o.Input) { + return true + } + + return false +} + +// SetInput gets a reference to the given HybridOrderEntityInput and assigns it to the Input field. +func (o *HybridOrderEntity) SetInput(v HybridOrderEntityInput) { + o.Input = &v +} + +// GetOutputs returns the Outputs field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetOutputs() []HybridOrderEntityOutputsInner { + if o == nil || IsNil(o.Outputs) { + var ret []HybridOrderEntityOutputsInner + return ret + } + return o.Outputs +} + +// GetOutputsOk returns a tuple with the Outputs field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetOutputsOk() ([]HybridOrderEntityOutputsInner, bool) { + if o == nil || IsNil(o.Outputs) { + return nil, false + } + return o.Outputs, true +} + +// HasOutputs returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasOutputs() bool { + if o != nil && !IsNil(o.Outputs) { + return true + } + + return false +} + +// SetOutputs gets a reference to the given []HybridOrderEntityOutputsInner and assigns it to the Outputs field. +func (o *HybridOrderEntity) SetOutputs(v []HybridOrderEntityOutputsInner) { + o.Outputs = v +} + +// GetCosigner returns the Cosigner field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetCosigner() string { + if o == nil || IsNil(o.Cosigner) { + var ret string + return ret + } + return *o.Cosigner +} + +// GetCosignerOk returns a tuple with the Cosigner field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetCosignerOk() (*string, bool) { + if o == nil || IsNil(o.Cosigner) { + return nil, false + } + return o.Cosigner, true +} + +// HasCosigner returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasCosigner() bool { + if o != nil && !IsNil(o.Cosigner) { + return true + } + + return false +} + +// SetCosigner gets a reference to the given string and assigns it to the Cosigner field. +func (o *HybridOrderEntity) SetCosigner(v string) { + o.Cosigner = &v +} + +// GetCosignerData returns the CosignerData field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetCosignerData() HybridOrderEntityCosignerData { + if o == nil || IsNil(o.CosignerData) { + var ret HybridOrderEntityCosignerData + return ret + } + return *o.CosignerData +} + +// GetCosignerDataOk returns a tuple with the CosignerData field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetCosignerDataOk() (*HybridOrderEntityCosignerData, bool) { + if o == nil || IsNil(o.CosignerData) { + return nil, false + } + return o.CosignerData, true +} + +// HasCosignerData returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasCosignerData() bool { + if o != nil && !IsNil(o.CosignerData) { + return true + } + + return false +} + +// SetCosignerData gets a reference to the given HybridOrderEntityCosignerData and assigns it to the CosignerData field. +func (o *HybridOrderEntity) SetCosignerData(v HybridOrderEntityCosignerData) { + o.CosignerData = &v +} + +// GetCosignature returns the Cosignature field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetCosignature() string { + if o == nil || IsNil(o.Cosignature) { + var ret string + return ret + } + return *o.Cosignature +} + +// GetCosignatureOk returns a tuple with the Cosignature field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetCosignatureOk() (*string, bool) { + if o == nil || IsNil(o.Cosignature) { + return nil, false + } + return o.Cosignature, true +} + +// HasCosignature returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasCosignature() bool { + if o != nil && !IsNil(o.Cosignature) { + return true + } + + return false +} + +// SetCosignature gets a reference to the given string and assigns it to the Cosignature field. +func (o *HybridOrderEntity) SetCosignature(v string) { + o.Cosignature = &v +} + +// GetCreatedAt returns the CreatedAt field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetCreatedAt() float32 { + if o == nil || IsNil(o.CreatedAt) { + var ret float32 + return ret + } + return *o.CreatedAt +} + +// GetCreatedAtOk returns a tuple with the CreatedAt field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetCreatedAtOk() (*float32, bool) { + if o == nil || IsNil(o.CreatedAt) { + return nil, false + } + return o.CreatedAt, true +} + +// HasCreatedAt returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasCreatedAt() bool { + if o != nil && !IsNil(o.CreatedAt) { + return true + } + + return false +} + +// SetCreatedAt gets a reference to the given float32 and assigns it to the CreatedAt field. +func (o *HybridOrderEntity) SetCreatedAt(v float32) { + o.CreatedAt = &v +} + +// GetQuoteId returns the QuoteId field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetQuoteId() string { + if o == nil || IsNil(o.QuoteId) { + var ret string + return ret + } + return *o.QuoteId +} + +// GetQuoteIdOk returns a tuple with the QuoteId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetQuoteIdOk() (*string, bool) { + if o == nil || IsNil(o.QuoteId) { + return nil, false + } + return o.QuoteId, true +} + +// HasQuoteId returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasQuoteId() bool { + if o != nil && !IsNil(o.QuoteId) { + return true + } + + return false +} + +// SetQuoteId gets a reference to the given string and assigns it to the QuoteId field. +func (o *HybridOrderEntity) SetQuoteId(v string) { + o.QuoteId = &v +} + +// GetRequestId returns the RequestId field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetRequestId() string { + if o == nil || IsNil(o.RequestId) { + var ret string + return ret + } + return *o.RequestId +} + +// GetRequestIdOk returns a tuple with the RequestId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetRequestIdOk() (*string, bool) { + if o == nil || IsNil(o.RequestId) { + return nil, false + } + return o.RequestId, true +} + +// HasRequestId returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasRequestId() bool { + if o != nil && !IsNil(o.RequestId) { + return true + } + + return false +} + +// SetRequestId gets a reference to the given string and assigns it to the RequestId field. +func (o *HybridOrderEntity) SetRequestId(v string) { + o.RequestId = &v +} + +// GetTxHash returns the TxHash field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetTxHash() string { + if o == nil || IsNil(o.TxHash) { + var ret string + return ret + } + return *o.TxHash +} + +// GetTxHashOk returns a tuple with the TxHash field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetTxHashOk() (*string, bool) { + if o == nil || IsNil(o.TxHash) { + return nil, false + } + return o.TxHash, true +} + +// HasTxHash returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasTxHash() bool { + if o != nil && !IsNil(o.TxHash) { + return true + } + + return false +} + +// SetTxHash gets a reference to the given string and assigns it to the TxHash field. +func (o *HybridOrderEntity) SetTxHash(v string) { + o.TxHash = &v +} + +// GetSettledAmounts returns the SettledAmounts field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetSettledAmounts() []SettledAmount { + if o == nil || IsNil(o.SettledAmounts) { + var ret []SettledAmount + return ret + } + return o.SettledAmounts +} + +// GetSettledAmountsOk returns a tuple with the SettledAmounts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetSettledAmountsOk() ([]SettledAmount, bool) { + if o == nil || IsNil(o.SettledAmounts) { + return nil, false + } + return o.SettledAmounts, true +} + +// HasSettledAmounts returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasSettledAmounts() bool { + if o != nil && !IsNil(o.SettledAmounts) { + return true + } + + return false +} + +// SetSettledAmounts gets a reference to the given []SettledAmount and assigns it to the SettledAmounts field. +func (o *HybridOrderEntity) SetSettledAmounts(v []SettledAmount) { + o.SettledAmounts = v +} + +// GetRoute returns the Route field value if set, zero value otherwise. +func (o *HybridOrderEntity) GetRoute() Route { + if o == nil || IsNil(o.Route) { + var ret Route + return ret + } + return *o.Route +} + +// GetRouteOk returns a tuple with the Route field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntity) GetRouteOk() (*Route, bool) { + if o == nil || IsNil(o.Route) { + return nil, false + } + return o.Route, true +} + +// HasRoute returns a boolean if a field has been set. +func (o *HybridOrderEntity) HasRoute() bool { + if o != nil && !IsNil(o.Route) { + return true + } + + return false +} + +// SetRoute gets a reference to the given Route and assigns it to the Route field. +func (o *HybridOrderEntity) SetRoute(v Route) { + o.Route = &v +} + +func (o HybridOrderEntity) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o HybridOrderEntity) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["type"] = o.Type + toSerialize["encodedOrder"] = o.EncodedOrder + toSerialize["signature"] = o.Signature + if !IsNil(o.Nonce) { + toSerialize["nonce"] = o.Nonce + } + toSerialize["orderHash"] = o.OrderHash + toSerialize["orderStatus"] = o.OrderStatus + toSerialize["chainId"] = o.ChainId + toSerialize["swapper"] = o.Swapper + if !IsNil(o.AuctionStartBlock) { + toSerialize["auctionStartBlock"] = o.AuctionStartBlock + } + if !IsNil(o.BaselinePriorityFee) { + toSerialize["baselinePriorityFee"] = o.BaselinePriorityFee + } + if !IsNil(o.ScalingFactor) { + toSerialize["scalingFactor"] = o.ScalingFactor + } + if !IsNil(o.PriceCurve) { + toSerialize["priceCurve"] = o.PriceCurve + } + if !IsNil(o.Input) { + toSerialize["input"] = o.Input + } + if !IsNil(o.Outputs) { + toSerialize["outputs"] = o.Outputs + } + if !IsNil(o.Cosigner) { + toSerialize["cosigner"] = o.Cosigner + } + if !IsNil(o.CosignerData) { + toSerialize["cosignerData"] = o.CosignerData + } + if !IsNil(o.Cosignature) { + toSerialize["cosignature"] = o.Cosignature + } + if !IsNil(o.CreatedAt) { + toSerialize["createdAt"] = o.CreatedAt + } + if !IsNil(o.QuoteId) { + toSerialize["quoteId"] = o.QuoteId + } + if !IsNil(o.RequestId) { + toSerialize["requestId"] = o.RequestId + } + if !IsNil(o.TxHash) { + toSerialize["txHash"] = o.TxHash + } + if !IsNil(o.SettledAmounts) { + toSerialize["settledAmounts"] = o.SettledAmounts + } + if !IsNil(o.Route) { + toSerialize["route"] = o.Route + } + return toSerialize, nil +} + +func (o *HybridOrderEntity) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "type", + "encodedOrder", + "signature", + "orderHash", + "orderStatus", + "chainId", + "swapper", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varHybridOrderEntity := _HybridOrderEntity{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varHybridOrderEntity) + + if err != nil { + return err + } + + *o = HybridOrderEntity(varHybridOrderEntity) + + return err +} + +type NullableHybridOrderEntity struct { + value *HybridOrderEntity + isSet bool +} + +func (v NullableHybridOrderEntity) Get() *HybridOrderEntity { + return v.value +} + +func (v *NullableHybridOrderEntity) Set(val *HybridOrderEntity) { + v.value = val + v.isSet = true +} + +func (v NullableHybridOrderEntity) IsSet() bool { + return v.isSet +} + +func (v *NullableHybridOrderEntity) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableHybridOrderEntity(val *HybridOrderEntity) *NullableHybridOrderEntity { + return &NullableHybridOrderEntity{value: val, isSet: true} +} + +func (v NullableHybridOrderEntity) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableHybridOrderEntity) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_hybrid_order_entity_cosigner_data.go b/api/uniswapxservice/model_hybrid_order_entity_cosigner_data.go new file mode 100644 index 00000000..cd38f323 --- /dev/null +++ b/api/uniswapxservice/model_hybrid_order_entity_cosigner_data.go @@ -0,0 +1,160 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" +) + +// checks if the HybridOrderEntityCosignerData type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &HybridOrderEntityCosignerData{} + +// HybridOrderEntityCosignerData struct for HybridOrderEntityCosignerData +type HybridOrderEntityCosignerData struct { + AuctionTargetBlock *float32 `json:"auctionTargetBlock,omitempty"` + SupplementalPriceCurve []string `json:"supplementalPriceCurve,omitempty"` +} + +// NewHybridOrderEntityCosignerData instantiates a new HybridOrderEntityCosignerData object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewHybridOrderEntityCosignerData() *HybridOrderEntityCosignerData { + this := HybridOrderEntityCosignerData{} + return &this +} + +// NewHybridOrderEntityCosignerDataWithDefaults instantiates a new HybridOrderEntityCosignerData object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewHybridOrderEntityCosignerDataWithDefaults() *HybridOrderEntityCosignerData { + this := HybridOrderEntityCosignerData{} + return &this +} + +// GetAuctionTargetBlock returns the AuctionTargetBlock field value if set, zero value otherwise. +func (o *HybridOrderEntityCosignerData) GetAuctionTargetBlock() float32 { + if o == nil || IsNil(o.AuctionTargetBlock) { + var ret float32 + return ret + } + return *o.AuctionTargetBlock +} + +// GetAuctionTargetBlockOk returns a tuple with the AuctionTargetBlock field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntityCosignerData) GetAuctionTargetBlockOk() (*float32, bool) { + if o == nil || IsNil(o.AuctionTargetBlock) { + return nil, false + } + return o.AuctionTargetBlock, true +} + +// HasAuctionTargetBlock returns a boolean if a field has been set. +func (o *HybridOrderEntityCosignerData) HasAuctionTargetBlock() bool { + if o != nil && !IsNil(o.AuctionTargetBlock) { + return true + } + + return false +} + +// SetAuctionTargetBlock gets a reference to the given float32 and assigns it to the AuctionTargetBlock field. +func (o *HybridOrderEntityCosignerData) SetAuctionTargetBlock(v float32) { + o.AuctionTargetBlock = &v +} + +// GetSupplementalPriceCurve returns the SupplementalPriceCurve field value if set, zero value otherwise. +func (o *HybridOrderEntityCosignerData) GetSupplementalPriceCurve() []string { + if o == nil || IsNil(o.SupplementalPriceCurve) { + var ret []string + return ret + } + return o.SupplementalPriceCurve +} + +// GetSupplementalPriceCurveOk returns a tuple with the SupplementalPriceCurve field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *HybridOrderEntityCosignerData) GetSupplementalPriceCurveOk() ([]string, bool) { + if o == nil || IsNil(o.SupplementalPriceCurve) { + return nil, false + } + return o.SupplementalPriceCurve, true +} + +// HasSupplementalPriceCurve returns a boolean if a field has been set. +func (o *HybridOrderEntityCosignerData) HasSupplementalPriceCurve() bool { + if o != nil && !IsNil(o.SupplementalPriceCurve) { + return true + } + + return false +} + +// SetSupplementalPriceCurve gets a reference to the given []string and assigns it to the SupplementalPriceCurve field. +func (o *HybridOrderEntityCosignerData) SetSupplementalPriceCurve(v []string) { + o.SupplementalPriceCurve = v +} + +func (o HybridOrderEntityCosignerData) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o HybridOrderEntityCosignerData) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.AuctionTargetBlock) { + toSerialize["auctionTargetBlock"] = o.AuctionTargetBlock + } + if !IsNil(o.SupplementalPriceCurve) { + toSerialize["supplementalPriceCurve"] = o.SupplementalPriceCurve + } + return toSerialize, nil +} + +type NullableHybridOrderEntityCosignerData struct { + value *HybridOrderEntityCosignerData + isSet bool +} + +func (v NullableHybridOrderEntityCosignerData) Get() *HybridOrderEntityCosignerData { + return v.value +} + +func (v *NullableHybridOrderEntityCosignerData) Set(val *HybridOrderEntityCosignerData) { + v.value = val + v.isSet = true +} + +func (v NullableHybridOrderEntityCosignerData) IsSet() bool { + return v.isSet +} + +func (v *NullableHybridOrderEntityCosignerData) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableHybridOrderEntityCosignerData(val *HybridOrderEntityCosignerData) *NullableHybridOrderEntityCosignerData { + return &NullableHybridOrderEntityCosignerData{value: val, isSet: true} +} + +func (v NullableHybridOrderEntityCosignerData) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableHybridOrderEntityCosignerData) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_hybrid_order_entity_input.go b/api/uniswapxservice/model_hybrid_order_entity_input.go new file mode 100644 index 00000000..c6a99475 --- /dev/null +++ b/api/uniswapxservice/model_hybrid_order_entity_input.go @@ -0,0 +1,186 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the HybridOrderEntityInput type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &HybridOrderEntityInput{} + +// HybridOrderEntityInput struct for HybridOrderEntityInput +type HybridOrderEntityInput struct { + // EIP-55 checksummed Ethereum address. + Token string `json:"token"` + // uint256 encoded as a base-10 string. + MaxAmount string `json:"maxAmount" validate:"regexp=^[0-9]{1,78}$"` +} + +type _HybridOrderEntityInput HybridOrderEntityInput + +// NewHybridOrderEntityInput instantiates a new HybridOrderEntityInput object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewHybridOrderEntityInput(token string, maxAmount string) *HybridOrderEntityInput { + this := HybridOrderEntityInput{} + this.Token = token + this.MaxAmount = maxAmount + return &this +} + +// NewHybridOrderEntityInputWithDefaults instantiates a new HybridOrderEntityInput object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewHybridOrderEntityInputWithDefaults() *HybridOrderEntityInput { + this := HybridOrderEntityInput{} + return &this +} + +// GetToken returns the Token field value +func (o *HybridOrderEntityInput) GetToken() string { + if o == nil { + var ret string + return ret + } + + return o.Token +} + +// GetTokenOk returns a tuple with the Token field value +// and a boolean to check if the value has been set. +func (o *HybridOrderEntityInput) GetTokenOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Token, true +} + +// SetToken sets field value +func (o *HybridOrderEntityInput) SetToken(v string) { + o.Token = v +} + +// GetMaxAmount returns the MaxAmount field value +func (o *HybridOrderEntityInput) GetMaxAmount() string { + if o == nil { + var ret string + return ret + } + + return o.MaxAmount +} + +// GetMaxAmountOk returns a tuple with the MaxAmount field value +// and a boolean to check if the value has been set. +func (o *HybridOrderEntityInput) GetMaxAmountOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.MaxAmount, true +} + +// SetMaxAmount sets field value +func (o *HybridOrderEntityInput) SetMaxAmount(v string) { + o.MaxAmount = v +} + +func (o HybridOrderEntityInput) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o HybridOrderEntityInput) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["token"] = o.Token + toSerialize["maxAmount"] = o.MaxAmount + return toSerialize, nil +} + +func (o *HybridOrderEntityInput) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "token", + "maxAmount", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varHybridOrderEntityInput := _HybridOrderEntityInput{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varHybridOrderEntityInput) + + if err != nil { + return err + } + + *o = HybridOrderEntityInput(varHybridOrderEntityInput) + + return err +} + +type NullableHybridOrderEntityInput struct { + value *HybridOrderEntityInput + isSet bool +} + +func (v NullableHybridOrderEntityInput) Get() *HybridOrderEntityInput { + return v.value +} + +func (v *NullableHybridOrderEntityInput) Set(val *HybridOrderEntityInput) { + v.value = val + v.isSet = true +} + +func (v NullableHybridOrderEntityInput) IsSet() bool { + return v.isSet +} + +func (v *NullableHybridOrderEntityInput) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableHybridOrderEntityInput(val *HybridOrderEntityInput) *NullableHybridOrderEntityInput { + return &NullableHybridOrderEntityInput{value: val, isSet: true} +} + +func (v NullableHybridOrderEntityInput) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableHybridOrderEntityInput) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_hybrid_order_entity_outputs_inner.go b/api/uniswapxservice/model_hybrid_order_entity_outputs_inner.go new file mode 100644 index 00000000..3c8f712c --- /dev/null +++ b/api/uniswapxservice/model_hybrid_order_entity_outputs_inner.go @@ -0,0 +1,215 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the HybridOrderEntityOutputsInner type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &HybridOrderEntityOutputsInner{} + +// HybridOrderEntityOutputsInner struct for HybridOrderEntityOutputsInner +type HybridOrderEntityOutputsInner struct { + // EIP-55 checksummed Ethereum address. + Token string `json:"token"` + // uint256 encoded as a base-10 string. + MinAmount string `json:"minAmount" validate:"regexp=^[0-9]{1,78}$"` + // EIP-55 checksummed Ethereum address. + Recipient string `json:"recipient"` +} + +type _HybridOrderEntityOutputsInner HybridOrderEntityOutputsInner + +// NewHybridOrderEntityOutputsInner instantiates a new HybridOrderEntityOutputsInner object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewHybridOrderEntityOutputsInner(token string, minAmount string, recipient string) *HybridOrderEntityOutputsInner { + this := HybridOrderEntityOutputsInner{} + this.Token = token + this.MinAmount = minAmount + this.Recipient = recipient + return &this +} + +// NewHybridOrderEntityOutputsInnerWithDefaults instantiates a new HybridOrderEntityOutputsInner object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewHybridOrderEntityOutputsInnerWithDefaults() *HybridOrderEntityOutputsInner { + this := HybridOrderEntityOutputsInner{} + return &this +} + +// GetToken returns the Token field value +func (o *HybridOrderEntityOutputsInner) GetToken() string { + if o == nil { + var ret string + return ret + } + + return o.Token +} + +// GetTokenOk returns a tuple with the Token field value +// and a boolean to check if the value has been set. +func (o *HybridOrderEntityOutputsInner) GetTokenOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Token, true +} + +// SetToken sets field value +func (o *HybridOrderEntityOutputsInner) SetToken(v string) { + o.Token = v +} + +// GetMinAmount returns the MinAmount field value +func (o *HybridOrderEntityOutputsInner) GetMinAmount() string { + if o == nil { + var ret string + return ret + } + + return o.MinAmount +} + +// GetMinAmountOk returns a tuple with the MinAmount field value +// and a boolean to check if the value has been set. +func (o *HybridOrderEntityOutputsInner) GetMinAmountOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.MinAmount, true +} + +// SetMinAmount sets field value +func (o *HybridOrderEntityOutputsInner) SetMinAmount(v string) { + o.MinAmount = v +} + +// GetRecipient returns the Recipient field value +func (o *HybridOrderEntityOutputsInner) GetRecipient() string { + if o == nil { + var ret string + return ret + } + + return o.Recipient +} + +// GetRecipientOk returns a tuple with the Recipient field value +// and a boolean to check if the value has been set. +func (o *HybridOrderEntityOutputsInner) GetRecipientOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Recipient, true +} + +// SetRecipient sets field value +func (o *HybridOrderEntityOutputsInner) SetRecipient(v string) { + o.Recipient = v +} + +func (o HybridOrderEntityOutputsInner) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o HybridOrderEntityOutputsInner) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["token"] = o.Token + toSerialize["minAmount"] = o.MinAmount + toSerialize["recipient"] = o.Recipient + return toSerialize, nil +} + +func (o *HybridOrderEntityOutputsInner) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "token", + "minAmount", + "recipient", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varHybridOrderEntityOutputsInner := _HybridOrderEntityOutputsInner{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varHybridOrderEntityOutputsInner) + + if err != nil { + return err + } + + *o = HybridOrderEntityOutputsInner(varHybridOrderEntityOutputsInner) + + return err +} + +type NullableHybridOrderEntityOutputsInner struct { + value *HybridOrderEntityOutputsInner + isSet bool +} + +func (v NullableHybridOrderEntityOutputsInner) Get() *HybridOrderEntityOutputsInner { + return v.value +} + +func (v *NullableHybridOrderEntityOutputsInner) Set(val *HybridOrderEntityOutputsInner) { + v.value = val + v.isSet = true +} + +func (v NullableHybridOrderEntityOutputsInner) IsSet() bool { + return v.isSet +} + +func (v *NullableHybridOrderEntityOutputsInner) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableHybridOrderEntityOutputsInner(val *HybridOrderEntityOutputsInner) *NullableHybridOrderEntityOutputsInner { + return &NullableHybridOrderEntityOutputsInner{value: val, isSet: true} +} + +func (v NullableHybridOrderEntityOutputsInner) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableHybridOrderEntityOutputsInner) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_nonlinear_dutch_decay_curve.go b/api/uniswapxservice/model_nonlinear_dutch_decay_curve.go new file mode 100644 index 00000000..79f57981 --- /dev/null +++ b/api/uniswapxservice/model_nonlinear_dutch_decay_curve.go @@ -0,0 +1,160 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" +) + +// checks if the NonlinearDutchDecayCurve type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &NonlinearDutchDecayCurve{} + +// NonlinearDutchDecayCurve Piecewise decay curve relative to the decay start block. +type NonlinearDutchDecayCurve struct { + RelativeBlocks []float32 `json:"relativeBlocks,omitempty"` + RelativeAmounts []string `json:"relativeAmounts,omitempty"` +} + +// NewNonlinearDutchDecayCurve instantiates a new NonlinearDutchDecayCurve object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewNonlinearDutchDecayCurve() *NonlinearDutchDecayCurve { + this := NonlinearDutchDecayCurve{} + return &this +} + +// NewNonlinearDutchDecayCurveWithDefaults instantiates a new NonlinearDutchDecayCurve object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewNonlinearDutchDecayCurveWithDefaults() *NonlinearDutchDecayCurve { + this := NonlinearDutchDecayCurve{} + return &this +} + +// GetRelativeBlocks returns the RelativeBlocks field value if set, zero value otherwise. +func (o *NonlinearDutchDecayCurve) GetRelativeBlocks() []float32 { + if o == nil || IsNil(o.RelativeBlocks) { + var ret []float32 + return ret + } + return o.RelativeBlocks +} + +// GetRelativeBlocksOk returns a tuple with the RelativeBlocks field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *NonlinearDutchDecayCurve) GetRelativeBlocksOk() ([]float32, bool) { + if o == nil || IsNil(o.RelativeBlocks) { + return nil, false + } + return o.RelativeBlocks, true +} + +// HasRelativeBlocks returns a boolean if a field has been set. +func (o *NonlinearDutchDecayCurve) HasRelativeBlocks() bool { + if o != nil && !IsNil(o.RelativeBlocks) { + return true + } + + return false +} + +// SetRelativeBlocks gets a reference to the given []float32 and assigns it to the RelativeBlocks field. +func (o *NonlinearDutchDecayCurve) SetRelativeBlocks(v []float32) { + o.RelativeBlocks = v +} + +// GetRelativeAmounts returns the RelativeAmounts field value if set, zero value otherwise. +func (o *NonlinearDutchDecayCurve) GetRelativeAmounts() []string { + if o == nil || IsNil(o.RelativeAmounts) { + var ret []string + return ret + } + return o.RelativeAmounts +} + +// GetRelativeAmountsOk returns a tuple with the RelativeAmounts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *NonlinearDutchDecayCurve) GetRelativeAmountsOk() ([]string, bool) { + if o == nil || IsNil(o.RelativeAmounts) { + return nil, false + } + return o.RelativeAmounts, true +} + +// HasRelativeAmounts returns a boolean if a field has been set. +func (o *NonlinearDutchDecayCurve) HasRelativeAmounts() bool { + if o != nil && !IsNil(o.RelativeAmounts) { + return true + } + + return false +} + +// SetRelativeAmounts gets a reference to the given []string and assigns it to the RelativeAmounts field. +func (o *NonlinearDutchDecayCurve) SetRelativeAmounts(v []string) { + o.RelativeAmounts = v +} + +func (o NonlinearDutchDecayCurve) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o NonlinearDutchDecayCurve) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.RelativeBlocks) { + toSerialize["relativeBlocks"] = o.RelativeBlocks + } + if !IsNil(o.RelativeAmounts) { + toSerialize["relativeAmounts"] = o.RelativeAmounts + } + return toSerialize, nil +} + +type NullableNonlinearDutchDecayCurve struct { + value *NonlinearDutchDecayCurve + isSet bool +} + +func (v NullableNonlinearDutchDecayCurve) Get() *NonlinearDutchDecayCurve { + return v.value +} + +func (v *NullableNonlinearDutchDecayCurve) Set(val *NonlinearDutchDecayCurve) { + v.value = val + v.isSet = true +} + +func (v NullableNonlinearDutchDecayCurve) IsSet() bool { + return v.isSet +} + +func (v *NullableNonlinearDutchDecayCurve) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableNonlinearDutchDecayCurve(val *NonlinearDutchDecayCurve) *NullableNonlinearDutchDecayCurve { + return &NullableNonlinearDutchDecayCurve{value: val, isSet: true} +} + +func (v NullableNonlinearDutchDecayCurve) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableNonlinearDutchDecayCurve) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_order_input.go b/api/uniswapxservice/model_order_input.go new file mode 100644 index 00000000..3126dc2e --- /dev/null +++ b/api/uniswapxservice/model_order_input.go @@ -0,0 +1,231 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the OrderInput type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OrderInput{} + +// OrderInput struct for OrderInput +type OrderInput struct { + // EIP-55 checksummed Ethereum address. + Token string `json:"token"` + // uint256 encoded as a base-10 string. + StartAmount *string `json:"startAmount,omitempty" validate:"regexp=^[0-9]{1,78}$"` + // uint256 encoded as a base-10 string. + EndAmount *string `json:"endAmount,omitempty" validate:"regexp=^[0-9]{1,78}$"` +} + +type _OrderInput OrderInput + +// NewOrderInput instantiates a new OrderInput object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewOrderInput(token string) *OrderInput { + this := OrderInput{} + this.Token = token + return &this +} + +// NewOrderInputWithDefaults instantiates a new OrderInput object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewOrderInputWithDefaults() *OrderInput { + this := OrderInput{} + return &this +} + +// GetToken returns the Token field value +func (o *OrderInput) GetToken() string { + if o == nil { + var ret string + return ret + } + + return o.Token +} + +// GetTokenOk returns a tuple with the Token field value +// and a boolean to check if the value has been set. +func (o *OrderInput) GetTokenOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Token, true +} + +// SetToken sets field value +func (o *OrderInput) SetToken(v string) { + o.Token = v +} + +// GetStartAmount returns the StartAmount field value if set, zero value otherwise. +func (o *OrderInput) GetStartAmount() string { + if o == nil || IsNil(o.StartAmount) { + var ret string + return ret + } + return *o.StartAmount +} + +// GetStartAmountOk returns a tuple with the StartAmount field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OrderInput) GetStartAmountOk() (*string, bool) { + if o == nil || IsNil(o.StartAmount) { + return nil, false + } + return o.StartAmount, true +} + +// HasStartAmount returns a boolean if a field has been set. +func (o *OrderInput) HasStartAmount() bool { + if o != nil && !IsNil(o.StartAmount) { + return true + } + + return false +} + +// SetStartAmount gets a reference to the given string and assigns it to the StartAmount field. +func (o *OrderInput) SetStartAmount(v string) { + o.StartAmount = &v +} + +// GetEndAmount returns the EndAmount field value if set, zero value otherwise. +func (o *OrderInput) GetEndAmount() string { + if o == nil || IsNil(o.EndAmount) { + var ret string + return ret + } + return *o.EndAmount +} + +// GetEndAmountOk returns a tuple with the EndAmount field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *OrderInput) GetEndAmountOk() (*string, bool) { + if o == nil || IsNil(o.EndAmount) { + return nil, false + } + return o.EndAmount, true +} + +// HasEndAmount returns a boolean if a field has been set. +func (o *OrderInput) HasEndAmount() bool { + if o != nil && !IsNil(o.EndAmount) { + return true + } + + return false +} + +// SetEndAmount gets a reference to the given string and assigns it to the EndAmount field. +func (o *OrderInput) SetEndAmount(v string) { + o.EndAmount = &v +} + +func (o OrderInput) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o OrderInput) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["token"] = o.Token + if !IsNil(o.StartAmount) { + toSerialize["startAmount"] = o.StartAmount + } + if !IsNil(o.EndAmount) { + toSerialize["endAmount"] = o.EndAmount + } + return toSerialize, nil +} + +func (o *OrderInput) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "token", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varOrderInput := _OrderInput{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varOrderInput) + + if err != nil { + return err + } + + *o = OrderInput(varOrderInput) + + return err +} + +type NullableOrderInput struct { + value *OrderInput + isSet bool +} + +func (v NullableOrderInput) Get() *OrderInput { + return v.value +} + +func (v *NullableOrderInput) Set(val *OrderInput) { + v.value = val + v.isSet = true +} + +func (v NullableOrderInput) IsSet() bool { + return v.isSet +} + +func (v *NullableOrderInput) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOrderInput(val *OrderInput) *NullableOrderInput { + return &NullableOrderInput{value: val, isSet: true} +} + +func (v NullableOrderInput) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOrderInput) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_order_output.go b/api/uniswapxservice/model_order_output.go new file mode 100644 index 00000000..7913f9a5 --- /dev/null +++ b/api/uniswapxservice/model_order_output.go @@ -0,0 +1,244 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the OrderOutput type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &OrderOutput{} + +// OrderOutput struct for OrderOutput +type OrderOutput struct { + // EIP-55 checksummed Ethereum address. + Token string `json:"token"` + // uint256 encoded as a base-10 string. + StartAmount string `json:"startAmount" validate:"regexp=^[0-9]{1,78}$"` + // uint256 encoded as a base-10 string. + EndAmount string `json:"endAmount" validate:"regexp=^[0-9]{1,78}$"` + // EIP-55 checksummed Ethereum address. + Recipient string `json:"recipient"` +} + +type _OrderOutput OrderOutput + +// NewOrderOutput instantiates a new OrderOutput object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewOrderOutput(token string, startAmount string, endAmount string, recipient string) *OrderOutput { + this := OrderOutput{} + this.Token = token + this.StartAmount = startAmount + this.EndAmount = endAmount + this.Recipient = recipient + return &this +} + +// NewOrderOutputWithDefaults instantiates a new OrderOutput object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewOrderOutputWithDefaults() *OrderOutput { + this := OrderOutput{} + return &this +} + +// GetToken returns the Token field value +func (o *OrderOutput) GetToken() string { + if o == nil { + var ret string + return ret + } + + return o.Token +} + +// GetTokenOk returns a tuple with the Token field value +// and a boolean to check if the value has been set. +func (o *OrderOutput) GetTokenOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Token, true +} + +// SetToken sets field value +func (o *OrderOutput) SetToken(v string) { + o.Token = v +} + +// GetStartAmount returns the StartAmount field value +func (o *OrderOutput) GetStartAmount() string { + if o == nil { + var ret string + return ret + } + + return o.StartAmount +} + +// GetStartAmountOk returns a tuple with the StartAmount field value +// and a boolean to check if the value has been set. +func (o *OrderOutput) GetStartAmountOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.StartAmount, true +} + +// SetStartAmount sets field value +func (o *OrderOutput) SetStartAmount(v string) { + o.StartAmount = v +} + +// GetEndAmount returns the EndAmount field value +func (o *OrderOutput) GetEndAmount() string { + if o == nil { + var ret string + return ret + } + + return o.EndAmount +} + +// GetEndAmountOk returns a tuple with the EndAmount field value +// and a boolean to check if the value has been set. +func (o *OrderOutput) GetEndAmountOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.EndAmount, true +} + +// SetEndAmount sets field value +func (o *OrderOutput) SetEndAmount(v string) { + o.EndAmount = v +} + +// GetRecipient returns the Recipient field value +func (o *OrderOutput) GetRecipient() string { + if o == nil { + var ret string + return ret + } + + return o.Recipient +} + +// GetRecipientOk returns a tuple with the Recipient field value +// and a boolean to check if the value has been set. +func (o *OrderOutput) GetRecipientOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Recipient, true +} + +// SetRecipient sets field value +func (o *OrderOutput) SetRecipient(v string) { + o.Recipient = v +} + +func (o OrderOutput) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o OrderOutput) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["token"] = o.Token + toSerialize["startAmount"] = o.StartAmount + toSerialize["endAmount"] = o.EndAmount + toSerialize["recipient"] = o.Recipient + return toSerialize, nil +} + +func (o *OrderOutput) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "token", + "startAmount", + "endAmount", + "recipient", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varOrderOutput := _OrderOutput{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varOrderOutput) + + if err != nil { + return err + } + + *o = OrderOutput(varOrderOutput) + + return err +} + +type NullableOrderOutput struct { + value *OrderOutput + isSet bool +} + +func (v NullableOrderOutput) Get() *OrderOutput { + return v.value +} + +func (v *NullableOrderOutput) Set(val *OrderOutput) { + v.value = val + v.isSet = true +} + +func (v NullableOrderOutput) IsSet() bool { + return v.isSet +} + +func (v *NullableOrderOutput) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOrderOutput(val *OrderOutput) *NullableOrderOutput { + return &NullableOrderOutput{value: val, isSet: true} +} + +func (v NullableOrderOutput) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOrderOutput) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_order_status.go b/api/uniswapxservice/model_order_status.go new file mode 100644 index 00000000..eef813dd --- /dev/null +++ b/api/uniswapxservice/model_order_status.go @@ -0,0 +1,118 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" + "fmt" +) + +// OrderStatus the model 'OrderStatus' +type OrderStatus string + +// List of OrderStatus +const ( + OPEN OrderStatus = "open" + EXPIRED OrderStatus = "expired" + ERROR OrderStatus = "error" + CANCELLED OrderStatus = "cancelled" + FILLED OrderStatus = "filled" + INSUFFICIENT_FUNDS OrderStatus = "insufficient-funds" +) + +// All allowed values of OrderStatus enum +var AllowedOrderStatusEnumValues = []OrderStatus{ + "open", + "expired", + "error", + "cancelled", + "filled", + "insufficient-funds", +} + +func (v *OrderStatus) UnmarshalJSON(src []byte) error { + var value string + err := json.Unmarshal(src, &value) + if err != nil { + return err + } + enumTypeValue := OrderStatus(value) + for _, existing := range AllowedOrderStatusEnumValues { + if existing == enumTypeValue { + *v = enumTypeValue + return nil + } + } + + return fmt.Errorf("%+v is not a valid OrderStatus", value) +} + +// NewOrderStatusFromValue returns a pointer to a valid OrderStatus +// for the value passed as argument, or an error if the value passed is not allowed by the enum +func NewOrderStatusFromValue(v string) (*OrderStatus, error) { + ev := OrderStatus(v) + if ev.IsValid() { + return &ev, nil + } else { + return nil, fmt.Errorf("invalid value '%v' for OrderStatus: valid values are %v", v, AllowedOrderStatusEnumValues) + } +} + +// IsValid return true if the value is valid for the enum, false otherwise +func (v OrderStatus) IsValid() bool { + for _, existing := range AllowedOrderStatusEnumValues { + if existing == v { + return true + } + } + return false +} + +// Ptr returns reference to OrderStatus value +func (v OrderStatus) Ptr() *OrderStatus { + return &v +} + +type NullableOrderStatus struct { + value *OrderStatus + isSet bool +} + +func (v NullableOrderStatus) Get() *OrderStatus { + return v.value +} + +func (v *NullableOrderStatus) Set(val *OrderStatus) { + v.value = val + v.isSet = true +} + +func (v NullableOrderStatus) IsSet() bool { + return v.isSet +} + +func (v *NullableOrderStatus) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOrderStatus(val *OrderStatus) *NullableOrderStatus { + return &NullableOrderStatus{value: val, isSet: true} +} + +func (v NullableOrderStatus) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOrderStatus) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_order_type_query.go b/api/uniswapxservice/model_order_type_query.go new file mode 100644 index 00000000..e4fa6535 --- /dev/null +++ b/api/uniswapxservice/model_order_type_query.go @@ -0,0 +1,122 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" + "fmt" +) + +// OrderTypeQuery Order type accepted by the orderType query parameter. Dutch_V1_V2 returns both Dutch V1 and Dutch V2 orders. +type OrderTypeQuery string + +// List of OrderTypeQuery +const ( + DUTCH OrderTypeQuery = "Dutch" + DUTCH_V2 OrderTypeQuery = "Dutch_V2" + DUTCH_V3 OrderTypeQuery = "Dutch_V3" + LIMIT OrderTypeQuery = "Limit" + RELAY OrderTypeQuery = "Relay" + DUTCH_V1_V2 OrderTypeQuery = "Dutch_V1_V2" + PRIORITY OrderTypeQuery = "Priority" + HYBRID OrderTypeQuery = "Hybrid" +) + +// All allowed values of OrderTypeQuery enum +var AllowedOrderTypeQueryEnumValues = []OrderTypeQuery{ + "Dutch", + "Dutch_V2", + "Dutch_V3", + "Limit", + "Relay", + "Dutch_V1_V2", + "Priority", + "Hybrid", +} + +func (v *OrderTypeQuery) UnmarshalJSON(src []byte) error { + var value string + err := json.Unmarshal(src, &value) + if err != nil { + return err + } + enumTypeValue := OrderTypeQuery(value) + for _, existing := range AllowedOrderTypeQueryEnumValues { + if existing == enumTypeValue { + *v = enumTypeValue + return nil + } + } + + return fmt.Errorf("%+v is not a valid OrderTypeQuery", value) +} + +// NewOrderTypeQueryFromValue returns a pointer to a valid OrderTypeQuery +// for the value passed as argument, or an error if the value passed is not allowed by the enum +func NewOrderTypeQueryFromValue(v string) (*OrderTypeQuery, error) { + ev := OrderTypeQuery(v) + if ev.IsValid() { + return &ev, nil + } else { + return nil, fmt.Errorf("invalid value '%v' for OrderTypeQuery: valid values are %v", v, AllowedOrderTypeQueryEnumValues) + } +} + +// IsValid return true if the value is valid for the enum, false otherwise +func (v OrderTypeQuery) IsValid() bool { + for _, existing := range AllowedOrderTypeQueryEnumValues { + if existing == v { + return true + } + } + return false +} + +// Ptr returns reference to OrderTypeQuery value +func (v OrderTypeQuery) Ptr() *OrderTypeQuery { + return &v +} + +type NullableOrderTypeQuery struct { + value *OrderTypeQuery + isSet bool +} + +func (v NullableOrderTypeQuery) Get() *OrderTypeQuery { + return v.value +} + +func (v *NullableOrderTypeQuery) Set(val *OrderTypeQuery) { + v.value = val + v.isSet = true +} + +func (v NullableOrderTypeQuery) IsSet() bool { + return v.isSet +} + +func (v *NullableOrderTypeQuery) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableOrderTypeQuery(val *OrderTypeQuery) *NullableOrderTypeQuery { + return &NullableOrderTypeQuery{value: val, isSet: true} +} + +func (v NullableOrderTypeQuery) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableOrderTypeQuery) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_priority_order_entity.go b/api/uniswapxservice/model_priority_order_entity.go new file mode 100644 index 00000000..f61d5105 --- /dev/null +++ b/api/uniswapxservice/model_priority_order_entity.go @@ -0,0 +1,801 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the PriorityOrderEntity type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &PriorityOrderEntity{} + +// PriorityOrderEntity Priority orders: amounts scale with the transaction's priority fee. +type PriorityOrderEntity struct { + Type string `json:"type"` + // ABI-encoded order struct. Decode with the uniswapx-sdk parser for the order's type. + EncodedOrder string `json:"encodedOrder" validate:"regexp=^0x[0-9a-fA-F]*$"` + // EIP-712 signature over the order. + Signature string `json:"signature" validate:"regexp=^0x[0-9a-fA-F]{130}$"` + // Permit2 nonce, uint256 encoded as a base-10 string. + Nonce *string `json:"nonce,omitempty" validate:"regexp=^[0-9]{1,78}$"` + OrderHash string `json:"orderHash" validate:"regexp=^0x[0-9a-fA-F]{64}$"` + OrderStatus OrderStatus `json:"orderStatus"` + ChainId ChainId `json:"chainId"` + // EIP-55 checksummed Ethereum address. + Swapper string `json:"swapper"` + AuctionStartBlock *float32 `json:"auctionStartBlock,omitempty"` + // uint256 encoded as a base-10 string. + BaselinePriorityFeeWei *string `json:"baselinePriorityFeeWei,omitempty" validate:"regexp=^[0-9]{1,78}$"` + Input *PriorityOrderEntityInput `json:"input,omitempty"` + Outputs []PriorityOrderEntityOutputsInner `json:"outputs,omitempty"` + CosignerData *PriorityOrderEntityCosignerData `json:"cosignerData,omitempty"` + Cosignature *string `json:"cosignature,omitempty"` + // Unix timestamp (seconds) at which the order was recorded. + CreatedAt *float32 `json:"createdAt,omitempty"` + // Defined when the order has a quote associated with it. + QuoteId *string `json:"quoteId,omitempty"` + // Defined when the order has a quote request associated with it. + RequestId *string `json:"requestId,omitempty"` + // Transaction hash of the fill. Defined once the order has been filled. + TxHash *string `json:"txHash,omitempty" validate:"regexp=^0x[0-9a-fA-F]{64}$"` + SettledAmounts []SettledAmount `json:"settledAmounts,omitempty"` + Route *Route `json:"route,omitempty"` +} + +type _PriorityOrderEntity PriorityOrderEntity + +// NewPriorityOrderEntity instantiates a new PriorityOrderEntity object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewPriorityOrderEntity(type_ string, encodedOrder string, signature string, orderHash string, orderStatus OrderStatus, chainId ChainId, swapper string) *PriorityOrderEntity { + this := PriorityOrderEntity{} + this.Type = type_ + this.EncodedOrder = encodedOrder + this.Signature = signature + this.OrderHash = orderHash + this.OrderStatus = orderStatus + this.ChainId = chainId + this.Swapper = swapper + return &this +} + +// NewPriorityOrderEntityWithDefaults instantiates a new PriorityOrderEntity object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewPriorityOrderEntityWithDefaults() *PriorityOrderEntity { + this := PriorityOrderEntity{} + return &this +} + +// GetType returns the Type field value +func (o *PriorityOrderEntity) GetType() string { + if o == nil { + var ret string + return ret + } + + return o.Type +} + +// GetTypeOk returns a tuple with the Type field value +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Type, true +} + +// SetType sets field value +func (o *PriorityOrderEntity) SetType(v string) { + o.Type = v +} + +// GetEncodedOrder returns the EncodedOrder field value +func (o *PriorityOrderEntity) GetEncodedOrder() string { + if o == nil { + var ret string + return ret + } + + return o.EncodedOrder +} + +// GetEncodedOrderOk returns a tuple with the EncodedOrder field value +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetEncodedOrderOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.EncodedOrder, true +} + +// SetEncodedOrder sets field value +func (o *PriorityOrderEntity) SetEncodedOrder(v string) { + o.EncodedOrder = v +} + +// GetSignature returns the Signature field value +func (o *PriorityOrderEntity) GetSignature() string { + if o == nil { + var ret string + return ret + } + + return o.Signature +} + +// GetSignatureOk returns a tuple with the Signature field value +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetSignatureOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Signature, true +} + +// SetSignature sets field value +func (o *PriorityOrderEntity) SetSignature(v string) { + o.Signature = v +} + +// GetNonce returns the Nonce field value if set, zero value otherwise. +func (o *PriorityOrderEntity) GetNonce() string { + if o == nil || IsNil(o.Nonce) { + var ret string + return ret + } + return *o.Nonce +} + +// GetNonceOk returns a tuple with the Nonce field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetNonceOk() (*string, bool) { + if o == nil || IsNil(o.Nonce) { + return nil, false + } + return o.Nonce, true +} + +// HasNonce returns a boolean if a field has been set. +func (o *PriorityOrderEntity) HasNonce() bool { + if o != nil && !IsNil(o.Nonce) { + return true + } + + return false +} + +// SetNonce gets a reference to the given string and assigns it to the Nonce field. +func (o *PriorityOrderEntity) SetNonce(v string) { + o.Nonce = &v +} + +// GetOrderHash returns the OrderHash field value +func (o *PriorityOrderEntity) GetOrderHash() string { + if o == nil { + var ret string + return ret + } + + return o.OrderHash +} + +// GetOrderHashOk returns a tuple with the OrderHash field value +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetOrderHashOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.OrderHash, true +} + +// SetOrderHash sets field value +func (o *PriorityOrderEntity) SetOrderHash(v string) { + o.OrderHash = v +} + +// GetOrderStatus returns the OrderStatus field value +func (o *PriorityOrderEntity) GetOrderStatus() OrderStatus { + if o == nil { + var ret OrderStatus + return ret + } + + return o.OrderStatus +} + +// GetOrderStatusOk returns a tuple with the OrderStatus field value +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetOrderStatusOk() (*OrderStatus, bool) { + if o == nil { + return nil, false + } + return &o.OrderStatus, true +} + +// SetOrderStatus sets field value +func (o *PriorityOrderEntity) SetOrderStatus(v OrderStatus) { + o.OrderStatus = v +} + +// GetChainId returns the ChainId field value +func (o *PriorityOrderEntity) GetChainId() ChainId { + if o == nil { + var ret ChainId + return ret + } + + return o.ChainId +} + +// GetChainIdOk returns a tuple with the ChainId field value +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetChainIdOk() (*ChainId, bool) { + if o == nil { + return nil, false + } + return &o.ChainId, true +} + +// SetChainId sets field value +func (o *PriorityOrderEntity) SetChainId(v ChainId) { + o.ChainId = v +} + +// GetSwapper returns the Swapper field value +func (o *PriorityOrderEntity) GetSwapper() string { + if o == nil { + var ret string + return ret + } + + return o.Swapper +} + +// GetSwapperOk returns a tuple with the Swapper field value +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetSwapperOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Swapper, true +} + +// SetSwapper sets field value +func (o *PriorityOrderEntity) SetSwapper(v string) { + o.Swapper = v +} + +// GetAuctionStartBlock returns the AuctionStartBlock field value if set, zero value otherwise. +func (o *PriorityOrderEntity) GetAuctionStartBlock() float32 { + if o == nil || IsNil(o.AuctionStartBlock) { + var ret float32 + return ret + } + return *o.AuctionStartBlock +} + +// GetAuctionStartBlockOk returns a tuple with the AuctionStartBlock field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetAuctionStartBlockOk() (*float32, bool) { + if o == nil || IsNil(o.AuctionStartBlock) { + return nil, false + } + return o.AuctionStartBlock, true +} + +// HasAuctionStartBlock returns a boolean if a field has been set. +func (o *PriorityOrderEntity) HasAuctionStartBlock() bool { + if o != nil && !IsNil(o.AuctionStartBlock) { + return true + } + + return false +} + +// SetAuctionStartBlock gets a reference to the given float32 and assigns it to the AuctionStartBlock field. +func (o *PriorityOrderEntity) SetAuctionStartBlock(v float32) { + o.AuctionStartBlock = &v +} + +// GetBaselinePriorityFeeWei returns the BaselinePriorityFeeWei field value if set, zero value otherwise. +func (o *PriorityOrderEntity) GetBaselinePriorityFeeWei() string { + if o == nil || IsNil(o.BaselinePriorityFeeWei) { + var ret string + return ret + } + return *o.BaselinePriorityFeeWei +} + +// GetBaselinePriorityFeeWeiOk returns a tuple with the BaselinePriorityFeeWei field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetBaselinePriorityFeeWeiOk() (*string, bool) { + if o == nil || IsNil(o.BaselinePriorityFeeWei) { + return nil, false + } + return o.BaselinePriorityFeeWei, true +} + +// HasBaselinePriorityFeeWei returns a boolean if a field has been set. +func (o *PriorityOrderEntity) HasBaselinePriorityFeeWei() bool { + if o != nil && !IsNil(o.BaselinePriorityFeeWei) { + return true + } + + return false +} + +// SetBaselinePriorityFeeWei gets a reference to the given string and assigns it to the BaselinePriorityFeeWei field. +func (o *PriorityOrderEntity) SetBaselinePriorityFeeWei(v string) { + o.BaselinePriorityFeeWei = &v +} + +// GetInput returns the Input field value if set, zero value otherwise. +func (o *PriorityOrderEntity) GetInput() PriorityOrderEntityInput { + if o == nil || IsNil(o.Input) { + var ret PriorityOrderEntityInput + return ret + } + return *o.Input +} + +// GetInputOk returns a tuple with the Input field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetInputOk() (*PriorityOrderEntityInput, bool) { + if o == nil || IsNil(o.Input) { + return nil, false + } + return o.Input, true +} + +// HasInput returns a boolean if a field has been set. +func (o *PriorityOrderEntity) HasInput() bool { + if o != nil && !IsNil(o.Input) { + return true + } + + return false +} + +// SetInput gets a reference to the given PriorityOrderEntityInput and assigns it to the Input field. +func (o *PriorityOrderEntity) SetInput(v PriorityOrderEntityInput) { + o.Input = &v +} + +// GetOutputs returns the Outputs field value if set, zero value otherwise. +func (o *PriorityOrderEntity) GetOutputs() []PriorityOrderEntityOutputsInner { + if o == nil || IsNil(o.Outputs) { + var ret []PriorityOrderEntityOutputsInner + return ret + } + return o.Outputs +} + +// GetOutputsOk returns a tuple with the Outputs field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetOutputsOk() ([]PriorityOrderEntityOutputsInner, bool) { + if o == nil || IsNil(o.Outputs) { + return nil, false + } + return o.Outputs, true +} + +// HasOutputs returns a boolean if a field has been set. +func (o *PriorityOrderEntity) HasOutputs() bool { + if o != nil && !IsNil(o.Outputs) { + return true + } + + return false +} + +// SetOutputs gets a reference to the given []PriorityOrderEntityOutputsInner and assigns it to the Outputs field. +func (o *PriorityOrderEntity) SetOutputs(v []PriorityOrderEntityOutputsInner) { + o.Outputs = v +} + +// GetCosignerData returns the CosignerData field value if set, zero value otherwise. +func (o *PriorityOrderEntity) GetCosignerData() PriorityOrderEntityCosignerData { + if o == nil || IsNil(o.CosignerData) { + var ret PriorityOrderEntityCosignerData + return ret + } + return *o.CosignerData +} + +// GetCosignerDataOk returns a tuple with the CosignerData field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetCosignerDataOk() (*PriorityOrderEntityCosignerData, bool) { + if o == nil || IsNil(o.CosignerData) { + return nil, false + } + return o.CosignerData, true +} + +// HasCosignerData returns a boolean if a field has been set. +func (o *PriorityOrderEntity) HasCosignerData() bool { + if o != nil && !IsNil(o.CosignerData) { + return true + } + + return false +} + +// SetCosignerData gets a reference to the given PriorityOrderEntityCosignerData and assigns it to the CosignerData field. +func (o *PriorityOrderEntity) SetCosignerData(v PriorityOrderEntityCosignerData) { + o.CosignerData = &v +} + +// GetCosignature returns the Cosignature field value if set, zero value otherwise. +func (o *PriorityOrderEntity) GetCosignature() string { + if o == nil || IsNil(o.Cosignature) { + var ret string + return ret + } + return *o.Cosignature +} + +// GetCosignatureOk returns a tuple with the Cosignature field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetCosignatureOk() (*string, bool) { + if o == nil || IsNil(o.Cosignature) { + return nil, false + } + return o.Cosignature, true +} + +// HasCosignature returns a boolean if a field has been set. +func (o *PriorityOrderEntity) HasCosignature() bool { + if o != nil && !IsNil(o.Cosignature) { + return true + } + + return false +} + +// SetCosignature gets a reference to the given string and assigns it to the Cosignature field. +func (o *PriorityOrderEntity) SetCosignature(v string) { + o.Cosignature = &v +} + +// GetCreatedAt returns the CreatedAt field value if set, zero value otherwise. +func (o *PriorityOrderEntity) GetCreatedAt() float32 { + if o == nil || IsNil(o.CreatedAt) { + var ret float32 + return ret + } + return *o.CreatedAt +} + +// GetCreatedAtOk returns a tuple with the CreatedAt field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetCreatedAtOk() (*float32, bool) { + if o == nil || IsNil(o.CreatedAt) { + return nil, false + } + return o.CreatedAt, true +} + +// HasCreatedAt returns a boolean if a field has been set. +func (o *PriorityOrderEntity) HasCreatedAt() bool { + if o != nil && !IsNil(o.CreatedAt) { + return true + } + + return false +} + +// SetCreatedAt gets a reference to the given float32 and assigns it to the CreatedAt field. +func (o *PriorityOrderEntity) SetCreatedAt(v float32) { + o.CreatedAt = &v +} + +// GetQuoteId returns the QuoteId field value if set, zero value otherwise. +func (o *PriorityOrderEntity) GetQuoteId() string { + if o == nil || IsNil(o.QuoteId) { + var ret string + return ret + } + return *o.QuoteId +} + +// GetQuoteIdOk returns a tuple with the QuoteId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetQuoteIdOk() (*string, bool) { + if o == nil || IsNil(o.QuoteId) { + return nil, false + } + return o.QuoteId, true +} + +// HasQuoteId returns a boolean if a field has been set. +func (o *PriorityOrderEntity) HasQuoteId() bool { + if o != nil && !IsNil(o.QuoteId) { + return true + } + + return false +} + +// SetQuoteId gets a reference to the given string and assigns it to the QuoteId field. +func (o *PriorityOrderEntity) SetQuoteId(v string) { + o.QuoteId = &v +} + +// GetRequestId returns the RequestId field value if set, zero value otherwise. +func (o *PriorityOrderEntity) GetRequestId() string { + if o == nil || IsNil(o.RequestId) { + var ret string + return ret + } + return *o.RequestId +} + +// GetRequestIdOk returns a tuple with the RequestId field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetRequestIdOk() (*string, bool) { + if o == nil || IsNil(o.RequestId) { + return nil, false + } + return o.RequestId, true +} + +// HasRequestId returns a boolean if a field has been set. +func (o *PriorityOrderEntity) HasRequestId() bool { + if o != nil && !IsNil(o.RequestId) { + return true + } + + return false +} + +// SetRequestId gets a reference to the given string and assigns it to the RequestId field. +func (o *PriorityOrderEntity) SetRequestId(v string) { + o.RequestId = &v +} + +// GetTxHash returns the TxHash field value if set, zero value otherwise. +func (o *PriorityOrderEntity) GetTxHash() string { + if o == nil || IsNil(o.TxHash) { + var ret string + return ret + } + return *o.TxHash +} + +// GetTxHashOk returns a tuple with the TxHash field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetTxHashOk() (*string, bool) { + if o == nil || IsNil(o.TxHash) { + return nil, false + } + return o.TxHash, true +} + +// HasTxHash returns a boolean if a field has been set. +func (o *PriorityOrderEntity) HasTxHash() bool { + if o != nil && !IsNil(o.TxHash) { + return true + } + + return false +} + +// SetTxHash gets a reference to the given string and assigns it to the TxHash field. +func (o *PriorityOrderEntity) SetTxHash(v string) { + o.TxHash = &v +} + +// GetSettledAmounts returns the SettledAmounts field value if set, zero value otherwise. +func (o *PriorityOrderEntity) GetSettledAmounts() []SettledAmount { + if o == nil || IsNil(o.SettledAmounts) { + var ret []SettledAmount + return ret + } + return o.SettledAmounts +} + +// GetSettledAmountsOk returns a tuple with the SettledAmounts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetSettledAmountsOk() ([]SettledAmount, bool) { + if o == nil || IsNil(o.SettledAmounts) { + return nil, false + } + return o.SettledAmounts, true +} + +// HasSettledAmounts returns a boolean if a field has been set. +func (o *PriorityOrderEntity) HasSettledAmounts() bool { + if o != nil && !IsNil(o.SettledAmounts) { + return true + } + + return false +} + +// SetSettledAmounts gets a reference to the given []SettledAmount and assigns it to the SettledAmounts field. +func (o *PriorityOrderEntity) SetSettledAmounts(v []SettledAmount) { + o.SettledAmounts = v +} + +// GetRoute returns the Route field value if set, zero value otherwise. +func (o *PriorityOrderEntity) GetRoute() Route { + if o == nil || IsNil(o.Route) { + var ret Route + return ret + } + return *o.Route +} + +// GetRouteOk returns a tuple with the Route field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntity) GetRouteOk() (*Route, bool) { + if o == nil || IsNil(o.Route) { + return nil, false + } + return o.Route, true +} + +// HasRoute returns a boolean if a field has been set. +func (o *PriorityOrderEntity) HasRoute() bool { + if o != nil && !IsNil(o.Route) { + return true + } + + return false +} + +// SetRoute gets a reference to the given Route and assigns it to the Route field. +func (o *PriorityOrderEntity) SetRoute(v Route) { + o.Route = &v +} + +func (o PriorityOrderEntity) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o PriorityOrderEntity) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["type"] = o.Type + toSerialize["encodedOrder"] = o.EncodedOrder + toSerialize["signature"] = o.Signature + if !IsNil(o.Nonce) { + toSerialize["nonce"] = o.Nonce + } + toSerialize["orderHash"] = o.OrderHash + toSerialize["orderStatus"] = o.OrderStatus + toSerialize["chainId"] = o.ChainId + toSerialize["swapper"] = o.Swapper + if !IsNil(o.AuctionStartBlock) { + toSerialize["auctionStartBlock"] = o.AuctionStartBlock + } + if !IsNil(o.BaselinePriorityFeeWei) { + toSerialize["baselinePriorityFeeWei"] = o.BaselinePriorityFeeWei + } + if !IsNil(o.Input) { + toSerialize["input"] = o.Input + } + if !IsNil(o.Outputs) { + toSerialize["outputs"] = o.Outputs + } + if !IsNil(o.CosignerData) { + toSerialize["cosignerData"] = o.CosignerData + } + if !IsNil(o.Cosignature) { + toSerialize["cosignature"] = o.Cosignature + } + if !IsNil(o.CreatedAt) { + toSerialize["createdAt"] = o.CreatedAt + } + if !IsNil(o.QuoteId) { + toSerialize["quoteId"] = o.QuoteId + } + if !IsNil(o.RequestId) { + toSerialize["requestId"] = o.RequestId + } + if !IsNil(o.TxHash) { + toSerialize["txHash"] = o.TxHash + } + if !IsNil(o.SettledAmounts) { + toSerialize["settledAmounts"] = o.SettledAmounts + } + if !IsNil(o.Route) { + toSerialize["route"] = o.Route + } + return toSerialize, nil +} + +func (o *PriorityOrderEntity) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "type", + "encodedOrder", + "signature", + "orderHash", + "orderStatus", + "chainId", + "swapper", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varPriorityOrderEntity := _PriorityOrderEntity{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varPriorityOrderEntity) + + if err != nil { + return err + } + + *o = PriorityOrderEntity(varPriorityOrderEntity) + + return err +} + +type NullablePriorityOrderEntity struct { + value *PriorityOrderEntity + isSet bool +} + +func (v NullablePriorityOrderEntity) Get() *PriorityOrderEntity { + return v.value +} + +func (v *NullablePriorityOrderEntity) Set(val *PriorityOrderEntity) { + v.value = val + v.isSet = true +} + +func (v NullablePriorityOrderEntity) IsSet() bool { + return v.isSet +} + +func (v *NullablePriorityOrderEntity) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullablePriorityOrderEntity(val *PriorityOrderEntity) *NullablePriorityOrderEntity { + return &NullablePriorityOrderEntity{value: val, isSet: true} +} + +func (v NullablePriorityOrderEntity) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullablePriorityOrderEntity) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_priority_order_entity_cosigner_data.go b/api/uniswapxservice/model_priority_order_entity_cosigner_data.go new file mode 100644 index 00000000..7579910b --- /dev/null +++ b/api/uniswapxservice/model_priority_order_entity_cosigner_data.go @@ -0,0 +1,124 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" +) + +// checks if the PriorityOrderEntityCosignerData type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &PriorityOrderEntityCosignerData{} + +// PriorityOrderEntityCosignerData struct for PriorityOrderEntityCosignerData +type PriorityOrderEntityCosignerData struct { + AuctionTargetBlock *float32 `json:"auctionTargetBlock,omitempty"` +} + +// NewPriorityOrderEntityCosignerData instantiates a new PriorityOrderEntityCosignerData object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewPriorityOrderEntityCosignerData() *PriorityOrderEntityCosignerData { + this := PriorityOrderEntityCosignerData{} + return &this +} + +// NewPriorityOrderEntityCosignerDataWithDefaults instantiates a new PriorityOrderEntityCosignerData object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewPriorityOrderEntityCosignerDataWithDefaults() *PriorityOrderEntityCosignerData { + this := PriorityOrderEntityCosignerData{} + return &this +} + +// GetAuctionTargetBlock returns the AuctionTargetBlock field value if set, zero value otherwise. +func (o *PriorityOrderEntityCosignerData) GetAuctionTargetBlock() float32 { + if o == nil || IsNil(o.AuctionTargetBlock) { + var ret float32 + return ret + } + return *o.AuctionTargetBlock +} + +// GetAuctionTargetBlockOk returns a tuple with the AuctionTargetBlock field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntityCosignerData) GetAuctionTargetBlockOk() (*float32, bool) { + if o == nil || IsNil(o.AuctionTargetBlock) { + return nil, false + } + return o.AuctionTargetBlock, true +} + +// HasAuctionTargetBlock returns a boolean if a field has been set. +func (o *PriorityOrderEntityCosignerData) HasAuctionTargetBlock() bool { + if o != nil && !IsNil(o.AuctionTargetBlock) { + return true + } + + return false +} + +// SetAuctionTargetBlock gets a reference to the given float32 and assigns it to the AuctionTargetBlock field. +func (o *PriorityOrderEntityCosignerData) SetAuctionTargetBlock(v float32) { + o.AuctionTargetBlock = &v +} + +func (o PriorityOrderEntityCosignerData) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o PriorityOrderEntityCosignerData) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.AuctionTargetBlock) { + toSerialize["auctionTargetBlock"] = o.AuctionTargetBlock + } + return toSerialize, nil +} + +type NullablePriorityOrderEntityCosignerData struct { + value *PriorityOrderEntityCosignerData + isSet bool +} + +func (v NullablePriorityOrderEntityCosignerData) Get() *PriorityOrderEntityCosignerData { + return v.value +} + +func (v *NullablePriorityOrderEntityCosignerData) Set(val *PriorityOrderEntityCosignerData) { + v.value = val + v.isSet = true +} + +func (v NullablePriorityOrderEntityCosignerData) IsSet() bool { + return v.isSet +} + +func (v *NullablePriorityOrderEntityCosignerData) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullablePriorityOrderEntityCosignerData(val *PriorityOrderEntityCosignerData) *NullablePriorityOrderEntityCosignerData { + return &NullablePriorityOrderEntityCosignerData{value: val, isSet: true} +} + +func (v NullablePriorityOrderEntityCosignerData) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullablePriorityOrderEntityCosignerData) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_priority_order_entity_input.go b/api/uniswapxservice/model_priority_order_entity_input.go new file mode 100644 index 00000000..24126b1e --- /dev/null +++ b/api/uniswapxservice/model_priority_order_entity_input.go @@ -0,0 +1,215 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the PriorityOrderEntityInput type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &PriorityOrderEntityInput{} + +// PriorityOrderEntityInput struct for PriorityOrderEntityInput +type PriorityOrderEntityInput struct { + // EIP-55 checksummed Ethereum address. + Token string `json:"token"` + // uint256 encoded as a base-10 string. + Amount string `json:"amount" validate:"regexp=^[0-9]{1,78}$"` + // uint256 encoded as a base-10 string. + MpsPerPriorityFeeWei string `json:"mpsPerPriorityFeeWei" validate:"regexp=^[0-9]{1,78}$"` +} + +type _PriorityOrderEntityInput PriorityOrderEntityInput + +// NewPriorityOrderEntityInput instantiates a new PriorityOrderEntityInput object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewPriorityOrderEntityInput(token string, amount string, mpsPerPriorityFeeWei string) *PriorityOrderEntityInput { + this := PriorityOrderEntityInput{} + this.Token = token + this.Amount = amount + this.MpsPerPriorityFeeWei = mpsPerPriorityFeeWei + return &this +} + +// NewPriorityOrderEntityInputWithDefaults instantiates a new PriorityOrderEntityInput object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewPriorityOrderEntityInputWithDefaults() *PriorityOrderEntityInput { + this := PriorityOrderEntityInput{} + return &this +} + +// GetToken returns the Token field value +func (o *PriorityOrderEntityInput) GetToken() string { + if o == nil { + var ret string + return ret + } + + return o.Token +} + +// GetTokenOk returns a tuple with the Token field value +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntityInput) GetTokenOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Token, true +} + +// SetToken sets field value +func (o *PriorityOrderEntityInput) SetToken(v string) { + o.Token = v +} + +// GetAmount returns the Amount field value +func (o *PriorityOrderEntityInput) GetAmount() string { + if o == nil { + var ret string + return ret + } + + return o.Amount +} + +// GetAmountOk returns a tuple with the Amount field value +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntityInput) GetAmountOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Amount, true +} + +// SetAmount sets field value +func (o *PriorityOrderEntityInput) SetAmount(v string) { + o.Amount = v +} + +// GetMpsPerPriorityFeeWei returns the MpsPerPriorityFeeWei field value +func (o *PriorityOrderEntityInput) GetMpsPerPriorityFeeWei() string { + if o == nil { + var ret string + return ret + } + + return o.MpsPerPriorityFeeWei +} + +// GetMpsPerPriorityFeeWeiOk returns a tuple with the MpsPerPriorityFeeWei field value +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntityInput) GetMpsPerPriorityFeeWeiOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.MpsPerPriorityFeeWei, true +} + +// SetMpsPerPriorityFeeWei sets field value +func (o *PriorityOrderEntityInput) SetMpsPerPriorityFeeWei(v string) { + o.MpsPerPriorityFeeWei = v +} + +func (o PriorityOrderEntityInput) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o PriorityOrderEntityInput) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["token"] = o.Token + toSerialize["amount"] = o.Amount + toSerialize["mpsPerPriorityFeeWei"] = o.MpsPerPriorityFeeWei + return toSerialize, nil +} + +func (o *PriorityOrderEntityInput) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "token", + "amount", + "mpsPerPriorityFeeWei", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varPriorityOrderEntityInput := _PriorityOrderEntityInput{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varPriorityOrderEntityInput) + + if err != nil { + return err + } + + *o = PriorityOrderEntityInput(varPriorityOrderEntityInput) + + return err +} + +type NullablePriorityOrderEntityInput struct { + value *PriorityOrderEntityInput + isSet bool +} + +func (v NullablePriorityOrderEntityInput) Get() *PriorityOrderEntityInput { + return v.value +} + +func (v *NullablePriorityOrderEntityInput) Set(val *PriorityOrderEntityInput) { + v.value = val + v.isSet = true +} + +func (v NullablePriorityOrderEntityInput) IsSet() bool { + return v.isSet +} + +func (v *NullablePriorityOrderEntityInput) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullablePriorityOrderEntityInput(val *PriorityOrderEntityInput) *NullablePriorityOrderEntityInput { + return &NullablePriorityOrderEntityInput{value: val, isSet: true} +} + +func (v NullablePriorityOrderEntityInput) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullablePriorityOrderEntityInput) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_priority_order_entity_outputs_inner.go b/api/uniswapxservice/model_priority_order_entity_outputs_inner.go new file mode 100644 index 00000000..a5a468a9 --- /dev/null +++ b/api/uniswapxservice/model_priority_order_entity_outputs_inner.go @@ -0,0 +1,244 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the PriorityOrderEntityOutputsInner type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &PriorityOrderEntityOutputsInner{} + +// PriorityOrderEntityOutputsInner struct for PriorityOrderEntityOutputsInner +type PriorityOrderEntityOutputsInner struct { + // EIP-55 checksummed Ethereum address. + Token string `json:"token"` + // uint256 encoded as a base-10 string. + Amount string `json:"amount" validate:"regexp=^[0-9]{1,78}$"` + // uint256 encoded as a base-10 string. + MpsPerPriorityFeeWei string `json:"mpsPerPriorityFeeWei" validate:"regexp=^[0-9]{1,78}$"` + // EIP-55 checksummed Ethereum address. + Recipient string `json:"recipient"` +} + +type _PriorityOrderEntityOutputsInner PriorityOrderEntityOutputsInner + +// NewPriorityOrderEntityOutputsInner instantiates a new PriorityOrderEntityOutputsInner object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewPriorityOrderEntityOutputsInner(token string, amount string, mpsPerPriorityFeeWei string, recipient string) *PriorityOrderEntityOutputsInner { + this := PriorityOrderEntityOutputsInner{} + this.Token = token + this.Amount = amount + this.MpsPerPriorityFeeWei = mpsPerPriorityFeeWei + this.Recipient = recipient + return &this +} + +// NewPriorityOrderEntityOutputsInnerWithDefaults instantiates a new PriorityOrderEntityOutputsInner object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewPriorityOrderEntityOutputsInnerWithDefaults() *PriorityOrderEntityOutputsInner { + this := PriorityOrderEntityOutputsInner{} + return &this +} + +// GetToken returns the Token field value +func (o *PriorityOrderEntityOutputsInner) GetToken() string { + if o == nil { + var ret string + return ret + } + + return o.Token +} + +// GetTokenOk returns a tuple with the Token field value +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntityOutputsInner) GetTokenOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Token, true +} + +// SetToken sets field value +func (o *PriorityOrderEntityOutputsInner) SetToken(v string) { + o.Token = v +} + +// GetAmount returns the Amount field value +func (o *PriorityOrderEntityOutputsInner) GetAmount() string { + if o == nil { + var ret string + return ret + } + + return o.Amount +} + +// GetAmountOk returns a tuple with the Amount field value +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntityOutputsInner) GetAmountOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Amount, true +} + +// SetAmount sets field value +func (o *PriorityOrderEntityOutputsInner) SetAmount(v string) { + o.Amount = v +} + +// GetMpsPerPriorityFeeWei returns the MpsPerPriorityFeeWei field value +func (o *PriorityOrderEntityOutputsInner) GetMpsPerPriorityFeeWei() string { + if o == nil { + var ret string + return ret + } + + return o.MpsPerPriorityFeeWei +} + +// GetMpsPerPriorityFeeWeiOk returns a tuple with the MpsPerPriorityFeeWei field value +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntityOutputsInner) GetMpsPerPriorityFeeWeiOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.MpsPerPriorityFeeWei, true +} + +// SetMpsPerPriorityFeeWei sets field value +func (o *PriorityOrderEntityOutputsInner) SetMpsPerPriorityFeeWei(v string) { + o.MpsPerPriorityFeeWei = v +} + +// GetRecipient returns the Recipient field value +func (o *PriorityOrderEntityOutputsInner) GetRecipient() string { + if o == nil { + var ret string + return ret + } + + return o.Recipient +} + +// GetRecipientOk returns a tuple with the Recipient field value +// and a boolean to check if the value has been set. +func (o *PriorityOrderEntityOutputsInner) GetRecipientOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Recipient, true +} + +// SetRecipient sets field value +func (o *PriorityOrderEntityOutputsInner) SetRecipient(v string) { + o.Recipient = v +} + +func (o PriorityOrderEntityOutputsInner) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o PriorityOrderEntityOutputsInner) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["token"] = o.Token + toSerialize["amount"] = o.Amount + toSerialize["mpsPerPriorityFeeWei"] = o.MpsPerPriorityFeeWei + toSerialize["recipient"] = o.Recipient + return toSerialize, nil +} + +func (o *PriorityOrderEntityOutputsInner) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "token", + "amount", + "mpsPerPriorityFeeWei", + "recipient", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varPriorityOrderEntityOutputsInner := _PriorityOrderEntityOutputsInner{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varPriorityOrderEntityOutputsInner) + + if err != nil { + return err + } + + *o = PriorityOrderEntityOutputsInner(varPriorityOrderEntityOutputsInner) + + return err +} + +type NullablePriorityOrderEntityOutputsInner struct { + value *PriorityOrderEntityOutputsInner + isSet bool +} + +func (v NullablePriorityOrderEntityOutputsInner) Get() *PriorityOrderEntityOutputsInner { + return v.value +} + +func (v *NullablePriorityOrderEntityOutputsInner) Set(val *PriorityOrderEntityOutputsInner) { + v.value = val + v.isSet = true +} + +func (v NullablePriorityOrderEntityOutputsInner) IsSet() bool { + return v.isSet +} + +func (v *NullablePriorityOrderEntityOutputsInner) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullablePriorityOrderEntityOutputsInner(val *PriorityOrderEntityOutputsInner) *NullablePriorityOrderEntityOutputsInner { + return &NullablePriorityOrderEntityOutputsInner{value: val, isSet: true} +} + +func (v NullablePriorityOrderEntityOutputsInner) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullablePriorityOrderEntityOutputsInner) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_relay_order_entity.go b/api/uniswapxservice/model_relay_order_entity.go new file mode 100644 index 00000000..3b54e8c1 --- /dev/null +++ b/api/uniswapxservice/model_relay_order_entity.go @@ -0,0 +1,509 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the RelayOrderEntity type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &RelayOrderEntity{} + +// RelayOrderEntity Relay orders: gasless transaction relays paid via a decaying relay fee. +type RelayOrderEntity struct { + Type string `json:"type"` + // ABI-encoded order struct. Decode with the uniswapx-sdk parser for the order's type. + EncodedOrder string `json:"encodedOrder" validate:"regexp=^0x[0-9a-fA-F]*$"` + // EIP-712 signature over the order. + Signature string `json:"signature" validate:"regexp=^0x[0-9a-fA-F]{130}$"` + OrderHash string `json:"orderHash" validate:"regexp=^0x[0-9a-fA-F]{64}$"` + OrderStatus OrderStatus `json:"orderStatus"` + ChainId ChainId `json:"chainId"` + // EIP-55 checksummed Ethereum address. + Swapper string `json:"swapper"` + Input *RelayOrderEntityInput `json:"input,omitempty"` + RelayFee *RelayOrderEntityRelayFee `json:"relayFee,omitempty"` + // Unix timestamp (seconds) at which the order was recorded. + CreatedAt *float32 `json:"createdAt,omitempty"` + // Transaction hash of the fill. Defined once the order has been filled. + TxHash *string `json:"txHash,omitempty" validate:"regexp=^0x[0-9a-fA-F]{64}$"` + SettledAmounts []SettledAmount `json:"settledAmounts,omitempty"` +} + +type _RelayOrderEntity RelayOrderEntity + +// NewRelayOrderEntity instantiates a new RelayOrderEntity object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewRelayOrderEntity(type_ string, encodedOrder string, signature string, orderHash string, orderStatus OrderStatus, chainId ChainId, swapper string) *RelayOrderEntity { + this := RelayOrderEntity{} + this.Type = type_ + this.EncodedOrder = encodedOrder + this.Signature = signature + this.OrderHash = orderHash + this.OrderStatus = orderStatus + this.ChainId = chainId + this.Swapper = swapper + return &this +} + +// NewRelayOrderEntityWithDefaults instantiates a new RelayOrderEntity object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewRelayOrderEntityWithDefaults() *RelayOrderEntity { + this := RelayOrderEntity{} + return &this +} + +// GetType returns the Type field value +func (o *RelayOrderEntity) GetType() string { + if o == nil { + var ret string + return ret + } + + return o.Type +} + +// GetTypeOk returns a tuple with the Type field value +// and a boolean to check if the value has been set. +func (o *RelayOrderEntity) GetTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Type, true +} + +// SetType sets field value +func (o *RelayOrderEntity) SetType(v string) { + o.Type = v +} + +// GetEncodedOrder returns the EncodedOrder field value +func (o *RelayOrderEntity) GetEncodedOrder() string { + if o == nil { + var ret string + return ret + } + + return o.EncodedOrder +} + +// GetEncodedOrderOk returns a tuple with the EncodedOrder field value +// and a boolean to check if the value has been set. +func (o *RelayOrderEntity) GetEncodedOrderOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.EncodedOrder, true +} + +// SetEncodedOrder sets field value +func (o *RelayOrderEntity) SetEncodedOrder(v string) { + o.EncodedOrder = v +} + +// GetSignature returns the Signature field value +func (o *RelayOrderEntity) GetSignature() string { + if o == nil { + var ret string + return ret + } + + return o.Signature +} + +// GetSignatureOk returns a tuple with the Signature field value +// and a boolean to check if the value has been set. +func (o *RelayOrderEntity) GetSignatureOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Signature, true +} + +// SetSignature sets field value +func (o *RelayOrderEntity) SetSignature(v string) { + o.Signature = v +} + +// GetOrderHash returns the OrderHash field value +func (o *RelayOrderEntity) GetOrderHash() string { + if o == nil { + var ret string + return ret + } + + return o.OrderHash +} + +// GetOrderHashOk returns a tuple with the OrderHash field value +// and a boolean to check if the value has been set. +func (o *RelayOrderEntity) GetOrderHashOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.OrderHash, true +} + +// SetOrderHash sets field value +func (o *RelayOrderEntity) SetOrderHash(v string) { + o.OrderHash = v +} + +// GetOrderStatus returns the OrderStatus field value +func (o *RelayOrderEntity) GetOrderStatus() OrderStatus { + if o == nil { + var ret OrderStatus + return ret + } + + return o.OrderStatus +} + +// GetOrderStatusOk returns a tuple with the OrderStatus field value +// and a boolean to check if the value has been set. +func (o *RelayOrderEntity) GetOrderStatusOk() (*OrderStatus, bool) { + if o == nil { + return nil, false + } + return &o.OrderStatus, true +} + +// SetOrderStatus sets field value +func (o *RelayOrderEntity) SetOrderStatus(v OrderStatus) { + o.OrderStatus = v +} + +// GetChainId returns the ChainId field value +func (o *RelayOrderEntity) GetChainId() ChainId { + if o == nil { + var ret ChainId + return ret + } + + return o.ChainId +} + +// GetChainIdOk returns a tuple with the ChainId field value +// and a boolean to check if the value has been set. +func (o *RelayOrderEntity) GetChainIdOk() (*ChainId, bool) { + if o == nil { + return nil, false + } + return &o.ChainId, true +} + +// SetChainId sets field value +func (o *RelayOrderEntity) SetChainId(v ChainId) { + o.ChainId = v +} + +// GetSwapper returns the Swapper field value +func (o *RelayOrderEntity) GetSwapper() string { + if o == nil { + var ret string + return ret + } + + return o.Swapper +} + +// GetSwapperOk returns a tuple with the Swapper field value +// and a boolean to check if the value has been set. +func (o *RelayOrderEntity) GetSwapperOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Swapper, true +} + +// SetSwapper sets field value +func (o *RelayOrderEntity) SetSwapper(v string) { + o.Swapper = v +} + +// GetInput returns the Input field value if set, zero value otherwise. +func (o *RelayOrderEntity) GetInput() RelayOrderEntityInput { + if o == nil || IsNil(o.Input) { + var ret RelayOrderEntityInput + return ret + } + return *o.Input +} + +// GetInputOk returns a tuple with the Input field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RelayOrderEntity) GetInputOk() (*RelayOrderEntityInput, bool) { + if o == nil || IsNil(o.Input) { + return nil, false + } + return o.Input, true +} + +// HasInput returns a boolean if a field has been set. +func (o *RelayOrderEntity) HasInput() bool { + if o != nil && !IsNil(o.Input) { + return true + } + + return false +} + +// SetInput gets a reference to the given RelayOrderEntityInput and assigns it to the Input field. +func (o *RelayOrderEntity) SetInput(v RelayOrderEntityInput) { + o.Input = &v +} + +// GetRelayFee returns the RelayFee field value if set, zero value otherwise. +func (o *RelayOrderEntity) GetRelayFee() RelayOrderEntityRelayFee { + if o == nil || IsNil(o.RelayFee) { + var ret RelayOrderEntityRelayFee + return ret + } + return *o.RelayFee +} + +// GetRelayFeeOk returns a tuple with the RelayFee field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RelayOrderEntity) GetRelayFeeOk() (*RelayOrderEntityRelayFee, bool) { + if o == nil || IsNil(o.RelayFee) { + return nil, false + } + return o.RelayFee, true +} + +// HasRelayFee returns a boolean if a field has been set. +func (o *RelayOrderEntity) HasRelayFee() bool { + if o != nil && !IsNil(o.RelayFee) { + return true + } + + return false +} + +// SetRelayFee gets a reference to the given RelayOrderEntityRelayFee and assigns it to the RelayFee field. +func (o *RelayOrderEntity) SetRelayFee(v RelayOrderEntityRelayFee) { + o.RelayFee = &v +} + +// GetCreatedAt returns the CreatedAt field value if set, zero value otherwise. +func (o *RelayOrderEntity) GetCreatedAt() float32 { + if o == nil || IsNil(o.CreatedAt) { + var ret float32 + return ret + } + return *o.CreatedAt +} + +// GetCreatedAtOk returns a tuple with the CreatedAt field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RelayOrderEntity) GetCreatedAtOk() (*float32, bool) { + if o == nil || IsNil(o.CreatedAt) { + return nil, false + } + return o.CreatedAt, true +} + +// HasCreatedAt returns a boolean if a field has been set. +func (o *RelayOrderEntity) HasCreatedAt() bool { + if o != nil && !IsNil(o.CreatedAt) { + return true + } + + return false +} + +// SetCreatedAt gets a reference to the given float32 and assigns it to the CreatedAt field. +func (o *RelayOrderEntity) SetCreatedAt(v float32) { + o.CreatedAt = &v +} + +// GetTxHash returns the TxHash field value if set, zero value otherwise. +func (o *RelayOrderEntity) GetTxHash() string { + if o == nil || IsNil(o.TxHash) { + var ret string + return ret + } + return *o.TxHash +} + +// GetTxHashOk returns a tuple with the TxHash field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RelayOrderEntity) GetTxHashOk() (*string, bool) { + if o == nil || IsNil(o.TxHash) { + return nil, false + } + return o.TxHash, true +} + +// HasTxHash returns a boolean if a field has been set. +func (o *RelayOrderEntity) HasTxHash() bool { + if o != nil && !IsNil(o.TxHash) { + return true + } + + return false +} + +// SetTxHash gets a reference to the given string and assigns it to the TxHash field. +func (o *RelayOrderEntity) SetTxHash(v string) { + o.TxHash = &v +} + +// GetSettledAmounts returns the SettledAmounts field value if set, zero value otherwise. +func (o *RelayOrderEntity) GetSettledAmounts() []SettledAmount { + if o == nil || IsNil(o.SettledAmounts) { + var ret []SettledAmount + return ret + } + return o.SettledAmounts +} + +// GetSettledAmountsOk returns a tuple with the SettledAmounts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RelayOrderEntity) GetSettledAmountsOk() ([]SettledAmount, bool) { + if o == nil || IsNil(o.SettledAmounts) { + return nil, false + } + return o.SettledAmounts, true +} + +// HasSettledAmounts returns a boolean if a field has been set. +func (o *RelayOrderEntity) HasSettledAmounts() bool { + if o != nil && !IsNil(o.SettledAmounts) { + return true + } + + return false +} + +// SetSettledAmounts gets a reference to the given []SettledAmount and assigns it to the SettledAmounts field. +func (o *RelayOrderEntity) SetSettledAmounts(v []SettledAmount) { + o.SettledAmounts = v +} + +func (o RelayOrderEntity) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o RelayOrderEntity) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["type"] = o.Type + toSerialize["encodedOrder"] = o.EncodedOrder + toSerialize["signature"] = o.Signature + toSerialize["orderHash"] = o.OrderHash + toSerialize["orderStatus"] = o.OrderStatus + toSerialize["chainId"] = o.ChainId + toSerialize["swapper"] = o.Swapper + if !IsNil(o.Input) { + toSerialize["input"] = o.Input + } + if !IsNil(o.RelayFee) { + toSerialize["relayFee"] = o.RelayFee + } + if !IsNil(o.CreatedAt) { + toSerialize["createdAt"] = o.CreatedAt + } + if !IsNil(o.TxHash) { + toSerialize["txHash"] = o.TxHash + } + if !IsNil(o.SettledAmounts) { + toSerialize["settledAmounts"] = o.SettledAmounts + } + return toSerialize, nil +} + +func (o *RelayOrderEntity) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "type", + "encodedOrder", + "signature", + "orderHash", + "orderStatus", + "chainId", + "swapper", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varRelayOrderEntity := _RelayOrderEntity{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varRelayOrderEntity) + + if err != nil { + return err + } + + *o = RelayOrderEntity(varRelayOrderEntity) + + return err +} + +type NullableRelayOrderEntity struct { + value *RelayOrderEntity + isSet bool +} + +func (v NullableRelayOrderEntity) Get() *RelayOrderEntity { + return v.value +} + +func (v *NullableRelayOrderEntity) Set(val *RelayOrderEntity) { + v.value = val + v.isSet = true +} + +func (v NullableRelayOrderEntity) IsSet() bool { + return v.isSet +} + +func (v *NullableRelayOrderEntity) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableRelayOrderEntity(val *RelayOrderEntity) *NullableRelayOrderEntity { + return &NullableRelayOrderEntity{value: val, isSet: true} +} + +func (v NullableRelayOrderEntity) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableRelayOrderEntity) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_relay_order_entity_input.go b/api/uniswapxservice/model_relay_order_entity_input.go new file mode 100644 index 00000000..e8abef06 --- /dev/null +++ b/api/uniswapxservice/model_relay_order_entity_input.go @@ -0,0 +1,199 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" +) + +// checks if the RelayOrderEntityInput type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &RelayOrderEntityInput{} + +// RelayOrderEntityInput struct for RelayOrderEntityInput +type RelayOrderEntityInput struct { + // EIP-55 checksummed Ethereum address. + Token *string `json:"token,omitempty"` + // uint256 encoded as a base-10 string. + Amount *string `json:"amount,omitempty" validate:"regexp=^[0-9]{1,78}$"` + // EIP-55 checksummed Ethereum address. + Recipient *string `json:"recipient,omitempty"` +} + +// NewRelayOrderEntityInput instantiates a new RelayOrderEntityInput object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewRelayOrderEntityInput() *RelayOrderEntityInput { + this := RelayOrderEntityInput{} + return &this +} + +// NewRelayOrderEntityInputWithDefaults instantiates a new RelayOrderEntityInput object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewRelayOrderEntityInputWithDefaults() *RelayOrderEntityInput { + this := RelayOrderEntityInput{} + return &this +} + +// GetToken returns the Token field value if set, zero value otherwise. +func (o *RelayOrderEntityInput) GetToken() string { + if o == nil || IsNil(o.Token) { + var ret string + return ret + } + return *o.Token +} + +// GetTokenOk returns a tuple with the Token field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RelayOrderEntityInput) GetTokenOk() (*string, bool) { + if o == nil || IsNil(o.Token) { + return nil, false + } + return o.Token, true +} + +// HasToken returns a boolean if a field has been set. +func (o *RelayOrderEntityInput) HasToken() bool { + if o != nil && !IsNil(o.Token) { + return true + } + + return false +} + +// SetToken gets a reference to the given string and assigns it to the Token field. +func (o *RelayOrderEntityInput) SetToken(v string) { + o.Token = &v +} + +// GetAmount returns the Amount field value if set, zero value otherwise. +func (o *RelayOrderEntityInput) GetAmount() string { + if o == nil || IsNil(o.Amount) { + var ret string + return ret + } + return *o.Amount +} + +// GetAmountOk returns a tuple with the Amount field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RelayOrderEntityInput) GetAmountOk() (*string, bool) { + if o == nil || IsNil(o.Amount) { + return nil, false + } + return o.Amount, true +} + +// HasAmount returns a boolean if a field has been set. +func (o *RelayOrderEntityInput) HasAmount() bool { + if o != nil && !IsNil(o.Amount) { + return true + } + + return false +} + +// SetAmount gets a reference to the given string and assigns it to the Amount field. +func (o *RelayOrderEntityInput) SetAmount(v string) { + o.Amount = &v +} + +// GetRecipient returns the Recipient field value if set, zero value otherwise. +func (o *RelayOrderEntityInput) GetRecipient() string { + if o == nil || IsNil(o.Recipient) { + var ret string + return ret + } + return *o.Recipient +} + +// GetRecipientOk returns a tuple with the Recipient field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RelayOrderEntityInput) GetRecipientOk() (*string, bool) { + if o == nil || IsNil(o.Recipient) { + return nil, false + } + return o.Recipient, true +} + +// HasRecipient returns a boolean if a field has been set. +func (o *RelayOrderEntityInput) HasRecipient() bool { + if o != nil && !IsNil(o.Recipient) { + return true + } + + return false +} + +// SetRecipient gets a reference to the given string and assigns it to the Recipient field. +func (o *RelayOrderEntityInput) SetRecipient(v string) { + o.Recipient = &v +} + +func (o RelayOrderEntityInput) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o RelayOrderEntityInput) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Token) { + toSerialize["token"] = o.Token + } + if !IsNil(o.Amount) { + toSerialize["amount"] = o.Amount + } + if !IsNil(o.Recipient) { + toSerialize["recipient"] = o.Recipient + } + return toSerialize, nil +} + +type NullableRelayOrderEntityInput struct { + value *RelayOrderEntityInput + isSet bool +} + +func (v NullableRelayOrderEntityInput) Get() *RelayOrderEntityInput { + return v.value +} + +func (v *NullableRelayOrderEntityInput) Set(val *RelayOrderEntityInput) { + v.value = val + v.isSet = true +} + +func (v NullableRelayOrderEntityInput) IsSet() bool { + return v.isSet +} + +func (v *NullableRelayOrderEntityInput) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableRelayOrderEntityInput(val *RelayOrderEntityInput) *NullableRelayOrderEntityInput { + return &NullableRelayOrderEntityInput{value: val, isSet: true} +} + +func (v NullableRelayOrderEntityInput) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableRelayOrderEntityInput) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_relay_order_entity_relay_fee.go b/api/uniswapxservice/model_relay_order_entity_relay_fee.go new file mode 100644 index 00000000..bec56e49 --- /dev/null +++ b/api/uniswapxservice/model_relay_order_entity_relay_fee.go @@ -0,0 +1,271 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" +) + +// checks if the RelayOrderEntityRelayFee type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &RelayOrderEntityRelayFee{} + +// RelayOrderEntityRelayFee struct for RelayOrderEntityRelayFee +type RelayOrderEntityRelayFee struct { + // EIP-55 checksummed Ethereum address. + Token *string `json:"token,omitempty"` + // uint256 encoded as a base-10 string. + StartAmount *string `json:"startAmount,omitempty" validate:"regexp=^[0-9]{1,78}$"` + // uint256 encoded as a base-10 string. + EndAmount *string `json:"endAmount,omitempty" validate:"regexp=^[0-9]{1,78}$"` + StartTime *float32 `json:"startTime,omitempty"` + EndTime *float32 `json:"endTime,omitempty"` +} + +// NewRelayOrderEntityRelayFee instantiates a new RelayOrderEntityRelayFee object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewRelayOrderEntityRelayFee() *RelayOrderEntityRelayFee { + this := RelayOrderEntityRelayFee{} + return &this +} + +// NewRelayOrderEntityRelayFeeWithDefaults instantiates a new RelayOrderEntityRelayFee object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewRelayOrderEntityRelayFeeWithDefaults() *RelayOrderEntityRelayFee { + this := RelayOrderEntityRelayFee{} + return &this +} + +// GetToken returns the Token field value if set, zero value otherwise. +func (o *RelayOrderEntityRelayFee) GetToken() string { + if o == nil || IsNil(o.Token) { + var ret string + return ret + } + return *o.Token +} + +// GetTokenOk returns a tuple with the Token field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RelayOrderEntityRelayFee) GetTokenOk() (*string, bool) { + if o == nil || IsNil(o.Token) { + return nil, false + } + return o.Token, true +} + +// HasToken returns a boolean if a field has been set. +func (o *RelayOrderEntityRelayFee) HasToken() bool { + if o != nil && !IsNil(o.Token) { + return true + } + + return false +} + +// SetToken gets a reference to the given string and assigns it to the Token field. +func (o *RelayOrderEntityRelayFee) SetToken(v string) { + o.Token = &v +} + +// GetStartAmount returns the StartAmount field value if set, zero value otherwise. +func (o *RelayOrderEntityRelayFee) GetStartAmount() string { + if o == nil || IsNil(o.StartAmount) { + var ret string + return ret + } + return *o.StartAmount +} + +// GetStartAmountOk returns a tuple with the StartAmount field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RelayOrderEntityRelayFee) GetStartAmountOk() (*string, bool) { + if o == nil || IsNil(o.StartAmount) { + return nil, false + } + return o.StartAmount, true +} + +// HasStartAmount returns a boolean if a field has been set. +func (o *RelayOrderEntityRelayFee) HasStartAmount() bool { + if o != nil && !IsNil(o.StartAmount) { + return true + } + + return false +} + +// SetStartAmount gets a reference to the given string and assigns it to the StartAmount field. +func (o *RelayOrderEntityRelayFee) SetStartAmount(v string) { + o.StartAmount = &v +} + +// GetEndAmount returns the EndAmount field value if set, zero value otherwise. +func (o *RelayOrderEntityRelayFee) GetEndAmount() string { + if o == nil || IsNil(o.EndAmount) { + var ret string + return ret + } + return *o.EndAmount +} + +// GetEndAmountOk returns a tuple with the EndAmount field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RelayOrderEntityRelayFee) GetEndAmountOk() (*string, bool) { + if o == nil || IsNil(o.EndAmount) { + return nil, false + } + return o.EndAmount, true +} + +// HasEndAmount returns a boolean if a field has been set. +func (o *RelayOrderEntityRelayFee) HasEndAmount() bool { + if o != nil && !IsNil(o.EndAmount) { + return true + } + + return false +} + +// SetEndAmount gets a reference to the given string and assigns it to the EndAmount field. +func (o *RelayOrderEntityRelayFee) SetEndAmount(v string) { + o.EndAmount = &v +} + +// GetStartTime returns the StartTime field value if set, zero value otherwise. +func (o *RelayOrderEntityRelayFee) GetStartTime() float32 { + if o == nil || IsNil(o.StartTime) { + var ret float32 + return ret + } + return *o.StartTime +} + +// GetStartTimeOk returns a tuple with the StartTime field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RelayOrderEntityRelayFee) GetStartTimeOk() (*float32, bool) { + if o == nil || IsNil(o.StartTime) { + return nil, false + } + return o.StartTime, true +} + +// HasStartTime returns a boolean if a field has been set. +func (o *RelayOrderEntityRelayFee) HasStartTime() bool { + if o != nil && !IsNil(o.StartTime) { + return true + } + + return false +} + +// SetStartTime gets a reference to the given float32 and assigns it to the StartTime field. +func (o *RelayOrderEntityRelayFee) SetStartTime(v float32) { + o.StartTime = &v +} + +// GetEndTime returns the EndTime field value if set, zero value otherwise. +func (o *RelayOrderEntityRelayFee) GetEndTime() float32 { + if o == nil || IsNil(o.EndTime) { + var ret float32 + return ret + } + return *o.EndTime +} + +// GetEndTimeOk returns a tuple with the EndTime field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RelayOrderEntityRelayFee) GetEndTimeOk() (*float32, bool) { + if o == nil || IsNil(o.EndTime) { + return nil, false + } + return o.EndTime, true +} + +// HasEndTime returns a boolean if a field has been set. +func (o *RelayOrderEntityRelayFee) HasEndTime() bool { + if o != nil && !IsNil(o.EndTime) { + return true + } + + return false +} + +// SetEndTime gets a reference to the given float32 and assigns it to the EndTime field. +func (o *RelayOrderEntityRelayFee) SetEndTime(v float32) { + o.EndTime = &v +} + +func (o RelayOrderEntityRelayFee) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o RelayOrderEntityRelayFee) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Token) { + toSerialize["token"] = o.Token + } + if !IsNil(o.StartAmount) { + toSerialize["startAmount"] = o.StartAmount + } + if !IsNil(o.EndAmount) { + toSerialize["endAmount"] = o.EndAmount + } + if !IsNil(o.StartTime) { + toSerialize["startTime"] = o.StartTime + } + if !IsNil(o.EndTime) { + toSerialize["endTime"] = o.EndTime + } + return toSerialize, nil +} + +type NullableRelayOrderEntityRelayFee struct { + value *RelayOrderEntityRelayFee + isSet bool +} + +func (v NullableRelayOrderEntityRelayFee) Get() *RelayOrderEntityRelayFee { + return v.value +} + +func (v *NullableRelayOrderEntityRelayFee) Set(val *RelayOrderEntityRelayFee) { + v.value = val + v.isSet = true +} + +func (v NullableRelayOrderEntityRelayFee) IsSet() bool { + return v.isSet +} + +func (v *NullableRelayOrderEntityRelayFee) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableRelayOrderEntityRelayFee(val *RelayOrderEntityRelayFee) *NullableRelayOrderEntityRelayFee { + return &NullableRelayOrderEntityRelayFee{value: val, isSet: true} +} + +func (v NullableRelayOrderEntityRelayFee) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableRelayOrderEntityRelayFee) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_route.go b/api/uniswapxservice/model_route.go new file mode 100644 index 00000000..699690f8 --- /dev/null +++ b/api/uniswapxservice/model_route.go @@ -0,0 +1,309 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" +) + +// checks if the Route type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &Route{} + +// Route Classic-route quote metadata associated with the order's quote. +type Route struct { + // uint256 encoded as a base-10 string. + Quote *string `json:"quote,omitempty" validate:"regexp=^[0-9]{1,78}$"` + // uint256 encoded as a base-10 string. + QuoteGasAdjusted *string `json:"quoteGasAdjusted,omitempty" validate:"regexp=^[0-9]{1,78}$"` + // uint256 encoded as a base-10 string. + GasPriceWei *string `json:"gasPriceWei,omitempty" validate:"regexp=^[0-9]{1,78}$"` + // uint256 encoded as a base-10 string. + GasUseEstimateQuote *string `json:"gasUseEstimateQuote,omitempty" validate:"regexp=^[0-9]{1,78}$"` + // uint256 encoded as a base-10 string. + GasUseEstimate *string `json:"gasUseEstimate,omitempty" validate:"regexp=^[0-9]{1,78}$"` + MethodParameters *RouteMethodParameters `json:"methodParameters,omitempty"` +} + +// NewRoute instantiates a new Route object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewRoute() *Route { + this := Route{} + return &this +} + +// NewRouteWithDefaults instantiates a new Route object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewRouteWithDefaults() *Route { + this := Route{} + return &this +} + +// GetQuote returns the Quote field value if set, zero value otherwise. +func (o *Route) GetQuote() string { + if o == nil || IsNil(o.Quote) { + var ret string + return ret + } + return *o.Quote +} + +// GetQuoteOk returns a tuple with the Quote field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Route) GetQuoteOk() (*string, bool) { + if o == nil || IsNil(o.Quote) { + return nil, false + } + return o.Quote, true +} + +// HasQuote returns a boolean if a field has been set. +func (o *Route) HasQuote() bool { + if o != nil && !IsNil(o.Quote) { + return true + } + + return false +} + +// SetQuote gets a reference to the given string and assigns it to the Quote field. +func (o *Route) SetQuote(v string) { + o.Quote = &v +} + +// GetQuoteGasAdjusted returns the QuoteGasAdjusted field value if set, zero value otherwise. +func (o *Route) GetQuoteGasAdjusted() string { + if o == nil || IsNil(o.QuoteGasAdjusted) { + var ret string + return ret + } + return *o.QuoteGasAdjusted +} + +// GetQuoteGasAdjustedOk returns a tuple with the QuoteGasAdjusted field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Route) GetQuoteGasAdjustedOk() (*string, bool) { + if o == nil || IsNil(o.QuoteGasAdjusted) { + return nil, false + } + return o.QuoteGasAdjusted, true +} + +// HasQuoteGasAdjusted returns a boolean if a field has been set. +func (o *Route) HasQuoteGasAdjusted() bool { + if o != nil && !IsNil(o.QuoteGasAdjusted) { + return true + } + + return false +} + +// SetQuoteGasAdjusted gets a reference to the given string and assigns it to the QuoteGasAdjusted field. +func (o *Route) SetQuoteGasAdjusted(v string) { + o.QuoteGasAdjusted = &v +} + +// GetGasPriceWei returns the GasPriceWei field value if set, zero value otherwise. +func (o *Route) GetGasPriceWei() string { + if o == nil || IsNil(o.GasPriceWei) { + var ret string + return ret + } + return *o.GasPriceWei +} + +// GetGasPriceWeiOk returns a tuple with the GasPriceWei field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Route) GetGasPriceWeiOk() (*string, bool) { + if o == nil || IsNil(o.GasPriceWei) { + return nil, false + } + return o.GasPriceWei, true +} + +// HasGasPriceWei returns a boolean if a field has been set. +func (o *Route) HasGasPriceWei() bool { + if o != nil && !IsNil(o.GasPriceWei) { + return true + } + + return false +} + +// SetGasPriceWei gets a reference to the given string and assigns it to the GasPriceWei field. +func (o *Route) SetGasPriceWei(v string) { + o.GasPriceWei = &v +} + +// GetGasUseEstimateQuote returns the GasUseEstimateQuote field value if set, zero value otherwise. +func (o *Route) GetGasUseEstimateQuote() string { + if o == nil || IsNil(o.GasUseEstimateQuote) { + var ret string + return ret + } + return *o.GasUseEstimateQuote +} + +// GetGasUseEstimateQuoteOk returns a tuple with the GasUseEstimateQuote field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Route) GetGasUseEstimateQuoteOk() (*string, bool) { + if o == nil || IsNil(o.GasUseEstimateQuote) { + return nil, false + } + return o.GasUseEstimateQuote, true +} + +// HasGasUseEstimateQuote returns a boolean if a field has been set. +func (o *Route) HasGasUseEstimateQuote() bool { + if o != nil && !IsNil(o.GasUseEstimateQuote) { + return true + } + + return false +} + +// SetGasUseEstimateQuote gets a reference to the given string and assigns it to the GasUseEstimateQuote field. +func (o *Route) SetGasUseEstimateQuote(v string) { + o.GasUseEstimateQuote = &v +} + +// GetGasUseEstimate returns the GasUseEstimate field value if set, zero value otherwise. +func (o *Route) GetGasUseEstimate() string { + if o == nil || IsNil(o.GasUseEstimate) { + var ret string + return ret + } + return *o.GasUseEstimate +} + +// GetGasUseEstimateOk returns a tuple with the GasUseEstimate field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Route) GetGasUseEstimateOk() (*string, bool) { + if o == nil || IsNil(o.GasUseEstimate) { + return nil, false + } + return o.GasUseEstimate, true +} + +// HasGasUseEstimate returns a boolean if a field has been set. +func (o *Route) HasGasUseEstimate() bool { + if o != nil && !IsNil(o.GasUseEstimate) { + return true + } + + return false +} + +// SetGasUseEstimate gets a reference to the given string and assigns it to the GasUseEstimate field. +func (o *Route) SetGasUseEstimate(v string) { + o.GasUseEstimate = &v +} + +// GetMethodParameters returns the MethodParameters field value if set, zero value otherwise. +func (o *Route) GetMethodParameters() RouteMethodParameters { + if o == nil || IsNil(o.MethodParameters) { + var ret RouteMethodParameters + return ret + } + return *o.MethodParameters +} + +// GetMethodParametersOk returns a tuple with the MethodParameters field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Route) GetMethodParametersOk() (*RouteMethodParameters, bool) { + if o == nil || IsNil(o.MethodParameters) { + return nil, false + } + return o.MethodParameters, true +} + +// HasMethodParameters returns a boolean if a field has been set. +func (o *Route) HasMethodParameters() bool { + if o != nil && !IsNil(o.MethodParameters) { + return true + } + + return false +} + +// SetMethodParameters gets a reference to the given RouteMethodParameters and assigns it to the MethodParameters field. +func (o *Route) SetMethodParameters(v RouteMethodParameters) { + o.MethodParameters = &v +} + +func (o Route) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o Route) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Quote) { + toSerialize["quote"] = o.Quote + } + if !IsNil(o.QuoteGasAdjusted) { + toSerialize["quoteGasAdjusted"] = o.QuoteGasAdjusted + } + if !IsNil(o.GasPriceWei) { + toSerialize["gasPriceWei"] = o.GasPriceWei + } + if !IsNil(o.GasUseEstimateQuote) { + toSerialize["gasUseEstimateQuote"] = o.GasUseEstimateQuote + } + if !IsNil(o.GasUseEstimate) { + toSerialize["gasUseEstimate"] = o.GasUseEstimate + } + if !IsNil(o.MethodParameters) { + toSerialize["methodParameters"] = o.MethodParameters + } + return toSerialize, nil +} + +type NullableRoute struct { + value *Route + isSet bool +} + +func (v NullableRoute) Get() *Route { + return v.value +} + +func (v *NullableRoute) Set(val *Route) { + v.value = val + v.isSet = true +} + +func (v NullableRoute) IsSet() bool { + return v.isSet +} + +func (v *NullableRoute) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableRoute(val *Route) *NullableRoute { + return &NullableRoute{value: val, isSet: true} +} + +func (v NullableRoute) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableRoute) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_route_method_parameters.go b/api/uniswapxservice/model_route_method_parameters.go new file mode 100644 index 00000000..583ec567 --- /dev/null +++ b/api/uniswapxservice/model_route_method_parameters.go @@ -0,0 +1,197 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" +) + +// checks if the RouteMethodParameters type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &RouteMethodParameters{} + +// RouteMethodParameters struct for RouteMethodParameters +type RouteMethodParameters struct { + Calldata *string `json:"calldata,omitempty"` + Value *string `json:"value,omitempty"` + // EIP-55 checksummed Ethereum address. + To *string `json:"to,omitempty"` +} + +// NewRouteMethodParameters instantiates a new RouteMethodParameters object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewRouteMethodParameters() *RouteMethodParameters { + this := RouteMethodParameters{} + return &this +} + +// NewRouteMethodParametersWithDefaults instantiates a new RouteMethodParameters object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewRouteMethodParametersWithDefaults() *RouteMethodParameters { + this := RouteMethodParameters{} + return &this +} + +// GetCalldata returns the Calldata field value if set, zero value otherwise. +func (o *RouteMethodParameters) GetCalldata() string { + if o == nil || IsNil(o.Calldata) { + var ret string + return ret + } + return *o.Calldata +} + +// GetCalldataOk returns a tuple with the Calldata field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RouteMethodParameters) GetCalldataOk() (*string, bool) { + if o == nil || IsNil(o.Calldata) { + return nil, false + } + return o.Calldata, true +} + +// HasCalldata returns a boolean if a field has been set. +func (o *RouteMethodParameters) HasCalldata() bool { + if o != nil && !IsNil(o.Calldata) { + return true + } + + return false +} + +// SetCalldata gets a reference to the given string and assigns it to the Calldata field. +func (o *RouteMethodParameters) SetCalldata(v string) { + o.Calldata = &v +} + +// GetValue returns the Value field value if set, zero value otherwise. +func (o *RouteMethodParameters) GetValue() string { + if o == nil || IsNil(o.Value) { + var ret string + return ret + } + return *o.Value +} + +// GetValueOk returns a tuple with the Value field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RouteMethodParameters) GetValueOk() (*string, bool) { + if o == nil || IsNil(o.Value) { + return nil, false + } + return o.Value, true +} + +// HasValue returns a boolean if a field has been set. +func (o *RouteMethodParameters) HasValue() bool { + if o != nil && !IsNil(o.Value) { + return true + } + + return false +} + +// SetValue gets a reference to the given string and assigns it to the Value field. +func (o *RouteMethodParameters) SetValue(v string) { + o.Value = &v +} + +// GetTo returns the To field value if set, zero value otherwise. +func (o *RouteMethodParameters) GetTo() string { + if o == nil || IsNil(o.To) { + var ret string + return ret + } + return *o.To +} + +// GetToOk returns a tuple with the To field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *RouteMethodParameters) GetToOk() (*string, bool) { + if o == nil || IsNil(o.To) { + return nil, false + } + return o.To, true +} + +// HasTo returns a boolean if a field has been set. +func (o *RouteMethodParameters) HasTo() bool { + if o != nil && !IsNil(o.To) { + return true + } + + return false +} + +// SetTo gets a reference to the given string and assigns it to the To field. +func (o *RouteMethodParameters) SetTo(v string) { + o.To = &v +} + +func (o RouteMethodParameters) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o RouteMethodParameters) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Calldata) { + toSerialize["calldata"] = o.Calldata + } + if !IsNil(o.Value) { + toSerialize["value"] = o.Value + } + if !IsNil(o.To) { + toSerialize["to"] = o.To + } + return toSerialize, nil +} + +type NullableRouteMethodParameters struct { + value *RouteMethodParameters + isSet bool +} + +func (v NullableRouteMethodParameters) Get() *RouteMethodParameters { + return v.value +} + +func (v *NullableRouteMethodParameters) Set(val *RouteMethodParameters) { + v.value = val + v.isSet = true +} + +func (v NullableRouteMethodParameters) IsSet() bool { + return v.isSet +} + +func (v *NullableRouteMethodParameters) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableRouteMethodParameters(val *RouteMethodParameters) *NullableRouteMethodParameters { + return &NullableRouteMethodParameters{value: val, isSet: true} +} + +func (v NullableRouteMethodParameters) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableRouteMethodParameters) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_settled_amount.go b/api/uniswapxservice/model_settled_amount.go new file mode 100644 index 00000000..47ec0b2c --- /dev/null +++ b/api/uniswapxservice/model_settled_amount.go @@ -0,0 +1,234 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" +) + +// checks if the SettledAmount type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &SettledAmount{} + +// SettledAmount Defined when the order has been filled and the fill amounts have been recorded. +type SettledAmount struct { + TokenOut *string `json:"tokenOut,omitempty" validate:"regexp=^(0x)?[0-9a-fA-F]{40}$"` + // uint256 encoded as a base-10 string. + AmountOut *string `json:"amountOut,omitempty" validate:"regexp=^[0-9]{1,78}$"` + TokenIn *string `json:"tokenIn,omitempty" validate:"regexp=^(0x)?[0-9a-fA-F]{40}$"` + // uint256 encoded as a base-10 string. + AmountIn *string `json:"amountIn,omitempty" validate:"regexp=^[0-9]{1,78}$"` +} + +// NewSettledAmount instantiates a new SettledAmount object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewSettledAmount() *SettledAmount { + this := SettledAmount{} + return &this +} + +// NewSettledAmountWithDefaults instantiates a new SettledAmount object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewSettledAmountWithDefaults() *SettledAmount { + this := SettledAmount{} + return &this +} + +// GetTokenOut returns the TokenOut field value if set, zero value otherwise. +func (o *SettledAmount) GetTokenOut() string { + if o == nil || IsNil(o.TokenOut) { + var ret string + return ret + } + return *o.TokenOut +} + +// GetTokenOutOk returns a tuple with the TokenOut field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *SettledAmount) GetTokenOutOk() (*string, bool) { + if o == nil || IsNil(o.TokenOut) { + return nil, false + } + return o.TokenOut, true +} + +// HasTokenOut returns a boolean if a field has been set. +func (o *SettledAmount) HasTokenOut() bool { + if o != nil && !IsNil(o.TokenOut) { + return true + } + + return false +} + +// SetTokenOut gets a reference to the given string and assigns it to the TokenOut field. +func (o *SettledAmount) SetTokenOut(v string) { + o.TokenOut = &v +} + +// GetAmountOut returns the AmountOut field value if set, zero value otherwise. +func (o *SettledAmount) GetAmountOut() string { + if o == nil || IsNil(o.AmountOut) { + var ret string + return ret + } + return *o.AmountOut +} + +// GetAmountOutOk returns a tuple with the AmountOut field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *SettledAmount) GetAmountOutOk() (*string, bool) { + if o == nil || IsNil(o.AmountOut) { + return nil, false + } + return o.AmountOut, true +} + +// HasAmountOut returns a boolean if a field has been set. +func (o *SettledAmount) HasAmountOut() bool { + if o != nil && !IsNil(o.AmountOut) { + return true + } + + return false +} + +// SetAmountOut gets a reference to the given string and assigns it to the AmountOut field. +func (o *SettledAmount) SetAmountOut(v string) { + o.AmountOut = &v +} + +// GetTokenIn returns the TokenIn field value if set, zero value otherwise. +func (o *SettledAmount) GetTokenIn() string { + if o == nil || IsNil(o.TokenIn) { + var ret string + return ret + } + return *o.TokenIn +} + +// GetTokenInOk returns a tuple with the TokenIn field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *SettledAmount) GetTokenInOk() (*string, bool) { + if o == nil || IsNil(o.TokenIn) { + return nil, false + } + return o.TokenIn, true +} + +// HasTokenIn returns a boolean if a field has been set. +func (o *SettledAmount) HasTokenIn() bool { + if o != nil && !IsNil(o.TokenIn) { + return true + } + + return false +} + +// SetTokenIn gets a reference to the given string and assigns it to the TokenIn field. +func (o *SettledAmount) SetTokenIn(v string) { + o.TokenIn = &v +} + +// GetAmountIn returns the AmountIn field value if set, zero value otherwise. +func (o *SettledAmount) GetAmountIn() string { + if o == nil || IsNil(o.AmountIn) { + var ret string + return ret + } + return *o.AmountIn +} + +// GetAmountInOk returns a tuple with the AmountIn field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *SettledAmount) GetAmountInOk() (*string, bool) { + if o == nil || IsNil(o.AmountIn) { + return nil, false + } + return o.AmountIn, true +} + +// HasAmountIn returns a boolean if a field has been set. +func (o *SettledAmount) HasAmountIn() bool { + if o != nil && !IsNil(o.AmountIn) { + return true + } + + return false +} + +// SetAmountIn gets a reference to the given string and assigns it to the AmountIn field. +func (o *SettledAmount) SetAmountIn(v string) { + o.AmountIn = &v +} + +func (o SettledAmount) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o SettledAmount) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.TokenOut) { + toSerialize["tokenOut"] = o.TokenOut + } + if !IsNil(o.AmountOut) { + toSerialize["amountOut"] = o.AmountOut + } + if !IsNil(o.TokenIn) { + toSerialize["tokenIn"] = o.TokenIn + } + if !IsNil(o.AmountIn) { + toSerialize["amountIn"] = o.AmountIn + } + return toSerialize, nil +} + +type NullableSettledAmount struct { + value *SettledAmount + isSet bool +} + +func (v NullableSettledAmount) Get() *SettledAmount { + return v.value +} + +func (v *NullableSettledAmount) Set(val *SettledAmount) { + v.value = val + v.isSet = true +} + +func (v NullableSettledAmount) IsSet() bool { + return v.isSet +} + +func (v *NullableSettledAmount) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableSettledAmount(val *SettledAmount) *NullableSettledAmount { + return &NullableSettledAmount{value: val, isSet: true} +} + +func (v NullableSettledAmount) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableSettledAmount) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/model_sort_key.go b/api/uniswapxservice/model_sort_key.go new file mode 100644 index 00000000..b65951e0 --- /dev/null +++ b/api/uniswapxservice/model_sort_key.go @@ -0,0 +1,108 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "encoding/json" + "fmt" +) + +// SortKey the model 'SortKey' +type SortKey string + +// List of SortKey +const ( + CREATED_AT SortKey = "createdAt" +) + +// All allowed values of SortKey enum +var AllowedSortKeyEnumValues = []SortKey{ + "createdAt", +} + +func (v *SortKey) UnmarshalJSON(src []byte) error { + var value string + err := json.Unmarshal(src, &value) + if err != nil { + return err + } + enumTypeValue := SortKey(value) + for _, existing := range AllowedSortKeyEnumValues { + if existing == enumTypeValue { + *v = enumTypeValue + return nil + } + } + + return fmt.Errorf("%+v is not a valid SortKey", value) +} + +// NewSortKeyFromValue returns a pointer to a valid SortKey +// for the value passed as argument, or an error if the value passed is not allowed by the enum +func NewSortKeyFromValue(v string) (*SortKey, error) { + ev := SortKey(v) + if ev.IsValid() { + return &ev, nil + } else { + return nil, fmt.Errorf("invalid value '%v' for SortKey: valid values are %v", v, AllowedSortKeyEnumValues) + } +} + +// IsValid return true if the value is valid for the enum, false otherwise +func (v SortKey) IsValid() bool { + for _, existing := range AllowedSortKeyEnumValues { + if existing == v { + return true + } + } + return false +} + +// Ptr returns reference to SortKey value +func (v SortKey) Ptr() *SortKey { + return &v +} + +type NullableSortKey struct { + value *SortKey + isSet bool +} + +func (v NullableSortKey) Get() *SortKey { + return v.value +} + +func (v *NullableSortKey) Set(val *SortKey) { + v.value = val + v.isSet = true +} + +func (v NullableSortKey) IsSet() bool { + return v.isSet +} + +func (v *NullableSortKey) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableSortKey(val *SortKey) *NullableSortKey { + return &NullableSortKey{value: val, isSet: true} +} + +func (v NullableSortKey) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableSortKey) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/api/uniswapxservice/response.go b/api/uniswapxservice/response.go new file mode 100644 index 00000000..ef36bb75 --- /dev/null +++ b/api/uniswapxservice/response.go @@ -0,0 +1,47 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "net/http" +) + +// APIResponse stores the API response returned by the server. +type APIResponse struct { + *http.Response `json:"-"` + Message string `json:"message,omitempty"` + // Operation is the name of the OpenAPI operation. + Operation string `json:"operation,omitempty"` + // RequestURL is the request URL. This value is always available, even if the + // embedded *http.Response is nil. + RequestURL string `json:"url,omitempty"` + // Method is the HTTP method used for the request. This value is always + // available, even if the embedded *http.Response is nil. + Method string `json:"method,omitempty"` + // Payload holds the contents of the response body (which may be nil or empty). + // This is provided here as the raw response.Body() reader will have already + // been drained. + Payload []byte `json:"-"` +} + +// NewAPIResponse returns a new APIResponse object. +func NewAPIResponse(r *http.Response) *APIResponse { + + response := &APIResponse{Response: r} + return response +} + +// NewAPIResponseWithError returns a new APIResponse object with the provided error message. +func NewAPIResponseWithError(errorMessage string) *APIResponse { + + response := &APIResponse{Message: errorMessage} + return response +} diff --git a/api/uniswapxservice/utils.go b/api/uniswapxservice/utils.go new file mode 100644 index 00000000..012e5d78 --- /dev/null +++ b/api/uniswapxservice/utils.go @@ -0,0 +1,361 @@ +/* +UniswapX + +REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification. + +API version: 2.0.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package uniswapxservice + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "time" +) + +// PtrBool is a helper routine that returns a pointer to given boolean value. +func PtrBool(v bool) *bool { return &v } + +// PtrInt is a helper routine that returns a pointer to given integer value. +func PtrInt(v int) *int { return &v } + +// PtrInt32 is a helper routine that returns a pointer to given integer value. +func PtrInt32(v int32) *int32 { return &v } + +// PtrInt64 is a helper routine that returns a pointer to given integer value. +func PtrInt64(v int64) *int64 { return &v } + +// PtrFloat32 is a helper routine that returns a pointer to given float value. +func PtrFloat32(v float32) *float32 { return &v } + +// PtrFloat64 is a helper routine that returns a pointer to given float value. +func PtrFloat64(v float64) *float64 { return &v } + +// PtrString is a helper routine that returns a pointer to given string value. +func PtrString(v string) *string { return &v } + +// PtrTime is helper routine that returns a pointer to given Time value. +func PtrTime(v time.Time) *time.Time { return &v } + +type NullableBool struct { + value *bool + isSet bool +} + +func (v NullableBool) Get() *bool { + return v.value +} + +func (v *NullableBool) Set(val *bool) { + v.value = val + v.isSet = true +} + +func (v NullableBool) IsSet() bool { + return v.isSet +} + +func (v *NullableBool) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableBool(val *bool) *NullableBool { + return &NullableBool{value: val, isSet: true} +} + +func (v NullableBool) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableBool) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + +type NullableInt struct { + value *int + isSet bool +} + +func (v NullableInt) Get() *int { + return v.value +} + +func (v *NullableInt) Set(val *int) { + v.value = val + v.isSet = true +} + +func (v NullableInt) IsSet() bool { + return v.isSet +} + +func (v *NullableInt) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableInt(val *int) *NullableInt { + return &NullableInt{value: val, isSet: true} +} + +func (v NullableInt) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableInt) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + +type NullableInt32 struct { + value *int32 + isSet bool +} + +func (v NullableInt32) Get() *int32 { + return v.value +} + +func (v *NullableInt32) Set(val *int32) { + v.value = val + v.isSet = true +} + +func (v NullableInt32) IsSet() bool { + return v.isSet +} + +func (v *NullableInt32) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableInt32(val *int32) *NullableInt32 { + return &NullableInt32{value: val, isSet: true} +} + +func (v NullableInt32) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableInt32) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + +type NullableInt64 struct { + value *int64 + isSet bool +} + +func (v NullableInt64) Get() *int64 { + return v.value +} + +func (v *NullableInt64) Set(val *int64) { + v.value = val + v.isSet = true +} + +func (v NullableInt64) IsSet() bool { + return v.isSet +} + +func (v *NullableInt64) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableInt64(val *int64) *NullableInt64 { + return &NullableInt64{value: val, isSet: true} +} + +func (v NullableInt64) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableInt64) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + +type NullableFloat32 struct { + value *float32 + isSet bool +} + +func (v NullableFloat32) Get() *float32 { + return v.value +} + +func (v *NullableFloat32) Set(val *float32) { + v.value = val + v.isSet = true +} + +func (v NullableFloat32) IsSet() bool { + return v.isSet +} + +func (v *NullableFloat32) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableFloat32(val *float32) *NullableFloat32 { + return &NullableFloat32{value: val, isSet: true} +} + +func (v NullableFloat32) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableFloat32) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + +type NullableFloat64 struct { + value *float64 + isSet bool +} + +func (v NullableFloat64) Get() *float64 { + return v.value +} + +func (v *NullableFloat64) Set(val *float64) { + v.value = val + v.isSet = true +} + +func (v NullableFloat64) IsSet() bool { + return v.isSet +} + +func (v *NullableFloat64) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableFloat64(val *float64) *NullableFloat64 { + return &NullableFloat64{value: val, isSet: true} +} + +func (v NullableFloat64) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableFloat64) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + +type NullableString struct { + value *string + isSet bool +} + +func (v NullableString) Get() *string { + return v.value +} + +func (v *NullableString) Set(val *string) { + v.value = val + v.isSet = true +} + +func (v NullableString) IsSet() bool { + return v.isSet +} + +func (v *NullableString) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableString(val *string) *NullableString { + return &NullableString{value: val, isSet: true} +} + +func (v NullableString) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableString) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + +type NullableTime struct { + value *time.Time + isSet bool +} + +func (v NullableTime) Get() *time.Time { + return v.value +} + +func (v *NullableTime) Set(val *time.Time) { + v.value = val + v.isSet = true +} + +func (v NullableTime) IsSet() bool { + return v.isSet +} + +func (v *NullableTime) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableTime(val *time.Time) *NullableTime { + return &NullableTime{value: val, isSet: true} +} + +func (v NullableTime) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableTime) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + +// IsNil checks if an input is nil +func IsNil(i interface{}) bool { + if i == nil { + return true + } + switch reflect.TypeOf(i).Kind() { + case reflect.Chan, reflect.Func, reflect.Map, reflect.Ptr, reflect.UnsafePointer, reflect.Interface, reflect.Slice: + return reflect.ValueOf(i).IsNil() + case reflect.Array: + return reflect.ValueOf(i).IsZero() + } + return false +} + +type MappedNullable interface { + ToMap() (map[string]interface{}, error) +} + +// A wrapper for strict JSON decoding +func newStrictDecoder(data []byte) *json.Decoder { + dec := json.NewDecoder(bytes.NewBuffer(data)) + dec.DisallowUnknownFields() + return dec +} + +// Prevent trying to import "fmt" +func reportError(format string, a ...interface{}) error { + return fmt.Errorf(format, a...) +} diff --git a/cmd/vault-solver/root.go b/cmd/vault-solver/root.go index 6bdcd7b0..23495fdd 100644 --- a/cmd/vault-solver/root.go +++ b/cmd/vault-solver/root.go @@ -6,8 +6,10 @@ 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/lifi" _ "github.com/symbioticfi/vault-solver/internal/solvers/redstoneoev" _ "github.com/symbioticfi/vault-solver/internal/solvers/rfq" + _ "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx" ) func newRootCmd() *cobra.Command { diff --git a/cmd/vault-solver/run.go b/cmd/vault-solver/run.go index f01c9cd3..a6e019e7 100644 --- a/cmd/vault-solver/run.go +++ b/cmd/vault-solver/run.go @@ -2,6 +2,7 @@ package main import ( "context" + "time" "github.com/go-errors/errors" "github.com/spf13/cobra" @@ -92,9 +93,11 @@ func runBot(ctx context.Context, configPath string, debugFlag, debugFlagSet bool // Shared, nonce-serialized transaction sender. txm := txmanager.New(chainClient, sgnr, chainClient.ChainID(), txmanager.Config{ - Confirmations: cfg.TxManager.Confirmations, - MaxFeeGwei: cfg.TxManager.MaxFeeGwei, - TipGwei: cfg.TxManager.TipGwei, + Confirmations: cfg.TxManager.Confirmations, + MaxFeeGwei: cfg.TxManager.MaxFeeGwei, + TipGwei: cfg.TxManager.TipGwei, + ReplacementInterval: time.Duration(cfg.TxManager.ReplacementIntervalMs) * time.Millisecond, + PendingTimeout: time.Duration(cfg.TxManager.PendingTimeoutMs) * time.Millisecond, }, log) go txm.Start(ctx) diff --git a/config/3f.example.yaml b/config/3f.example.yaml index 097c1f84..25b528a0 100644 --- a/config/3f.example.yaml +++ b/config/3f.example.yaml @@ -2,7 +2,7 @@ # # 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). +# are the 3F Sepolia dev deployment. # # 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. @@ -27,7 +27,9 @@ signer: 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 + maxFeeGwei: 50 # required absolute ceiling; normal sends reserve one bump for cancellation + replacementIntervalMs: 30000 # fee-bump pending transactions every 30s + pendingTimeoutMs: 300000 # cancel the lowest blocked nonce after 5m # tipGwei: 1 # priority fee; omit to use the node's suggestion observability: @@ -40,21 +42,31 @@ solvers: 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) + # offerExpiryBuffer: 2h # signed-offer expiry = auction solve_start_time + this (default 2h) 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 + # When adapters is omitted, all factory entities are enumerated at startup and before every + # discovery pass, with a hard limit of 2,000 (a larger reported count is an error). A factory with + # no entities is valid: the daemon stays ready and picks up later deployments automatically. + # Uncommenting adapters makes that list exclusive and skips factory discovery. Either source keeps + # only entries that authorize this solver's signer (validated via the adapter's ERC-1271 + # isValidSignature — the solver EOA or an EIP-1271 contract signer) and whose + # adapter.vault()/vault.asset() resolve to non-zero addresses. + adapterFactory: "0x5e0c165Acc4653bD4cf966A454C9aF1647E65Eb1" + # liquidityLens: "0x..." # optional FrontendLiquidityLens; when set, funding headroom is read + # from its cross-adapter deallocation-cascade estimate instead of the + # adapter's own getMaxAssets(). Omit to use the adapter getter. + # adapters: # optional exclusive override + # - "0x..." + + # Per-request caps (min yield / min & max assets per request), funding headroom (getMaxAssets), + # and concurrency (MAX_REQUESTS) live on each adapter and are read on-chain — never configured. intervals: - discover: 1h # how often to poll for open auctions and (re)offer coverage + discover: 5m # refresh adapters, then poll 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/lifi.example.yaml b/config/lifi.example.yaml new file mode 100644 index 00000000..645e43be --- /dev/null +++ b/config/lifi.example.yaml @@ -0,0 +1,95 @@ +# vault-solver — LI.FI same-chain intent solver (`lifi-samechain`), annotated example. +# +# Publishes standing quotes to the LI.FI Intents order server for LiquidLane-backed same-chain +# RWA -> underlying routes, then listens for matched escrow orders over the LI.FI WebSocket feed. +# +# 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} + chainId: 11155111 + # writeRpcUrl: ${WRITE_RPC_URL} + # rpcFallbackUrls: + # - ${ETH_RPC_URL_SEPOLIA_BACKUP} + +signer: + # Runtime caller and tx sender. The executor owner adds it through setCallers(); startup checks isCaller(). + keyEnv: SOLVER_PRIVATE_KEY + +txManager: + confirmations: 2 + maxFeeGwei: 50 # required absolute ceiling; normal sends reserve one bump for cancellation + replacementIntervalMs: 30000 # replace a pending call with higher fees every 30s + pendingTimeoutMs: 300000 # after 5m, cancel the lowest blocked nonce + # Each LI.FI fill also pins its own decision-time fee cap; txmanager clamps fee and tip to that + # budget and drops only when it no longer covers base fee. + # tipGwei: 1 + +observability: + addr: ":9090" + debug: false + +solvers: + - name: lifi-samechain + config: + strategy: + name: default + config: + priceBufferBps: 20 # one rate-move buffer, also reserves upward private-discount output movement + inventoryReserveBps: 500 # never advertise the final 5% of getMaxAssets + minAmount: "1000000" # tokenIn floor; choose it high enough to cover gas and rounding + rangeCount: 8 # geometric ranges per pair (default 8, max 16) + executionDeadlineBuffer: 12s # one Ethereum block left for order and private signatures + + # Gas conversion is a solver fact shared by every strategy. Configure one Chainlink USD feed for + # every distinct adapter vault asset (tokenOut); startup fails if any resolved route is uncovered. + gas: + nativeUsdFeed: ${ETH_USD_FEED} + nativeMaxAge: 2h # actual heartbeat plus testnet publication slack + tokenUsdFeeds: + - token: "0x468BB3245BF520a0CD030BDE029c98aCEAF84C9d" # TLOAN (6 decimals) + feed: ${TLOAN_USD_FEED} + maxAge: 24h # set independently for every token/USD feed + + # External strategy alternative: + # strategy: + # name: webhook + # config: + # url: https://strategy.example/lifi + # timeout: 5s + + orderServer: + baseUrl: https://order-dev.li.fi + wsUrl: wss://order-dev.li.fi + # Deployment convention: one key per executor; all processes using it share its reputation. + apiKeyEnv: LIFI_SOLVER_API_KEY + # httpTimeout: 10s + + # Mirrors RFQ deployment profiles. "external" (default) uses only direct filler-authorized + # adapters. "internal" also uses the shared private-discounts API; the URL is required then. + solverMode: external + # solverMode: internal + # privateDiscountsUrl: ${RFQ_BACKEND_URL} + inputSettler: "0x000025c3226C00B2Cdc200005a1600509f4e00C0" # LI.FI InputSettlerEscrowLIFI + outputSettler: "0x0000000000eC36B683C2E6AC89e9A75989C22a2e" # LI.FI OutputSettler + executor: "0x0000000000000000000000000000000000000000" # registered EIP-1271 LI.FI solver + # liquidityLens: "0x..." # optional FrontendLiquidityLens; when set, LiquidLane swappable + # headroom is read from its cross-adapter deallocation-cascade + # estimate instead of each adapter's getMaxAssets(). Omit to use the adapter getter. + + # LiquidLane adapter instances this solver serves. Each adapter's vault/asset and tokenToRedeem + # list are resolved on-chain at startup; this config is the executor's route scope. + # Direct filler authorization is required in external mode; signed discounts authorize internal fills. + adapters: + - "0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b" # Sepolia TCOL -> TLOAN testbed adapter + + # Scope input tokens against permissionedTokens: "all" (default), "permissioned" (only listed), + # or "permissionless" (only unlisted). Permissioned scope also requires one physical route. + # tokensToQuote: all + # permissionedTokens: + # - "0x..." + + quoteIntervalMs: 1000 # block poll interval; quotes are recalculated only on a new block + quoteTtl: 36s # rolling expiry, about three Ethereum blocks + quoteRefreshMode: block # "block" (default) | "interval" diff --git a/config/redstone-oev.example.yaml b/config/redstone-oev.example.yaml index 8000d8f6..c9350289 100644 --- a/config/redstone-oev.example.yaml +++ b/config/redstone-oev.example.yaml @@ -31,6 +31,8 @@ solvers: executor: "0xfdFB1862a53a974b166d1f0D012f524Ebd2e0EbD" # RedStone Atom Executor (proxy) — verifies our bid signature adapter: "0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b" # LiquidLane adapter served by this solver (TLOAN vault / TCOL collateral) + # liquidityLens: "0x..." # optional FrontendLiquidityLens; reads LiquidLane headroom from its + # cross-adapter deallocation-cascade estimate instead of getMaxAssets(). Omit to use the adapter getter. callback: "0x065B612a182f360D4428cD00a8094049B3c92168" # SymbioticOevSolver — receives Executor callback and pays the bid strategy: diff --git a/config/rfq.example.yaml b/config/rfq.example.yaml index 3c4c5fc3..957b0670 100644 --- a/config/rfq.example.yaml +++ b/config/rfq.example.yaml @@ -25,7 +25,9 @@ signer: 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 + maxFeeGwei: 50 # required absolute ceiling; normal sends reserve one bump for cancellation + replacementIntervalMs: 30000 # fee-bump pending transactions every 30s + pendingTimeoutMs: 300000 # cancel the lowest blocked nonce after 5m # tipGwei: 1 # priority fee; omit to use the node's suggestion observability: @@ -47,8 +49,10 @@ solvers: orderLimit: 20 # max open orders fetched per poll # RFQ contract deployment (mainnet): - executor: "0xe60E84218BB81539cc599A1E213d6F67058C69Cf" # Executor — the bot calls Executor.fill to settle - reactor: "0xAE1c0995Daa1C0e56Df6c31207c69FBB5278A5B7" # Reactor — invoked by the Executor at fill time + executor: "0x031f569DA822A6b8D1500D74733986a9aE4cBda3" # Executor — the bot calls Executor.fill to settle + reactor: "0xC323B898d7E4105E3980082B74CC5D4602996B10" # Reactor — invoked by the Executor at fill time + # liquidityLens: "0x..." # optional FrontendLiquidityLens; reads LiquidLane headroom from its + # cross-adapter deallocation-cascade estimate instead of getMaxAssets(). Omit to use the adapter getter. # solverMode: "external" (default) | "internal". # external — the open-source filler: never touches the discounts API; `adapters` is REQUIRED and @@ -58,12 +62,18 @@ solvers: solverMode: external # tokensToQuote scopes which input tokens the filler will quote, evaluated against - # permissionedTokens: "all" (default) | "permissioned" (only tokens in the list below) | + # permissionedTokens. Values: "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 + # permissionedTokens: # with tokensToQuote: permissioned, inputs must use one candidate route # - "0x..." + # minAmountsIn sets a per-input-token floor on request size, in that token's BASE UNITS (decimal + # string). A request below its token's minimum gets no quote (HTTP 204); an amount equal to the + # minimum still quotes. Tokens not listed have no minimum. + # minAmountsIn: + # "0x238a700eD6165261Cf8b2e544ba797BC11e466Ba": "1000000000000000000" # 1 token (18 decimals) + # 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 diff --git a/config/uniswapx.example.yaml b/config/uniswapx.example.yaml new file mode 100644 index 00000000..9295815a --- /dev/null +++ b/config/uniswapx.example.yaml @@ -0,0 +1,86 @@ +# UniswapX V2 quoter + filler for Ethereum mainnet. +# Uniswap must allowlist the public quote endpoint and executor before beta traffic reaches this solver. +# Secrets are referenced by env-var name; ${VAR} values are expanded when the config is loaded. + +chain: + rpcUrl: ${ETH_RPC_URL_MAINNET} # primary Ethereum RPC for reads, fills, and receipts + chainId: 1 # must match the RPC chain ID + +signer: + keyEnv: SOLVER_PRIVATE_KEY # executor caller key; membership is checked at startup + +txManager: + confirmations: 2 # confirmations awaited before capacity is released + +observability: + addr: ":9090" # framework metrics and health endpoints + debug: false # enable debug-level logs + +solvers: + - name: uniswapx-filler + config: + # Expected V2 Reactor; verify the executor immutable at deployment because the ABI has no getter. + reactor: "0x00000011F84B9aa48e5f8aA8B9897600006289Be" + executor: ${UNISWAPX_EXECUTOR} # advertised filler; bytecode and caller are checked at startup + # liquidityLens: "0x..." # optional FrontendLiquidityLens; reads LiquidLane headroom from its + # cross-adapter deallocation-cascade estimate instead of getMaxAssets(). Omit to use the adapter getter. + + # Direct LiquidLane adapter scope. Required in external mode. + # In internal mode this list is optional: when present it scopes quotes and direct fills, + # while signed-discount fills may recover through any valid adapter advertised by the backend. + adapters: + - ${LIQUIDLANE_ADAPTER} + + # external (default): direct filler-authorized routes only. + # internal: enables signed discounts and permits an empty adapters list. + solverMode: external + # solverMode: internal + + # Input-token scope: all, permissioned (listed only), or permissionless (unlisted only). + tokensToQuote: permissioned + permissionedTokens: + - ${TOKEN_TO_REDEEM} # RWA input token accepted by this solver + + quoteServer: + listenAddress: ":42080" # public POST /quote plus /healthz and /ready + httpTimeout: 450ms # quote request read/write deadline + refreshInterval: 12s # cadence for inventory, gas, and price snapshots + quoteTtl: 30s # quote-state lifetime; must be >= 2x refreshInterval + + orderServer: + baseUrl: https://api.uniswap.org/v2 # UniswapX order-service API root + apiKeyEnv: UNISWAPX_ORDER_API_KEY # env var containing the Uniswap API key + pollInterval: 1s # order polling cadence; minimum 167ms + httpTimeout: 5s # timeout for each order API request + beta: true # send the x-beta-rfq header + sources: + exclusiveV2: true # awarded orders plus terminal reconciliation; required while quoting + publicV2: true # also compete for permissionless public V2 orders + + # Required only in internal solverMode; forbidden in external mode. + # discounts: + # baseUrl: ${RFQ_BACKEND_URL} # signed-discount API root + # httpTimeout: 2s # timeout for discount list and resolve requests + # minimumValidity: 15s # required remaining signature lifetime at resolution + + # Optional. Omit this entire block to exclude fill gas from quote/fill economics and skip + # gas-state and Chainlink reads. The tx manager still prices and pays actual transaction gas. + # When enabled, dynamic internal discount routes without a configured token feed are skipped. + gas: + nativeUsdFeed: ${ETH_USD_FEED} # native gas token / USD feed + nativeMaxAge: 1h # maximum accepted native feed staleness + tokenUsdFeeds: + - token: ${VAULT_ASSET} # adapter vault asset (tokenOut) + feed: ${VAULT_ASSET_USD_FEED} # tokenOut / USD feed + maxAge: 1h # maximum accepted token feed staleness + + breaker: + maxFailures: 3 # pause quotes after this many failed fills in the window + window: 5m # failure-counting window and resulting pause duration + + strategy: + name: default # in-process greedy LiquidLane pricing and fill selection + config: + priceBufferBps: 20 # rate/staleness safety margin in basis points + inventoryReserveBps: 500 # keep 5% of route capacity uncommitted + executionDeadlineBuffer: 12s # required lifetime remaining for a safe fill diff --git a/docs/3F-PLAN.md b/docs/3F-PLAN.md index d1b2f7d2..e47ccce5 100644 --- a/docs/3F-PLAN.md +++ b/docs/3F-PLAN.md @@ -21,7 +21,7 @@ repo root) §4 for the functional blueprint of the 3F solver. 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. +- **Target networks:** 3F Sepolia dev (`chainId 11155111`) and Ethereum mainnet (`chainId 1`). --- @@ -35,7 +35,7 @@ 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. | -| 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). | +| Adapter scope | One solver serves adapters from exactly one source: an explicit `adapters` list when present, otherwise a dynamic set discovered from a configured on-chain `IAdapterFactory`. Factory enumeration has a hard 2,000-entity limit and returns an error above it. The snapshot is refreshed before every auction-discovery pass; either source is filtered by `offerSigner`, non-zero vault, and non-zero asset. 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. | @@ -89,8 +89,9 @@ vault-solver/ ├── api/ │ ├── abi/ # vendored *.abi.json (copied from forge build) │ ├── bindings/ # abigen output (committed), grouped per integration: -│ │ ├── 3f/{adapter,request,vaultcontroller,whitelist}/ # 3F-specific (future: rfq/, oev/) -│ │ └── vaultv2/ # shared Symbiotic core, reused by every integration +│ │ ├── 3f/{adapter,request,vaultcontroller,whitelist}/ # 3F-specific +│ │ ├── adapterfactory/ # shared IAdapterFactory registry surface +│ │ └── vaultv2/ # shared Symbiotic core, reused by every integration │ └── threef/ # openapi-generator (Java) output (committed) ├── openapi/3f-bf.openapi.json # vendored OpenAPI snapshot ├── config/{3f,rfq,redstone-oev}.example.yaml # one annotated example per solver @@ -161,32 +162,47 @@ solvers: 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" + adapterFactory: "0x…factory" # used when adapters is omitted; max 2,000 entities + # adapters: # optional exclusive override; factory is not queried + # - "0x…adapterA" redeemBatchSize: 10 # optional (default 10) httpTimeout: 30s # optional - intervals: { discover: 1h, redeemPoll: 5m, reconcile: 15m } + intervals: { discover: 5m, 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-request caps are read on-chain** (`minYieldPerRequest` — kept in exact ppm for the offer floor +check and sent as ppm on the webhook wire (the webhook derives bps itself if it needs it); +`minAssetsPerRequest`; `maxAssetsPerRequest` — set via `setLimitsPerRequest`) — config carries the +adapter factory plus an optional exclusive adapter list. When `adapters` is present, the solver resolves +only those entries and does not query the factory. When it is omitted, the solver reads +`totalEntities()` + `entity(i)` for every registry entry on startup and before each discovery pass, then +resolves every candidate. A reported count above the hard 2,000-entity limit returns an error. A +factory-backed deployment may start with a successful empty snapshot and keep running; an +explicit-list configuration still fails startup if none of its adapters validate. A later whole-refresh +RPC failure preserves the last-known-good snapshot; a successful refresh replaces it, so signer changes +remove and can later re-add an adapter. Before every offer pass the solver reconciles its live-offer +cache against the 3F API (per adapter): it re-lists each adapter's live offers and replaces that +adapter's cache wholesale, so coverage reflects offers made out of band — a manual re-offer, a second +instance, or a server-side cancellation — and it never double-offers an auction it already covers. The +1-2 minute poll is authoritative and always surfaces our own just-submitted offers, so any pair not in +the fresh listing is gone and dropped (no local record is kept between passes). 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. A signed offer's `expiration` is anchored to the +auction's `solve_start_time` plus a configurable `offerExpiryBuffer` (default 2h), never earlier than +`now + buffer`, so the offer stays valid across the whole solve window regardless of when it is signed +(not a fixed TTL from wall-clock). ### 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. +1. **Solver-owned snapshot** — the solver lists auctions, reconciles its live-offer cache against the + API and reads each configured adapter's liquidity/exposure in Multicall, prunes the 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. @@ -215,7 +231,7 @@ Each discover tick lists open auctions (public, unauthenticated), then for each OpenCount int // requestsLength() MaxAssets uint256 // maxAssetsPerRequest, 0 = reject-all MinAssets uint256 // minAssetsPerRequest, 0 = disabled - MinYieldBps uint256 // minYieldPerRequest converted from ppm to bps + MinYieldPpm uint256 // minYieldPerRequest in ppm — exact on-chain floor (also sent on the webhook wire) MaxConcurrent int // MAX_REQUESTS } @@ -238,10 +254,15 @@ Each discover tick lists open auctions (public, unauthenticated), then for each } ``` - 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`), + The default local strategy: process auctions in API order, filter adapter eligibility (collateral + match, no live offer for the pair, the auction max rate can reach the adapter's `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. + each offer to the still-uncovered remainder, and track local adapter commitments across the pass. Each + offer is **priced at the adapter's `minYieldPerRequest` floor** (the most competitive rate it allows), + rounded up so the realised yield always clears the on-chain floor, and skipped if that floor rate + exceeds the auction's max rate for the sized principal. The submission loop re-checks every strategy's + offers (default and webhook) against the exact `minYieldPerRequest` before signing, so no path posts a + sub-floor offer the fill would revert. 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 @@ -266,8 +287,8 @@ Each discover tick lists open auctions (public, unauthenticated), then for each | `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: `ThreeFAdapter` (from core-mirror), `IRequest`/`IVaultController`, -`IWhitelist`, `IVaultV2`. +on demand. ABIs required: `ThreeFAdapter` and `IAdapterFactory` (from core-mirror), +`IRequest`/`IVaultController`, `IWhitelist`, `IVaultV2`. --- @@ -286,14 +307,22 @@ Prerequisite (done). **`ThreeFAdapter` contract** — core-mirror's `src/contrac `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. + - **Dynamic adapter sources + ERC-1271 offer-signer check.** When `adapters[]` is present it is the + exclusive source; otherwise `adapterFactory` is enumerated at startup and each discovery tick, with + a hard 2,000-entity limit that returns an error for a larger reported count. Every candidate's + vault/collateral is re-resolved, and offer-signer authorization is validated via the adapter's own + ERC-1271 `isValidSignature` (**not** an address match): a one-time payload is signed with the solver + key at startup and validated against each adapter, so an adapter is kept iff its `offerSigner` + authorizes our key — our EOA *or* an EIP-1271 contract signer; the probe is reusable across ticks. + Successful snapshots replace the active set, whole-refresh failures retain the last-known-good set, + and a factory-backed deployment may validly idle with zero eligible adapters. Explicit-list startup + retains its fail-closed behavior. The live-offer cache is reconciled against the API before every + offer pass, so out-of-band offers (manual re-offer, another instance, server-side cancellation) + count toward coverage and are never double-offered. - **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` + - Tests: strategy registry/default selection, default strategy eligibility/sizing, webhook wire shape, per-(adapter,auction) dedup, `liveCoverage`, `reconcileAdapter` wholesale replace (API-authoritative), signed `listOffers` httptest, `resolveAdapters` (incl. the ERC-1271 `isValidSignature` offer-signer probe + unauthorized-drop) 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). @@ -304,9 +333,10 @@ Prerequisite (done). **`ThreeFAdapter` contract** — core-mirror's `src/contrac - **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. +- Filtering to adapters our key is authorized to sign for is settled: the adapter's ERC-1271 + `isValidSignature` probe (§8), reused across factory-discovery ticks. +- Mainnet `RequestWhitelist` address and prod API base URL — operational onboarding inputs supplied by + 3F; the bot discovers adapter instances from their factory and does not configure the whitelist itself. - Go module path (`github.com/symbioticfi/vault-solver` placeholder) — adjust to the real org. --- @@ -319,13 +349,17 @@ Tracked TODOs and known gaps — each a scoped follow-up; none block release. - **(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. +- **(done) Dynamic adapter discovery.** When `adapters` is omitted, every entry in the configured + `IAdapterFactory` is enumerated at startup and before each discovery pass, subject to a hard 2,000-entry + limit that errors above it. When `adapters` is present, only that explicit list is used. Either source + is filtered to adapters whose non-zero vault/asset resolve and that authorize this solver's signer via + the adapter's ERC-1271 `isValidSignature` (**not** an address match; EOA or contract signer). - **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). +- **Offer cancellation.** `OfferControllerCancelV1` not wired — needs offer-id↔auction state. - **WS live-log subscription** (`chain.wsUrl`) — config field present but unused; the poll-based reconcile/redeem path is sufficient for v0. **Testing:** diff --git a/docs/LIFI-PLAN.md b/docs/LIFI-PLAN.md index bff3c011..bec7ec86 100644 --- a/docs/LIFI-PLAN.md +++ b/docs/LIFI-PLAN.md @@ -1,37 +1,49 @@ # vault-solver — LI.FI / Catalyst same-chain intent filler (plan) -Adding a **`lifi`** solver to `vault-solver` that fills **same-chain** LI.FI Intents (Open Intents -Framework / Catalyst) by redeeming the intent's input RWA through a Symbiotic **LiquidLane adapter** to -produce the output — **atomically, with no held inventory**. Follows the framework boundary and +The **`lifi-samechain`** solver fills **same-chain on-chain** LI.FI Intents (Open Intents Framework / +Catalyst). The executor contract is the registered LI.FI solver identity. Its owner +authorizes runtime callers; a caller submits the selected `FillRoute[]`, and the executor uses the input +settler's direct finalise path, +receives the claimed input RWA in the callback, redeems it through a Symbiotic +**LiquidLane adapter**, then fills and attests the output in one transaction. +Follows the framework boundary and conventions in [`../CLAUDE.md`](../CLAUDE.md); the strategy layer follows [`strategy-plan.md`](strategy-plan.md). -> **Status:** planned (design). Spans two repos: an on-chain executor contract in the sibling `rfq` -> repo, and the off-chain Go solver here. +> **Status:** the on-chain-order path is implemented and has settled a real Sepolia order end to end. +> The solver parses matched escrow orders from the WebSocket feed, takes a fresh LiquidLane fill snapshot, +> runs the strategy decision, builds `LiquidLaneLifiExecutor.finaliseWithCurrentTimestamp(...)` calldata, +> confirms `InputSettlerEscrowLIFI.orderStatus(orderId) == Deposited`, and submits through the shared +> `txmanager`. Gasless opening is explicitly out of scope. The executor is registered once through EIP-1271 +> and the framework signer is an authorized runtime caller; no per-fill solver signature is required. The +> latest ERC-1271-enabled executor ABI still requires the +> redeploy and authorization step tracked in §10 before the next live run. --- ## 1. What it does -A user signs an intent: "here is X of RWA token `tokenIn`; pay me ≥ Y of `tokenOut` (the redeemed -underlying)." The LI.FI order server matches that intent to our standing quote and pushes us the -**signed `StandardOrder`**. We settle it on-chain in **one atomic transaction** via the LI.FI escrow -settler's `openForAndFinalise`, which: +A user opens/funds an intent on-chain: "here is X of RWA token `tokenIn`; pay me ≥ Y of `tokenOut` +(the redeemed underlying)." The LI.FI order server is still used for quote discovery, status tracking, +and matched-order delivery; it pushes the `StandardOrder` to us over the solver WebSocket. We settle +that already-opened order in **one atomic transaction**: -1. pulls the user's RWA input (via their permit2/ERC-3009 signature) and hands it to **our executor - contract** (`destination`), -2. calls back into our executor (`orderFinalised`), where — with the RWA already in hand — the - executor **redeems it through the LiquidLane adapter** to produce `tokenOut`, pays the user via the - OutputSettler, and self-attests, -3. verifies the fill and reverts the whole tx if anything fell short. +1. call `LiquidLaneLifiExecutor.finaliseWithCurrentTimestamp(order, routes)` with the matched + `StandardOrder` and selected LiquidLane routes, +2. the executor calls `InputSettler.finalise(...)` with `solver = destination = address(this)`; the input + settler releases the opened order input to the executor and calls `orderFinalised(inputs, FillCall)` with + the callback payload constructed by the executor, +3. inside the callback the executor redeems the received RWA through the LiquidLane adapter, then fills + and attests the output. -Because settlement is atomic and the output is produced from the just-received input, the solver holds -**zero output inventory** and carries **no float / FX / rebalancing** risk. Profit is the redemption -surplus: `adapter.getAmountOut(RWA, X) − Y`, retained in the executor and swept by its owner. +Because input redemption and output fill are in one transaction, the executor does **not** need prefunded +output inventory for this path. The economic surplus is aggregate redeemed output minus the resolved order +output after the strategy's gas-aware checks. It remains in the executor; the current ABI has no sweep +entrypoint, so recovery requires the proxy administration path described in §7. This is the same-chain specialization of the cross-chain OIF flow. Same-chain is strictly simpler: -`inputOracle == OutputSettler` (the settler is its own oracle — no cross-chain proof relay), and -open + fill + finalise happen in one tx. +`inputOracle == OutputSettler` (the settler is its own oracle — no cross-chain proof relay). The +user/order creator is responsible for the on-chain open step before the solver sees the order. --- @@ -40,101 +52,135 @@ open + fill + finalise happen in one tx. A new self-contained `internal/solvers/lifi/` implementing `solver.Solver` — no framework edits (CLAUDE.md modularity rule). Reused as-is: -- **`Run(ctx)`** connects to the LI.FI order server (WebSocket order feed), refreshes standing quotes - on an interval, and drives the fill loop; blocks until ctx cancels. -- **Fills go through the shared `txmanager`** — the solver builds the `openForAndFinalise` calldata; +- **`Run(ctx)`** connects to the LI.FI order server (WebSocket order feed), refreshes standing quotes, + and evaluates every admitted order once for immediate execution; blocks until ctx cancels. +- **Fills go through the shared `txmanager`** — the solver builds the executor finalise calldata; txmanager owns the nonce, send, and receipt/revert. Same nonce-serialized EOA as every other solver. -- **On-chain reads use `chain.Multicall`** — adapter `getAmountOut` / `getMaxAssets` / `getMaxRate` - batched per quote/price refresh. -- **Signer** — the framework EOA is the registered LI.FI **solver address** and the tx sender. It is - *not* an on-chain signer for the intent (the user signs that); it only sends `openForAndFinalise`. +- **On-chain reads use `chain.Multicall`** — adapter `getAmountOut` / `minDiscount` / `getMaxAssets` / + `getMaxRate`, executor immutables/caller authorization, and filler authorization are batched where appropriate. +- **Signer/caller** — the framework EOA is the tx sender and must be authorized through + `executor.setCallers(...)`. The registered LI.FI solver address is the executor contract itself. - **Config, secrets** — order-server URL + `apiKeyEnv`, settler/executor/adapter addresses via - `solver.config`; the LI.FI API key via `*Env` indirection. + `solver.config`; the LI.FI API key via `*Env` indirection. `solverMode` mirrors RFQ: `external` is + direct-only, while `internal` enables the shared private-discounts backend. - **Pluggable strategy** — both the standing-quote curve and the fill decision are a strategy - (`DecideQuotes` + `DecideFill`; `default` in-process, `webhook` optional later), per + (`DecideQuotes` + `DecideFill`; `default` in-process or `webhook` external), per [`strategy-plan.md`](strategy-plan.md). See §5.2. +- **Shared LiquidLane decision boundary** — direct/physical inventory, fill quotes, and gas state come + from the common snapshot reader. Default allocation and external webhook plans converge on the same + canonical fill routes, capacity reservations, gas floor, and fail-closed route validation before the + LI.FI-specific OIF calldata mapping. ### Component / repo map | Piece | Where | Responsibility | |---|---|---| -| `LiquidLaneLifiExecutor` (Solidity) | `../rfq/src/lifi/` | OIF `IInputCallback` callback: redeem input via adapter → `fill` → `setAttestation`. Contract-of-record. | +| `LiquidLaneLifiExecutor` (Solidity) | `../rfq/src/lifi/` | Caller-gated solver/callback contract; `finaliseWithCurrentTimestamp(...)` calls `InputSettler.finalise`; `orderFinalised(..., FillCall)` redeems claimed input via LiquidLane, fills output, and attests; ERC-1271 validates domain-separated registration signatures against the current callers. | | Vendored OIF interfaces/structs | `../rfq/src/lifi/interfaces/` | `IInputCallback`, `MandateOutput`, `StandardOrder`, OutputSettler `fill`/`setAttestation` surface. | -| `lifi` solver (Go) | `internal/solvers/lifi/` | Pricing, decision, `openForAndFinalise` calldata, submit. | +| `lifi` solver (Go) | `internal/solvers/lifi/` | Pricing, decision, finalise calldata with typed `FillRoute[]`, submit. | | Order-server client (Go, generated) | `api/lifiorder/` ← `openapi/lifi-order.openapi.json` | Typed HTTP client for register / `quotes/submit` / `orders` (vendor→generate→commit, like `api/rfqbackend`). The WebSocket order feed is a thin hand-written client. | -| `strategies/{default,webhook}` (Go) | `internal/solvers/lifi/strategies/` | Quote curve + fill decision (`DecideQuotes` + `DecideFill`). | +| LI.FI strategies (Go) | `internal/solvers/lifi/strategies/` | `default` owns local quote/fill policy; `webhook` delegates to `/decide-quotes` and `/decide-fill` and validates returned route references. | | LI.FI order server | external | Discovery: standing quotes + matched-order WS feed. | | OIF settlers | on-chain (LI.FI-owned) | Order lifecycle; **we do not deploy these**. | --- -## 3. On-chain executor — `LiquidLaneLifiExecutor` (contract-of-record) +## 3. On-chain contract — `LiquidLaneLifiExecutor` -New contract in `../rfq/src/lifi/`, modeled on `src/oev/SymbioticOevSolver.sol` (a self-contained -callback for an external protocol that routes through a LiquidLane adapter). It does **not** reuse the -RFQ `Reactor` — the OIF settlers already own the order/signature/nonce/settlement lifecycle. +Contracts live in `../rfq/src/lifi/`. `LiquidLaneLifiExecutor` is a self-contained finalise + callback +executor for LI.FI opened orders. It does **not** reuse the RFQ `Reactor` — the OIF settlers already +own the order/nonce/settlement lifecycle. `LiquidLaneLifiExecutor.finaliseWithCurrentTimestamp` is the +tx entrypoint the Go solver calls. ### Interface ```solidity +// Caller-gated runtime entrypoint. The executor derives settler, solver, and destination itself. +function finaliseWithCurrentTimestamp(StandardOrder calldata order, FillRoute[] calldata routes) external; + // IInputCallback (vendored from OIF) — the settler calls this on `destination`. function orderFinalised(uint256[2][] calldata inputs, bytes calldata call) external; + +// Hashes the LI.FI message hash into the executor's EIP-712 registration domain. +function lifiRegistrationDigest(bytes32 messageHash) external view returns (bytes32); + +// EIP-1271 registration only; accepts a domain-separated signature from any current caller. +function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4); ``` -`inputs` are the RWA amounts already delivered to the executor. `call` is the ABI payload our Go bot -builds. Proposed encoding: +The current contract also exposes `initialize`, `callers`/`setCallers`/`isCaller` plus standard Ownable +`owner()`. It is deployed behind a transparent proxy: settler addresses are implementation immutables; +owner, caller list, and EIP-712 state are initialized in proxy storage. The owner manages caller authorization. +ERC-1271 uses the same caller set but is not used by the Go fill path. + +`inputs` are the RWA amounts delivered to the executor during finalise. The solver submits only `FillRoute[]`. +The executor constructs the callback `FillCall` from the canonical order and those routes: ```solidity struct FillCall { - address adapter; // the LiquidLane adapter to redeem through (must be allowlisted) - address outputSettler; // OIF OutputSettler to fill + attest on - bytes32 orderId; // OIF order id - MandateOutput output; // the single output to satisfy (token, amount, recipient, ...) - uint48 fillDeadline; // from the order - bytes32 solver; // our registered solver identifier (fillerData / attestation) + bytes32 orderId; // OIF order id + MandateOutput output; // the single output to satisfy (token, amount, recipient, ...) + uint32 fillDeadline; // from the order + FillRoute[] routes; // atomic LiquidLane execution legs +} +struct FillRoute { + address adapter; + uint256 amountIn; + uint256 amountOut; // requested direct-swap output; unused by a discount route + FillDiscount discount; // discountId == 0 means direct swap +} +struct FillDiscount { + bytes32 discountId; + ILiquidLaneAdapter.DiscountSwap discountSwap; + bytes protocolSignature; } ``` -### `orderFinalised` flow (inside the atomic tx) - -1. `require(INPUT_SETTLER == msg.sender)` — only the OIF escrow settler may call. -2. Decode `call`; `require(_isAllowedAdapter(fc.adapter))` and `require(fc.outputSettler == OUTPUT_SETTLER)`. -3. Transfer the input RWA to the adapter (`ILiquidLaneAdapter.swap` "assumes tokenIn already - transferred to the adapter") — `SafeERC20.safeTransfer(tokenIn, fc.adapter, amountIn)`. -4. `fc.adapter.swap(Swap{recipient: address(this), tokenIn, amountIn, amountOut: fc.output.amount})` — - the redeemed underlying lands in the executor. (`Swap{address recipient; address tokenIn; uint256 - amountIn; uint256 amountOut;}`.) -5. `require(IERC20(outputToken).balanceOf(self) >= fc.output.amount)` — the redemption covered the - output (belt-and-suspenders; the adapter should deliver `amountOut`). -6. `forceApprove(outputToken, OUTPUT_SETTLER, fc.output.amount)`. -7. `OUTPUT_SETTLER.fill(fc.orderId, fc.output, fc.fillDeadline, abi.encode(fc.solver))` — pays the user - (`transferFrom(executor → recipient)`). -8. `OUTPUT_SETTLER.setAttestation(fc.orderId, fc.solver, uint32(block.timestamp), fc.output)` — writes - the local attestation the settler's `_validateFillsNow` reads (same-chain oracle == settler). -9. Surplus (`redeemed − fc.output.amount`) stays in the executor. +### Execution flow + +1. `finaliseWithCurrentTimestamp(order, routes)` requires an authorized executor caller, computes the order id, + and constructs callback data from `order.outputs[0]`, `order.fillDeadline`, and the supplied routes. +2. It calls `InputSettler.finalise(order, solveParams, bytes32(address(this)), call)` with + `solveParams[0].solver = bytes32(address(this))`. The settler's direct path accepts this because its caller + is the canonical solver contract. +3. After the input is claimed, `orderFinalised` accepts calls only from the immutable input settler, transfers + each route's input to its adapter, and calls direct `swap` or signed + `discountSwap`. The canonical adapter verifies discount signer/protocol signatures and terms. +4. The OutputSettler resolves the accepted limit or exclusive-limit context authoritatively and pulls the + amount it is owed; a shortfall or invalid context reverts the transaction. Dutch contexts are rejected by + the solver before planning. +5. The executor calls `setAttestation(...)`. Any produced surplus stays in the executor. ### Authorization & safety -- **`INPUT_SETTLER`, `OUTPUT_SETTLER` immutable** (constructor); adapters via an **allowlist** - (`setAdapters`, owner-only) or an adapter-factory `isEntity` membership check. -- **`onlyOwner` sweep** for accumulated surplus (`sweep(token, to)`); the executor holds no funds - between txs otherwise. -- The executor must be a **registered filler on each LiquidLane adapter** (adapter `marketMaker` / - `owner` / delegated `isFiller` == executor) — an onboarding prerequisite, exactly like the RFQ - `Executor`. `adapter.swap` reverts `InvalidCaller` otherwise. -- Attack surface is bounded: `openForAndFinalise` requires the **user's signature** to open at all, and - `_validateFillsNow` reverts the whole tx unless the output was paid — so a griefer with a signed - order can at worst make a valid fill on our behalf (paying gas), never redirect the surplus (it stays - in the executor, owner-swept). +- **`INPUT_SETTLER`, `OUTPUT_SETTLER` immutable** (constructor). The Go solver verifies both at startup. +- **Zero governance fee** — this implementation intentionally does not model input deductions. Startup reads + `InputSettler.governanceFee()` and fails unless it is exactly zero. Every admitted order repeats that read + before order identification or planning; a non-zero or unreadable result skips the order and emits an error + log while the process remains available for later orders. With that invariant, the Go solver also requires + the calldata route-input sum to equal the gross order input. +- **Caller runtime gate** — only addresses installed by the owner through `setCallers` can call finalise. + Startup verifies the framework signer through `isCaller`. The configured adapter list is the trusted route + scope; the current executor intentionally has no second adapter allowlist. +- **ERC-1271** is used only for LI.FI account registration. It wraps LI.FI's message hash in the + `LiquidLaneLifiExecutor` version `1` EIP-712 domain for the current chain and executor address, then accepts + a signature from any current caller. It is not used on each fill. +- In `external` mode the executor must be a **registered direct filler** on every configured adapter. + In `internal` mode direct candidates still require that authorization, but signed discount candidates + do not: the adapter authorizes those through the discount signer and protocol cosign. The executor + configured route scope remains mandatory in both modes. +- Attack surface is bounded: the solver only finalises orders that were already opened/funded on-chain, + and the executor/output settler revert the whole tx unless redemption, fill, and attestation all + succeed. A bad order can at worst cost a reverted fill attempt; it cannot redirect output or surplus. ### Placement & house style -`src/lifi/LiquidLaneLifiExecutor.sol` + `src/lifi/interfaces/ILiquidLaneLifiExecutor.sol` + vendored -`src/lifi/interfaces/{IInputCallback,IOutputSettler,...}.sol` (MIT, mirroring `src/oev/interfaces/`). +`src/lifi/LiquidLaneLifiExecutor.sol` + vendored `src/lifi/interfaces/*` (mirroring the RFQ/OEV +contract style). solc `0.8.28`, BUSL-1.1 header, `forge fmt` (120-col, tabs, double quotes, `int_types=long`), I-prefixed interface with full NatSpec, section separators, `callers`/`setCallers`-style patterns. Tests: -`test/lifi/LiquidLaneLifiExecutor.t.sol` (unit, inline mocks à la `test/Reactor.t.sol`) + -`test/lifi/LiquidLaneLifiIntegration.t.sol` (end-to-end same-chain, modeled on +`test/lifi/LiquidLaneLifiExecutor.t.sol` style unit tests + +an on-chain-order E2E script/test (modeled on `catalystsystem/lifi-intent/test/integration/InputSettler7683LIFI.samechain.t.sol` and `test/CoreMirrorIntegration.t.sol`), aiming for 100% line/branch coverage. @@ -148,16 +194,20 @@ uint32 fillDeadline; address inputOracle; uint256[2][] inputs; MandateOutput[] o **`MandateOutput`** (OIF): `{ bytes32 oracle; bytes32 settler; uint256 chainId; bytes32 token; uint256 amount; bytes32 recipient; bytes callbackData; bytes context; }`. Same-chain: `oracle == settler == -OutputSettler`, `chainId == block.chainid`, empty `callbackData`/`context`, and `order.inputOracle == -OutputSettler`. - -**Entrypoint:** `InputSettlerEscrowLIFI.openForAndFinalise(StandardOrder order, address sponsor, bytes -signature, address destination, bytes call)` — `sponsor == order.user`; `signature` = `b1 sigType (0x00 -permit2 / 0x01 3009) || sig`; `destination` = our executor (receives inputs, is the solver identity); -`call` = the `FillCall` payload above. Emits `Open(orderId)` then `Finalised(...)`. +OutputSettler`, `chainId == block.chainid`, empty `callbackData`, and `order.inputOracle == +OutputSettler`. `context` is the OutputSettlerSimple pricing/access payload: empty or `0x00` = limit +amount (`output.amount`), `0x01` = Dutch amount, `0xe0` = exclusive limit, `0xe1` = exclusive Dutch. +The solver supports only limit and exclusive-limit contexts. It discards both Dutch variants at WebSocket +admission and logs the order identifiers and unsupported context type. + +**Entrypoint:** the bot calls +`LiquidLaneLifiExecutor.finaliseWithCurrentTimestamp(order, routes)`. The executor calls the LI.FI +opened-order direct finalise path with the current `block.timestamp`; both canonical solver and destination +are the executor itself, and it constructs the callback `FillCall` internally. Gasless +`openForAndFinalise` is not supported. **Deployed addresses** (LI.FI-owned; integrate against these — do **not** deploy): -- `InputSettlerEscrowLIFI` (has `openForAndFinalise`): `0x000025c3226C00B2Cdc200005a1600509f4e00C0` +- `InputSettlerEscrowLIFI` / opened-order input settler: `0x000025c3226C00B2Cdc200005a1600509f4e00C0` - OutputSettler (LIFI): `0x0000000000eC36B683C2E6AC89e9A75989C22a2e` - (bare OIF reference set: `InputSettlerEscrow 0x1CC9260E285C2C8AC8D2E7102F3978056Ec1d0a8`, `OutputSettlerSimple 0x52602D7cc3D833F5d28ee6D01C7F82C9b2322e10` — deployed at identical addresses on @@ -171,76 +221,213 @@ permit2 / 0x01 3009) || sig`; `destination` = our executor (receives inputs, is ### 5.1 Discovery (LI.FI order server) The REST surface is the **generated `api/lifiorder` client** (from the vendored -`openapi/lifi-order.openapi.json`); all calls carry the `api-key` header (`LIFI_SOLVER_API_KEY`). Wire +`openapi/lifi-order.openapi.json`); all calls carry the `x-api-key` header (`LIFI_SOLVER_API_KEY`). Wire shapes below are verified against the live `order-dev.li.fi` OpenAPI. -**One-time onboarding** (self-serve, no KYC): -1. Create a solver identity + API key in the solver UI (prod `intents.li.fi`, testnet `devintents.li.fi`). -2. Register the framework EOA: `POST /solver-api/account/register` with `{ address, message, signature, - chainId? }` (sign the server-issued message; `chainId` only for EIP-1271). One address ↔ one API key. -3. **Opt into the escrow callback path:** `PUT /api/v1/solver/supported-contracts` with - `{ inputSettler:[{chain, address}], outputSettler:[…], oracle:[…] }` (CAIP-2 chains) listing the - **escrow** `InputSettlerEscrowLIFI` + OutputSettler + oracle (§4 addresses). This is how the order - server routes us escrow orders; our executor is never registered here — it's the `openForAndFinalise` - `destination`. - -**Standing quotes** — every `quoteRefresh`, compute a price curve per configured RWA→underlying route -from `adapter.getMaxRate` / `getAmountOut` / `getMaxAssets`, and `POST /quotes/submit`: +Account, chain, contract, and route prerequisites are the onboarding runbook in §8.1. LI.FI registers the +**executor contract** as the solver account through EIP-1271. On startup the solver verifies that the API +key's registered identities include the configured executor, then checks +`GET /api/v1/solver/supported-contracts` and, when needed, +merges the configured escrow InputSettler and OutputSettler into the complete list with `PUT`. The endpoint +has replace semantics, so the solver preserves existing entries and registers the OutputSettler in both the +`outputSettler` and `oracle` lists. This opts the solver into opened escrow delivery over WebSocket; the same +executor is the on-chain solver identity and callback destination. + +#### Identity, API key, and reputation + +Our deployment convention is **one LI.FI API key ↔ one registered executor contract**. LI.FI supports +multiple registered accounts under one key, but this deployment deliberately keeps each executor on its own +key and reputation. All logical solvers or processes operating through one executor share its +`LIFI_SOLVER_API_KEY`, quotes, matched orders, and status. + +The LI.FI API key, executor owner key, and authorized caller transaction key are distinct credentials. +Sharing the LI.FI identity does not make uncoordinated active-active processes safe: they would observe the +same order flow, and every authorized caller can submit the same fill. Multiple instances must use a single +active sender or shared order coordination; active/standby replicas are the simple supported deployment. + +**Standing quotes** — on each `quoteIntervalMs` tick, or once per new block when +`quoteRefreshMode: block` (block mode polls at `quoteIntervalMs`, default 1s), compute one non-overlapping +price curve per RWA→underlying pair from `adapter.getMaxRate` / `getMaxAssets`, and +`POST /quotes/submit`: ``` { quotes: [{ fromChain, toChain, // toChain == fromChain for our same-chain routes fromAsset, toAsset, fromDecimals, toDecimals, ranges: [{ minAmount, maxAmount, quote }], // quote = toAsset per 1 fromAsset, decimal string - expiry, exclusiveFor }] } // exclusiveFor = our solver address → matched orders route only to us + expiry, exclusiveFor }] } // exclusiveFor = executor address → matched orders route only to us ``` +`fromChain` and `toChain` are transport fields only: `orderClient` initializes both once from the solver's +configured runtime chain. Strategy outputs and quote-state keys contain only the local token pair, so a +same-chain solver cannot accidentally publish a mixed-chain curve. + +There are two independent exclusivity layers. Quote `exclusiveFor = executor` tells the order server which +registered solver should receive a match. Supported on-chain exclusivity is encoded as an `0xe0` exclusive +limit context: before its start time only the encoded `exclusiveFor` address may fill; afterwards any allowed +solver may fill. The strategy resolves that context at decision time and skips an order that is not executable +by this executor now. Exclusive Dutch (`0xe1`) is unsupported and discarded on receipt. `quoteId` is optional +correlation metadata only: it is not an authorization input, is not used by the contract, and may be absent in +the WS event. **Order feed** — subscribe to the WebSocket `user:vm-order-submit` event (respond to `ping` with -`pong`; dedup on `orderId`). Each message is a `SubmitOrderDto`: +`pong`). The socket reader hands parsed orders to a bounded FIFO so slow chain reads do not block +heartbeats; queued replays are coalesced by on-chain order ID, and a full queue logs and drops the +newest message instead of growing memory without bound (the upstream replay can redeliver it). An +accepted message is evaluated once; the solver does not persist or retry it locally. Each message is a +`SubmitOrderDto`: ``` -{ orderType, quoteId, - inputSettler, // escrow-vs-Compact discriminator — must be the ESCROW settler for our callback path - sponsorSignature, // user's permit2/3009 signature — required by openForAndFinalise +{ orderType?, quoteId, + inputSettler, // escrow-vs-Compact discriminator — must be the opened ESCROW settler order: StandardOrder, meta: { orderStatus: Signed|Delivered|Settled, onChainOrderId, ... } } ``` -The callback (no-inventory) path requires `inputSettler` = the escrow settler, `orderStatus: Signed` -(unopened), and a permit2/3009 `sponsorSignature` (see §10). Registering our executor as the callback -destination is likely `PUT /api/v1/solver/supported-contracts` — confirmed in P1. +We do **not** listen to on-chain events for discovery. The order must arrive via the LI.FI WebSocket. +The fill path requires `inputSettler` = the configured escrow input settler and a live, not-yet-settled +status (`Signed`/`Delivered` today). LI.FI's opened-order WS message currently omits `orderType`, so an +absent value is accepted; an explicitly supplied value is fail-closed to the opened on-chain shapes we +know (`OnChainOrder` / `oif-user-open-v0`). A missing type is inferred only when +`meta.onChainOrderId` and `inputSettler` are present, and is not trusted by itself: the full +`StandardOrder`, configured escrow identity, canonical order ID, and `Deposited` on-chain status are still +required. It does +not require a gasless permit/3009 signature or backend `sponsorSignature`. ### 5.2 The strategy — owns both decisions All pricing lives in a pluggable strategy (per [`strategy-plan.md`](strategy-plan.md)): the solver -supplies **raw facts** (adapter reads) and **executes** (publish quotes, send the tx); the strategy is +maps adapter reads into typed LiquidLane facts and **executes** (publish quotes, send the tx); the strategy is the brain for **both** decision points — the standing-quote curve *and* the fill decision — mirroring -rfq's `DecideQuote`/`BuildFillPlan`. +rfq's `DecideQuote`/`BuildFillPlan`. LiquidLane route/inventory/fill-quote terminology follows +[`LIQUIDLANE-CONVENTIONS.md`](LIQUIDLANE-CONVENTIONS.md), so LIFI-specific route snapshots should map +from shared `Route`, `Inventory`, and `FillQuote` facts rather than defining a third LiquidLane shape. +LI.FI's standing range quote construction stays local. Its fill decision normalizes fresh facts into the +neutral `FillTask` also used by RFQ and UniswapX for LiquidLane route selection, shared-capacity +reservation, gas conversion, price buffering, and minimum-output distribution. LI.FI still resolves OIF +output contexts locally. ```go type Strategy interface { // §5.1 standing-quote curve, from configured routes + live adapter facts. - DecideQuotes(ctx, QuoteInput) (QuoteOutput, error) // → per-route ranges[] {minAmount,maxAmount,quote} - // A matched WS order + fresh adapter reads → fill-or-skip and the FillCall params. + DecideQuotes(ctx, QuoteInput) (QuoteOutput, error) // → per-pair ranges[] {minAmount,maxAmount,quote} + // A matched WS order + fresh adapter reads → immediate fill or skip. DecideFill(ctx, FillInput) (*FillPlan, error) } ``` -- **`QuoteInput`** = the configured routes plus, per route, the adapter facts the solver read - (`getMaxRate`, `getAmountOut` at tier points, `getMaxAssets`, token decimals). `DecideQuotes` returns - the `ranges[]` curve the solver POSTs to `/quotes/submit`; the `default` sets - `quote = adapterRate × (1 − minMarginBps)` and caps `maxAmount` at `getMaxAssets`. -- **`FillInput`** = the matched signed `StandardOrder` plus a fresh `getAmountOut(tokenIn, amountIn)` / - `getMaxAssets(tokenIn)` read. `DecideFill` returns a `*FillPlan` (fill) or `nil` (skip). The `default` - fills iff `redeemed ≥ output.amount + minMargin`, `amountIn ≤ getMaxAssets`, the adapter asset matches - `output.token`, and the order is within `fillDeadline`/`expires`. - -The solver then executes the result — publish the curve, or build + send -`openForAndFinalise(destination = executor)` from the `FillPlan`'s `FillCall`. `default` = in-process; -`webhook` = external decider — same trusted-strategy model as rfq/3f/oev, so swapping the pricing brain -never touches the solver skeleton. +- **`QuoteInput`** = shared `[]liquidlane.Inventory`, latest LiquidLane gas snapshot + (adapter-local owner/market-maker `acquireBalance` and vault-level shared `freeAssets`/`withdrawable`), vault-level in-flight capacity + reservations, chain time, server wall time, solver-owned quote expiry, and raw current + `txmanager.MaxFeePerGas`. The shared LiquidLane predictor derives every adapter swap route as + acquire/allocate/deallocate/unknown. The solver reads Chainlink native/USD and token/USD feeds at the + latest state and passes a `tokenOut per native` snapshot to the strategy. Every distinct resolved + adapter `tokenOut` must have a configured feed; missing coverage fails startup and stale/invalid rounds + fail closed for that decision. Gas units are code-owned conservative constants: 250k fixed LI.FI + settlement, shared LiquidLane route units, and 75k for each private route. + `DecideQuotes` applies `inventoryReserveBps` before pricing, normalizes direct and private inventory into + shared greedy candidates, and keeps at most three physical routes (one for permissioned inputs). LI.FI + retains only the range-shaped protocol adapter: selected capacity is divided geometrically into at most + `rangeCount` contiguous ranges (default eight, hard protocol limit sixteen). For each + `[inputLow,inputHigh]` the strategy calls the same exact-input `greedy.SolveQuote` used by concrete + RFQ-style solvers at both endpoints. The lower endpoint rate is capped by a linear conservative floor + derived from the alternatives able to cover each route at `inputHigh`, worst-case complete-plan gas, and + integer rounding. This covers interior route switches without enumerating route combinations. Two + price-movement stages are deducted (quote→decision and decision→inclusion). There is no separate LI.FI + quote planner, profitability binary search, or minimum-profit setting. If either endpoint is not + economically positive, that whole range is omitted. Solver-level token admission uses the shared + `internal/tokenpolicy` policy also used by RFQ: `all` serves every input, `permissioned` serves only + `permissionedTokens`, and `permissionless` serves only inputs outside that set. Only the + `permissioned` scope is single-route: the solver passes that constraint into each strategy decision, + the curve uses one physical route, and the solver rejects any fill plan that does not contain exactly + one route. All live direct and private candidates for one route remain alternatives, never additive; the + allocator chooses the best alternative able to cover each concrete leg. Routes sharing a vault share one + conservative `CapacityID`; reserve is applied + before in-flight amounts are subtracted. In internal mode, advertised discount inventory is bounded + by current on-chain `getMaxAssets`/`getMaxRate` and its deadline. The backend discount and its already-net + `maxRate` are validated together; the strategy must not apply the ppm discount to that rate again. + Quote lifetime + belongs to the solver cadence: by default the head is polled every second, quotes are recalculated once per + new block, at most three physical routes are used, and `quoteTtl` is 36 seconds. Unchanged quotes are renewed + when at most `max(quoteInterval, quoteTtl / 3)` remains, even when no new block is observed or the head poll + fails. The strategy + may only shorten that expiry to `discount deadline - executionDeadlineBuffer`. +- **`FillInput`** = the matched signed `StandardOrder` output facts (`output.amount`, raw + `output.context`) plus fresh `getAmountOut`, `minDiscount`, `getMaxAssets`, pending fill reservations + by shared `CapacityID`, and the same latest LiquidLane gas facts. Direct candidates require current + filler authorization. Internal discount candidates are resolved again through the + backend, validated against the advertised ID/adapter/token/deadlines and adapter minimum, then priced + as `getAmountOut * (1 - signedDiscount)`. + `DecideFill` returns an immediate `*FillPlan` or `nil`; the solver does not retain or retry skipped orders. + The `default` resolves the supported OutputSettlerSimple contexts: limit and exclusive limit both use + `output.amount`, while an exclusive order for another solver before `startTime` is declined. Dutch and + exclusive Dutch orders never reach the strategy because WebSocket admission discards them. It fills + only when aggregate fresh output covers resolved amount + one execution price buffer + gas for + every selected leg, the adapter asset matches `output.token`, and `fillDeadline`/`expires` plus + private-signature deadlines have at least `executionDeadlineBuffer` remaining. The plan commits a target + after downward `priceBufferBps` and an aggregate internal `minAmountOut = resolvedAmount + gas`. For direct + routes, the calldata `amountOut` is the buffered target and the adapter either produces it or reverts. A + private-discount swap uses its signed terms instead of calldata `amountOut`, so + the strategy requires its full current output plus upward `priceBufferBps` to fit reserved capacity. + The current adapter minimum is checked directly; there is no separate discount-headroom policy. + Permissionless tokens may split the order across independent capacity domains. Selection keeps at most + the best direct and best private candidate per physical route, then greedily assigns each remaining leg + to the highest-rate candidate that can cover that route's full available share. Shared-vault capacity is + reserved as each leg is selected. There is no capacity-first retry or plan comparison: once the complete + allocation is built, full route-aware gas is charged and the plan is either executed or skipped. + Routes sharing a vault consume one aggregate capacity and gas-liquidity budget. Direct and + private candidates for the same route remain mutually exclusive. These route-planning mechanics live + in the shared LiquidLane fill core; this strategy supplies the policy values and adapts the result to + `FillPlan{Routes}`. + +The order worker owns pending fills and their capacity reservations. It reserves each direct route's +target output and each private route's upward-buffered output against its shared `CapacityID` while an +accepted fill tx is in flight, passes the aggregate reservation snapshot to every later fill decision, +and releases it when that send completes. A single shared `CapacityLedger` is the source for both fill +planning and quote refresh; the quote coordinator receives only a coalesced refresh signal and does not +keep a second copy of per-order reservations. On startup, when any economic payload changes, or when expiry enters the renewal +window, it submits the replacement curve directly; LI.FI overwrites the old quote for the pair. When a pair +stops quoting, it submits the last curve with an expiry in the past, which overwrites and immediately expires +the old server-side quote. An unchanged pair is not reposted on every calculation tick. + +The solver then executes the result — publish the curve, or send one +`finaliseWithCurrentTimestamp(order, routes)` tx from the +`FillPlan`. `default` is in-process. `webhook` posts the same raw snapshots to `/decide-quotes` and +`/decide-fill`; its response is a `FillPlan` or `null`. The solver normalizes adapters/capacity IDs from +trusted candidates and rejects unknown, oversized, duplicated, input-mismatched, capacity-conflicting, +or gas-negative routes before calldata construction. LI.FI owns the fixed settlement and +private-payload gas envelope; the shared gas calculator consumes it for both quote/fill decisions and +solver-side validation. ### 5.3 Build & submit -Encode the `FillCall` payload → build `InputSettlerEscrowLIFI.openForAndFinalise(order, order.user, -signature, EXECUTOR, call)` via generated bindings → submit through `txmanager`. One tx per fill; an -on-chain revert (e.g. someone else filled, or price moved) marks the attempt failed and it's dropped -(the order is gone). +Convert the strategy plan into typed executor `FillRoute[]` → require the route input sum to equal the gross +order input → pack `LiquidLaneLifiExecutor.finaliseWithCurrentTimestamp(order, routes)` via generated +bindings → read `InputSettlerEscrowLIFI.orderStatus(orderId)` again → submit only when the status is +`Deposited`. +The executor derives the solver identifier from `address(this)`. The WS handler +places parsed orders into an in-memory FIFO without blocking the socket reader, so ping/pong and later messages +continue while one planner evaluates accepted orders in arrival order. It reads +fresh state and gas, asks the strategy, builds calldata, and immediately submits the result. No `FillPlan`, +gas cap, adapter snapshot, discount resolution, or calldata waits in a second queue. The solver has no local +in-flight limit: every accepted order is handed to the shared txmanager as soon as planning finishes. +An admitted order first verifies `governanceFee() == 0`, then derives the canonical ID and verifies +`orderStatus == Deposited` before expensive route reads. It selects only configured routes matching both +order tokens. For private candidates it resolves the +signatures under one order-server timeout, then re-reads latest-state LiquidLane inventory and current block +time before each strategy decision. That decision-time max fee is passed as a hard per-request cap to `txmanager`. +Before broadcast, txmanager clamps its fee cap and tip to that budget and drops the fill only if the current base +fee itself no longer fits. It verifies `Deposited` again immediately before async submission. The shared txmanager +serializes fee selection, signing, nonce assignment, +and broadcast, but waits for receipts independently, allowing consecutive nonces to be pending together. Pending +calls are fee-bumped within their decision cap. After the shared pending timeout, txmanager cancels only the +lowest unresolved nonce with a same-nonce self-transfer; this cancellation is outside the fill's profitability +cap but remains bounded by the operator's required global `txManager.maxFeeGwei`. Normal sends reserve one +replacement bump below that global ceiling so cancellation still has fee headroom. LI.FI +requests complete at inclusion/revert rather than waiting for the txmanager's extra confirmation depth; the +planner then releases that fill's reservation. Every later fill decision subtracts aggregate pending +capacity before route allocation. At inclusion, the LiquidLane adapter and OutputSettler enforce the requested +swap and resolved output; stale state therefore reverts atomically rather than being repriced by the executor. +There is no solver-level pending plan, timer, future-auction scheduling, or new fill attempt. The txmanager +may replace the same pending nonce as described above; that is fee management for one submission, not order +retry. +For a selected private candidate, the solver commits the fresh signed terms and both signatures inside +the selected `FillRoute`; a missing or mismatched resolution aborts before submission. Those two signatures +authorize the private LiquidLane route and are unrelated to LI.FI account or fill authorization. ### 5.4 Config block (sketch) @@ -248,19 +435,38 @@ on-chain revert (e.g. someone else filled, or price moved) marks the attempt fai solvers: - name: lifi-samechain config: - strategy: { name: default, config: {} } + strategy: + name: default + config: + priceBufferBps: 20 + inventoryReserveBps: 500 + minAmount: "1000000" # tokenIn floor sized to cover gas and rounding + rangeCount: 8 # geometric ranges per pair; hard max is 16 + executionDeadlineBuffer: 12s + gas: + nativeUsdFeed: "0x…" + nativeMaxAge: 1h # native/USD feed heartbeat + tokenUsdFeeds: + - token: "0x…" # every resolved adapter tokenOut + feed: "0x…" # token/USD Chainlink feed + maxAge: 24h # this token/USD feed's heartbeat orderServer: baseUrl: https://order-dev.li.fi # order.li.fi in prod - wsUrl: wss://order-dev.li.fi/... # confirm exact WS path in P1 + wsUrl: wss://order-dev.li.fi apiKeyEnv: LIFI_SOLVER_API_KEY - solverAddress: "0x…" # our registered solver EOA (== signer) + solverMode: internal + privateDiscountsUrl: ${RFQ_BACKEND_URL} inputSettler: "0x000025c3226C00B2Cdc200005a1600509f4e00C0" outputSettler: "0x0000000000eC36B683C2E6AC89e9A75989C22a2e" - executor: "0x…" # our deployed LiquidLaneLifiExecutor + executor: "0x…" # registered EIP-1271 LiquidLaneLifiExecutor adapters: # LiquidLane adapters (RWA→underlying); vault+asset resolved on-chain - "0x…" - minMarginBps: 10 # required surplus over the order's output - intervals: { quoteRefresh: 30s, statePoll: 10s } + tokensToQuote: permissioned # all (default) | permissioned | permissionless + permissionedTokens: # membership set; single-route only in permissioned scope + - "0x…" + quoteIntervalMs: 1000 # block poll interval; default is 1000ms + quoteTtl: 36s # rolling expiry, about three Ethereum blocks + quoteRefreshMode: block # block (default) | interval ``` --- @@ -268,20 +474,19 @@ solvers: ## 6. Data flow (end to end) ``` -LI.FI order server ──(WS: signed StandardOrder)──▶ lifi solver - price: adapter.getAmountOut(RWA, X) → redeemed ; adapter.getMaxAssets → cap - decide: redeemed ≥ output.amount + margin && X ≤ cap ? ── no ─▶ skip +LI.FI order server ──(WS: opened/funded StandardOrder)──▶ lifi solver + price: fresh direct getAmountOut or signed-discount output; getMaxAssets → reserved cap + decide: buffered target ≥ resolved output + gas, deadlines buffered ? ── no ─▶ skip │ yes - build FillCall + openForAndFinalise(order, user, sig, EXECUTOR, call) + build FillRoute[]; require Σ amountIn == order input │ - txmanager ─▶ InputSettlerEscrowLIFI.openForAndFinalise(...) - ├─ pull user's RWA (permit2) → EXECUTOR - ├─ EXECUTOR.orderFinalised(inputs, call): - │ RWA → adapter ; adapter.swap(→ underlying to EXECUTOR) - │ OUTPUT_SETTLER.fill(orderId, output, deadline, solver) // pays user - │ OUTPUT_SETTLER.setAttestation(orderId, solver, ts, output) - └─ _validateFillsNow ✓ (atomic; reverts all if unfilled) - surplus (redeemed − output.amount) accrues in EXECUTOR → owner sweeps + txmanager ─▶ LiquidLaneLifiExecutor.finaliseWithCurrentTimestamp(order, routes) + └▶ InputSettlerEscrowLIFI.finalise(... solver=destination=EXECUTOR ...) + ├─ deliver opened order RWA → EXECUTOR + └─ EXECUTOR.orderFinalised(inputs, FillCall): + direct swap(amountOut) or signed discount swap → EXECUTOR + OUTPUT_SETTLER.fill + setAttestation + surplus (redeemed - resolved output) remains in EXECUTOR ``` --- @@ -289,16 +494,43 @@ LI.FI order server ──(WS: signed StandardOrder)──▶ lifi solver ## 7. Error handling & safety - **Atomic revert-safety** is the backbone: if the redemption under-delivers, the adapter reverts, or - the output isn't paid, `_validateFillsNow` reverts the entire tx — no partial state, no stuck funds. -- **Pre-submit skips** (never send a doomed tx): unprofitable (`redeemed < output.amount + margin`), - over-capacity (`amountIn > getMaxAssets`), asset mismatch, past deadline/expiry, adapter paused. -- **Staleness** — price/capacity reads are refreshed on `statePoll`; a matched order is priced against - a fresh read at decision time, not the quote-time curve. + the output fill/attestation fails, the entire tx reverts — no partial state, no stuck funds. +- **Pre-submit skips** (never send a doomed tx): insufficient output + (aggregate buffered target below output amount + selected-leg gas), buffered private output above reserved + capacity, invalid current private discount bounds, asset mismatch, deadline/expiry inside the execution + buffer, or adapter paused. +- **Inclusion-time enforcement** — direct routes ask the adapter for the buffered target; private routes use + the signed terms. If current adapter state cannot execute the request or the OutputSettler cannot pull the + accepted order amount, the whole transaction reverts. +- **Gas-aware quotes** — the solver supplies the live txmanager fee cap, latest LiquidLane gas state, and + Chainlink-derived token/native conversion as raw facts. Code-owned fixed settlement/private units combine + with shared route prediction; there is no separate gas padding knob. Operators set `minAmount` high enough + to cover gas, both quote-time price windows, and rounding. + The strategy charges complete-plan gas after route allocation and omits any capacity range whose lower + boundary is not economically positive. +- **Capacity safety** — routes sharing a vault share one conservative capacity domain. Both quote and + fill planning subtract in-flight buffered outputs before allocating that shared capacity. Each fill + still uses a fresh chain snapshot and the adapter enforces execution at inclusion. An economic change removes + old server ranges before replacement. +- **Authorization safety** — startup validates executor immutables and requires the framework signer to be + authorized by `executor.isCaller`. Startup and every admitted order also require + `InputSettler.governanceFee() == 0`; fee-bearing input settlement is intentionally unsupported. External mode + additionally requires direct `owner/marketMaker/isFiller` authorization for every route. Internal mode + checks direct authorization dynamically and otherwise requires fresh adapter-verified discount signatures. +- **Staleness** — a matched order is priced against a fresh read at decision time, not the quote-time + curve. Private-signature resolution is bounded by one timeout and followed by another adapter/block-time + read, so network latency cannot silently preserve the pre-resolution capacity snapshot. - **Competition** — same-chain fills are winner-take-all on-chain; `exclusiveFor` on our quotes routes matched orders to us, but a late/again-priced fill can still revert (already filled) → drop. -- **No inventory / callback-balance risk** (unlike OEV): nothing is fronted; the only capital at risk - per tx is gas, and reverts cost only gas. -- **Executor surplus** is the sole standing balance; owner-swept, never user-redirectable. +- **Private discounts** — internal mode uses shared `internal/liquidlane/discounts` discovery, physical-route + matching, cap/rate clipping, and fresh signed-term validation. Advertised terms + may shape standing quotes, but execution always resolves fresh signatures, recomputes output from the + current adapter oracle, and commits the selected discount ID and typed payload in `FillRoute`. +- **No prefunded working inventory is required** (unlike OEV): the opened input funds each atomic + redemption. A reverting fill spends gas only; accumulated executor surplus is a separate standing balance + governed by the deployment and zero-fee invariant. +- **Executor surplus** may remain as a standing balance. The current PR #18 executor ABI has no sweep entrypoint; + recovery therefore requires the deployment's proxy-upgrade administration rather than the runtime solver. --- @@ -308,38 +540,145 @@ LI.FI order server ──(WS: signed StandardOrder)──▶ lifi solver LiquidLane adapter on a public testnet, so the dev environment matches production one-for-one. The local foundry loop (§8.3) is kept only for fast contract-unit iteration, not for integration. -### 8.1 Onboarding (verified, self-serve) -No KYC / approval gate. Testnet UI `devintents.li.fi` + order server `order-dev.li.fi` (both live; prod -is `intents.li.fi` / `order.li.fi`). Create a solver identity → API key → sign a registration message -and `POST /solver-api/account/register` the solver EOA. Secret via `LIFI_SOLVER_API_KEY` env. +### 8.1 Onboarding and prerequisites + +The current dev onboarding is self-serve. Testnet uses `devintents.li.fi` and `order-dev.li.fi`; production +uses `intents.li.fi` and `order.li.fi`. Treat dev and production as separate environments: create and +register the identity in the target environment and do not assume a dev API key or registration is valid in +production. + +#### Supported scope + +| Supported | Rejected / out of scope | +|---|---| +| One configured EVM chain; same-chain input and output. | Cross-chain orders or a chain different from runtime config. | +| Already-opened `InputSettlerEscrowLIFI` order delivered over the LI.FI WebSocket. | Compact, Permit2, ERC-3009, gasless submit, and `openForAndFinalise`. | +| One ERC-20 input, one output, full fill. | Native input, multiple inputs/outputs, and partial fills. | +| Configured OutputSettler as input oracle, output oracle, and output settler. | Unknown settlers/oracles and non-empty output callback data. | +| The default strategy handles limit and exclusive-limit output contexts. | Dutch and exclusive Dutch are ignored globally. The default strategy rejects unknown or malformed contexts; a webhook strategy must decline every non-Dutch context it cannot resolve. | +| Immediate decide-and-send using current time and state. | Retaining or scheduling a future exclusive-limit order for later retry. | +| WebSocket discovery with an on-chain `Deposited` check before send. | On-chain event discovery or trusting WS status without the chain check. | + +#### Ownership map + +| Owner | Must provide | +|---|---| +| LI.FI | Solver identity/API key, executor-account registration, order server + WebSocket access, canonical OIF settler addresses, and support for the target chain. | +| Solver operator (us) | The executor owner EOA, an authorized caller EOA with gas funds, RPC access, a deployed EIP-1271 `LiquidLaneLifiExecutor`, YAML config, and monitoring. | +| LiquidLane adapter owner | A live adapter/route with capacity and rate data, plus direct filler authorization for our executor when direct execution is required. | +| Test order creator | A separate user EOA, input-token balance and approval, and the ability to open/fund an escrow order through `open`/`openFor`. The order server indexes that on-chain order. | + +#### Required before startup + +1. **Choose one chain and route.** The chain must be returned by the target order server's + `/chains/supported` endpoint and host the LiquidLane adapter, input token, output token, and canonical + LI.FI escrow InputSettler/OutputSettler. This solver is same-chain only; `fromChain == toChain`. +2. **Use the canonical settlers.** Put the target chain's opened-order `InputSettlerEscrowLIFI` and + OutputSettler addresses in config. We do not deploy these contracts and we do not support Compact, + Permit2/3009, gasless submit, or `openForAndFinalise` orders. +3. **Deploy our executor.** Deploy the ERC-1271-enabled `LiquidLaneLifiExecutor` implementation with immutable + input/output settlers matching config, then a transparent proxy initialized with the owner EOA and an initial + caller list containing the EOA from `signer.keyEnv`. Keep the proxy-admin owner separate and recorded. The + proxy address is the configured and registered LI.FI solver account; the owner manages callers and callers + can finalise. Configured adapters are the solver's trusted route scope. +4. **Create the API key and register the deployed executor.** Create the target-environment API key and fetch + the server-issued message from `GET /api/v1/solver/register/message`. Compute its standard EVM + `hashMessage(message)`, then sign the EIP-712 `LifiRegistration(bytes32 messageHash)` value with any current + caller using domain `{ name: "LiquidLaneLifiExecutor", version: "1", chainId, verifyingContract: executor }`. + Submit `POST /api/v1/solver/register` with `{ message, signature, account: executor, + chain: "eip155:" }`. LI.FI passes its message hash and the signature to + `executor.isValidSignature`. Keep the key only in the environment named by `orderServer.apiKeyEnv` + (normally `LIFI_SOLVER_API_KEY`). Under our deployment convention all processes using this executor + share the key and reputation; use another executor and key for an independent deployment. +5. **Authorize LiquidLane execution.** For every configured adapter, verify its vault, output asset, + redeemable input-token list, current capacity, and rate. Grant `setFiller(executor, true)` for direct + routes. In `internal` mode a signed private-discount leg has its own authorization, but any direct + fallback still needs filler authorization. +6. **Configure gas conversion.** Provide one native/USD Chainlink feed and one token/USD feed for every + distinct adapter output asset. Set `gas.nativeMaxAge` and every `gas.tokenUsdFeeds[].maxAge` + from the feed's heartbeat plus realistic publication slack; stale, non-positive, missing, or materially + future-dated rounds fail the quote/fill decision closed. +7. **Optional private discounts.** `solverMode: external` needs no discount backend and serves only + direct-authorized routes. `solverMode: internal` additionally requires a reachable + `privateDiscountsUrl` and active signer/protocol policies for the configured adapters. + +#### Deployment preparation + +Before each testnet or production deployment, record enough information in the operator's normal release +process to reproduce and audit it: executor source revision and compiler settings, implementation constructor +arguments, proxy initializer arguments and proxy-admin owner, target chain, expected owner and settler addresses, +implementation/proxy addresses and transactions, verified runtime bytecode, LI.FI registration result, and +every adapter filler-authorization transaction. These values are +deployment-specific and are intentionally not pinned in this repository. + +The minimum operator config is [`../config/lifi.example.yaml`](../config/lifi.example.yaml). Before +starting, replace every zero/placeholder address and provide these secrets without putting them in YAML: + +| Environment | Config reference | Purpose | +|---|---|---| +| `SOLVER_PRIVATE_KEY` | `signer.keyEnv` | Authorized executor caller and tx sender; it may be separate from the owner. | +| `LIFI_SOLVER_API_KEY` | `orderServer.apiKeyEnv` | REST quote/supported-contract calls and WebSocket authentication. | +| RPC URL variables | `chain.rpcUrl` / optional write and fallback URLs | Current-state reads and transaction submission. | +| Chainlink feed variables | `gas.nativeUsdFeed`, `gas.tokenUsdFeeds[]` | Native gas cost converted into each output token; every feed has its own required max age. | +| `RFQ_BACKEND_URL` | `privateDiscountsUrl` | Required only for `solverMode: internal`. | + +#### Startup preflight performed by the solver + +Startup fails before quote publication when config is invalid, any configured adapter's `vault()`, vault +`asset()`, token list, or token decimals cannot be resolved, no adapter routes resolve, an output token has +no configured gas oracle, executor settler immutables do not match, the signer is not returned by +`executor.isCaller`, `InputSettler.governanceFee()` is non-zero or unreadable, the API +key does not list the executor as a registered solver identity, or external mode lacks direct filler +authorization. After those checks the solver reads `GET /api/v1/solver/supported-contracts`; if needed it +preserves the current lists and adds the configured escrow InputSettler plus the OutputSettler in both the +`outputSettler` and `oracle` lists with one replacement `PUT`. + +#### Onboarding acceptance check + +Onboarding is complete only when all of the following are observed in the target environment: + +1. The process starts without route, oracle, executor, authorization, or supported-contract errors. +2. The order server accepts a non-empty same-chain quote whose `exclusiveFor` is the registered executor. +3. A user calls the canonical `open`/`openFor` path. The order server indexes the transaction without + `POST /orders/submit` and delivers `user:vm-order-submit` with a full `StandardOrder` and + `meta.onChainOrderId`; `quoteId` may be absent and no on-chain event listener is involved. +4. Immediately before submission the canonical order ID matches and on-chain status is `Deposited`. +5. The executor transaction succeeds atomically: input claim -> LiquidLane redemption -> OutputSettler + fill/attestation. The user receives the required output and backend status becomes `Settled`. +6. Receipt gas is consistent with the conservative settlement constants and the submitted fee remains within + the decision and global fee caps; any surplus is held by the executor. ### 8.2 Testnet dev environment (primary loop) -Target **Ethereum Sepolia** (chainId 11155111) — the intersection of: LI.FI `order-dev` support, the -canonical OIF settlers (deployed there, §4 addresses), and an existing Symbiotic **LiquidLane adapter** -(the redstone-oev / rfq work already runs on Sepolia LiquidLane adapters). Confirm one adapter that -redeems a testnet RWA → its underlying, or point at/deploy one (an open item, §10). +Target **Ethereum Sepolia** (chainId 11155111) — the intersection of LI.FI `order-dev` support, the +canonical OIF settlers (deployed there, §4 addresses), and the existing Symbiotic **LiquidLane adapter** +used by the redstone-oev / RFQ testbed. The v1 route is **TCOL → TLOAN** (redstone-oev testbed): adapter `0xB5951fec…70b`, TCOL (RWA) `0x17e892…A4D3`, TLOAN (underlying) `0x468BB3…4C9d`. One-time setup: -1. **Executor** — deploy `LiquidLaneLifiExecutor` to Sepolia (`INPUT_SETTLER`/`OUTPUT_SETTLER` = the - LI.FI addresses in §4; adapter allowlist = `0xB5951fec…70b`). -2. **Filler auth** — the testbed owner `0x8124…7309` registers our executor as a filler on the adapter - (`marketMaker`/`owner`/`isFiller` == executor). -3. **Solver identity + opt-in** — register the framework EOA on `devintents.li.fi` + `POST - /solver-api/account/register`, then `PUT /api/v1/solver/supported-contracts` listing the escrow - settler + OutputSettler + oracle for `eip155:11155111`; fund the EOA with Sepolia ETH for gas. -4. **Config** — a `config/lifi.sepolia.example.yaml` pointing `orderServer` at `order-dev.li.fi`, the §4 - settler addresses, our deployed executor, and the TCOL→TLOAN adapter above. +1. **Contracts** — deploy the ERC-1271-enabled `LiquidLaneLifiExecutor` implementation and transparent proxy + to Sepolia (`INPUT_SETTLER`/`OUTPUT_SETTLER` implementation immutables = the LI.FI addresses in §4; + proxy initializer owner = admin EOA; initial callers include the framework signer). +2. **Solver identity** — register that deployed executor on `devintents.li.fi` through the V1 EIP-1271 + flow, and fund the framework caller EOA with Sepolia ETH. +3. **Filler auth** — the testbed owner `0x8124…7309` registers our executor as a filler on the adapter + (`setFiller(executor, true)` / equivalent owner path). +4. **Config** — copy `config/lifi.example.yaml` into an operator-local config, point `orderServer` at + `order-dev.li.fi`, and set the §4 settlers, deployed executor, TCOL->TLOAN adapter, RPC, and gas feeds. + On first startup the solver preserves existing supported contracts and adds the escrow InputSettler plus + the OutputSettler as both output settler and oracle for `eip155:11155111`. The loop, on every change: -1. Run the bot → it submits an **exclusive** standing quote (`exclusiveFor = our solver addr`) for the +1. Run the bot → it submits an **exclusive** standing quote (`exclusiveFor = executor`) for the RWA→underlying route to `order-dev.li.fi`. -2. Create a matching **test order** from a second (user) key — easiest via the `lintent.org` reference - UI in **"Escrow" mode**, or a small script that signs a `StandardOrder` + permit2 and submits it. +2. From a second user key, select the quote and call the canonical escrow `open`/`openFor` path. Do not call + `POST /orders/submit`: the order server detects the on-chain order and delivers the full `StandardOrder` + over WebSocket. For a manual run, use a `quoteTtl` long enough to complete quote selection and opening; + keep the short rolling TTL in automated or production flows. 3. The order server matches it to our exclusive quote and pushes it over the WS feed → the bot prices - it, builds `openForAndFinalise`, and settles it atomically on Sepolia. + it, calls `finaliseWithCurrentTimestamp`, and the executor finalises/redeems/fills it on Sepolia in + the same tx. 4. Inspect the tx (redeem → fill → attest), the user's received output, and the executor's accrued surplus. Iterate. @@ -347,15 +686,16 @@ This exercises the full real path — order server, WS, settlers, adapter, txman ### 8.3 Local contract loop (fast iteration only) For quick Solidity iteration without a network: foundry/anvil, self-deploy the OIF settlers + a -real/mock adapter, and drive `openForAndFinalise` — the shape of Catalyst's +real/mock adapter, open/fund an order, and drive the opened-order finalise path — the shape of Catalyst's `InputSettler7683LIFI.samechain.t.sol`. This is the `forge test` unit/integration coverage of the executor, **not** the integration loop (§8.2 is). The Go side is unit-tested against an `httptest` order-server mock + a simulated/forked chain backend. ### 8.4 Mainnet deployment - **We do not deploy the settlers** — LI.FI/OIF canonical deployments at fixed addresses. -- **Per chain we deploy** `LiquidLaneLifiExecutor` (+ register it as a filler on each target LiquidLane - adapter), register the solver EOA, fund gas, and run the bot — the same steps as §8.2 but against +- **Per chain we deploy** `LiquidLaneLifiExecutor` (+ register the executor as a filler on each target + LiquidLane adapter), register that executor with LI.FI through EIP-1271, fund its runtime caller, and run + the bot — the same steps as §8.2 but against `order.li.fi`. LI.FI is live on Ethereum, Base, Optimism, Arbitrum, Polygon, BSC, Katana, MegaETH, etc. (`order.li.fi/chains/supported` authoritative); v1 targets the chain(s) hosting the LiquidLane RWA adapters we serve. @@ -366,59 +706,86 @@ order-server mock + a simulated/forked chain backend. ## 9. Build phases -Testnet-first: the executor is on Sepolia from P0 so every later phase integrates against the live -`order-dev.li.fi` + real settlers + real adapter (§8.2). - -0. **Contract + Sepolia deploy** — vendor OIF interfaces into `../rfq/src/lifi/interfaces/`; write - `LiquidLaneLifiExecutor` + foundry unit/integration tests (self-deployed settlers + adapter, the - §8.3 local loop). Then **deploy to Ethereum Sepolia and register the executor as an adapter filler**. - CGO-free rfq build stays green; `forge fmt`/`forge test`/coverage pass. -1. **Order-server client** — the vendored `openapi/lifi-order.openapi.json` + generated `api/lifiorder` +Testnet-first: the executor is developed from P0 so every later phase integrates against the live +`order-dev.li.fi` + real settlers + real adapter (§8.2). The opened-order callback flow was proven through +a settled Sepolia order using the previous deployed executor; the latest `FillRoute` ABI still requires +the redeploy in phase 0. + +0. **Done locally; Sepolia redeploy required** — `LiquidLaneLifiExecutor` implements domain-separated ERC-1271 + registration through its caller set and caller-gated runtime authorization in §3. Its Foundry unit suite + and the real-settler Sepolia fork test pass. Deploy to Ethereum + Sepolia, register it with LI.FI through EIP-1271, and register it as an adapter filler. + The vendored ABI and Go binding are generated from the contract artifact at + [symbioticfi/rfq#18](https://github.com/symbioticfi/rfq/pull/18) head `25b35af`. +1. **Done locally** Order-server client — the vendored `openapi/lifi-order.openapi.json` + generated `api/lifiorder` client (register / `quotes/submit` / `orders`) plus a thin hand-written WS client for - `user:vm-order-submit`, wired to the live `order-dev.li.fi`; register the solver EOA; config parsing + `user:vm-order-submit`, wired to the live `order-dev.li.fi`; register the executor account; config parsing + framework wiring (`solver.Register`, blank-import). `httptest`-backed unit tests, validated live. - (The spec + generated client land in the plan PR; P1 wires them into the solver.) -2. **Pricing + decision + tx build** — `default` strategy (getAmountOut/getMaxAssets, margin, asset - match); `FillCall` encoding + `openForAndFinalise` calldata via generated bindings; txmanager submit. - Validated end-to-end on Sepolia by self-filling a `lintent.org` order matched to our exclusive quote. +2. **Done locally; previous ABI live Sepolia happy path proven** Pricing + decision + tx build — `default` strategy + (direct executable getMaxRate for quotes; getAmountOut/minDiscount/getMaxAssets for fills; + Chainlink gas conversion snapshots, code-owned settlement/private gas constants, pair-level route + ladders, all live direct/private alternatives per route, shared capacity and shared LI.FI/UniswapX + LiquidLane fill planning, + asset match, immediate OutputSettlerSimple context resolution + for limit and exclusive-limit outputs, with Dutch contexts rejected at WebSocket admission); + executor-as-solver typed `FillRoute[]` direct-finalise calldata; + early/final `orderStatus == Deposited` checks; latest-state snapshots; raw live txmanager fee input; dynamic + ranges; quote reconciliation; bounded replay-coalescing fill handoff, sequential nonce broadcast, + pending-capacity-aware one-shot planning, inclusion-time reservation release, and fresh state for every admitted order. + The ladder is quote-only: an awarded order is greedily replanned from current amount-specific quotes, + and output above the resolved order amount remains in the executor; the current ABI has no sweep entrypoint. + Unit-tested through the solver-level submit path and validated end-to-end on Sepolia with a + WebSocket-delivered on-chain order matched to our exclusive quote: open tx + `0xd3f619048a745fb896c2f6c8b4e3b42a65b104eb3035b2bb9c20cf9593623480`, fill tx + `0x338aef70060093f6341538cd633fd4b5cfecc2fe2a10d77458946bf0e84fe960`, backend status `Settled`. + The new executor asks direct adapters for the buffered `amountOut`, executes private routes from signed + terms, and delegates context resolution and output sufficiency to the OutputSettler. If the order is not + executable at decision time, it is dropped. 3. **Harden** — staleness/skip edge cases, revert handling, metrics on the shared observability server; a repeatable green E2E on Sepolia (`order-dev`). -4. **Mainnet** — deploy the executor per target chain, point config at `order.li.fi`, register the - solver, and run. +4. **Mainnet** — deploy the executor per target chain, point config at `order.li.fi`, register each + executor account through EIP-1271, and run. --- -## 10. Open items / prerequisites - -- **Callback flow: CONFIRMED supported; only the opt-in wiring remains for P1.** The inventory-free - path our design uses is a documented, first-class LI.FI same-chain flow — the **"Same Chain Intent - with callback"** diagram in [`architecture/overview`](https://docs.li.fi/lifi-intents/architecture/overview) - and `for-solvers/settlement` state that the solver **receives inputs before delivering outputs** via - `orderFinalised(uint256[2][] inputs, bytes call)` and must "fill and setAttestations for the intent - outputs within the callback." That is exactly our `openForAndFinalise(destination = executor)` design. +## 10. Open items + +- **Gas calibration: direct-finalise rerun required.** The previous signature-based executor's 51-test + suite measured a maximum `finaliseWithCurrentTimestamp` call of 478,838 gas. Re-run Foundry gas reports + after the direct-finalise contract cutover and compare the first Sepolia receipts before changing the + conservative Go settlement constants. + The first acquire-route budgets are 550k direct and 625k private from + `250k fixed + LiquidLane route units (+75k private)`. Multi-route callback behavior was also exercised. + Compare the first Sepolia receipts against these constants before mainnet rollout. +- **Executor redeploy** — deploy the [rfq#18](https://github.com/symbioticfi/rfq/pull/18) implementation plus + transparent proxy, initialize the runtime signer as a caller, register the proxy address with LI.FI, update + config, and grant the proxy adapter filler authorization before E2E. Confirm the canonical InputSettler + reports `governanceFee() == 0`; startup fails closed otherwise, and every admitted order rechecks it. +- **Opened-order callback flow: previously confirmed on Sepolia; contract-identity rerun required.** The + executor uses the same opened-order callback path, but `finaliseWithCurrentTimestamp` now calls + `InputSettler.finalise` as the registered solver contract, then receives/redeems inputs and + fills/attests output via `orderFinalised(uint256[2][] inputs, bytes call)` in the same transaction. It is **opt-in** ("your solver has to support `orderFinalised`"). **Opt-in mechanism = resolved:** we - register the **escrow `InputSettlerEscrowLIFI`** (plus the OutputSettler + oracle) via - `PUT /api/v1/solver/supported-contracts` — the vendored spec's `PutSupportedContractsDto` takes - `{ oracle[], inputSettler[], outputSettler[] }` keyed by CAIP-2 chain, i.e. the settler/oracle set the - solver supports. Our executor is **not** registered with the order server; it is only the `destination` - argument we pass to `openForAndFinalise` at settlement. **Residual (empirical, P1 spike on `order-dev`):** - confirm that supporting the escrow settler yields matched orders delivered **escrow-typed** - (`inputSettler` = escrow), **unopened** (`orderStatus: Signed`), carrying the user's permit2/3009 - `sponsorSignature`. Fallback if a given order arrives Compact/pre-opened: a small revolving inventory - buffer (fill from buffer, then replenish by redeeming the claimed input) — Plan-B only. -- **Wire schemas: RESOLVED** — the order-server OpenAPI is vendored (`openapi/lifi-order.openapi.json`) - and the Go client generated (`api/lifiorder`); the quote / order / register shapes are in §5.1. The - WebSocket `user:vm-order-submit` event is a socket event (not in the OpenAPI); its payload is captured - in §5.1 from the reference client — confirm the exact WS URL/handshake on `order-dev` in P1. -- **Upstream OpenAPI defects (report to LI.FI)** — the `order-dev` spec is mislabelled `3.0.0` but uses - 3.1 constructs, has 3 dangling `oneOf` `$ref`s (`Oif{3009,Escrow,UserOpenIntent}OrderDto` are - referenced but never defined), and 2 multi-tag operations (`/quote/request`, `/quotes/submit`) — so - the raw spec does not generate a compiling Go client. `make refresh-lifi-client` applies a documented - normalization shim (`hack/lifi-openapi-normalize.py`: passthrough the undefined order schema, - single-tag the ops) to generate `api/lifiorder`. Report the defects upstream and drop the shim once - fixed. The vendored spec stays raw (contract of record); the shim runs only at codegen time. + ensure the **escrow `InputSettlerEscrowLIFI`** plus the OutputSettler via + `GET /api/v1/solver/supported-contracts`, then conditional `PUT /api/v1/solver/supported-contracts` + when missing. The solver merges its configured escrow InputSettler and OutputSettler into the complete + current list before replacement, preserving other chains and registering the OutputSettler in both + `outputSettler[]` and `oracle[]`. The executor is the registered solver, finalise caller, and callback + destination. The previous EOA-identity build proved the remainder of the live path: `order-dev` + delivered an already-opened/funded escrow order over `user:vm-order-submit`, and the resulting Sepolia + fill reached backend status `Settled`. Repeat that E2E after deploying and registering the new executor. + The live feed may omit `orderType`; admission + therefore requires `meta.onChainOrderId` and relies on the full escrow order plus canonical on-chain + `Deposited` status. We do not support gasless + Compact or permit2/3009 opening. +- **Wire schemas: RESOLVED** — the live order-server OpenAPI is valid 3.1, vendored at + `openapi/lifi-order.openapi.json`, and generates `api/lifiorder` directly without a normalization shim. + The WebSocket `user:vm-order-submit` event is outside the OpenAPI; the confirmed dev connection uses + `wss://order-dev.li.fi`, `x-api-key`, and application-level `ping`/`pong`. Its opened-order payload and + optional `quoteId` behavior are captured in §5.1. - **Adapter filler registration** — our executor must be granted filler rights on each LiquidLane - adapter (`marketMaker`/`owner`/`isFiller`), by the adapter's vault creator. Onboarding prereq. + adapter (`setFiller(executor, true)` / equivalent owner path), by the adapter's vault creator. + Onboarding prereq. - **Sepolia testnet adapter — resolved (v1 dev route).** The redstone-oev testbed provides a usable Sepolia LiquidLane adapter: `0xB5951fecFc34f56a6Ffbd62A2c61cE328E9De70b` (vault `0xb99F1FeA50f40Bb7C5E568c2De6D79dd0b61EB3A`), redeeming **TCOL** `0x17e892d4E802B01d7DA49Ca3542560f6851AA4D3` @@ -432,3 +799,6 @@ Testnet-first: the executor is on Sepolia from P0 so every later phase integrate undocumented; `exclusiveFor` on our own quotes should make this moot for v1. - **`getMaxAssets` is non-view** (mutates) — read via a call, not a static-call, in the pricing path (mirror how the RFQ solver handles it). +- **Private-discount deployment config** — internal mode needs the reachable RFQ/private-discounts + backend URL and live signer/protocol policies for the configured adapters. The code path is complete; + Sepolia E2E still needs a real advertised discount and newly deployed executor ABI. diff --git a/docs/LIQUIDLANE-CONVENTIONS.md b/docs/LIQUIDLANE-CONVENTIONS.md new file mode 100644 index 00000000..0fefc5c9 --- /dev/null +++ b/docs/LIQUIDLANE-CONVENTIONS.md @@ -0,0 +1,197 @@ +# LiquidLane conventions + +LiquidLane is shared liquidity infrastructure, not a solver. This document is the compact standard for +RFQ, LI.FI, OEV, future UniswapX, and any new solver that consumes `LiquidLaneAdapter` state. + +## Ownership + +| Package | Owns | +|---|---| +| `internal/liquidlane` | adapter/vault/route types, latest-state reads, direct authorization, ids, rate math, and the pending-capacity ledger | +| `internal/liquidlane/snapshot` | common direct/physical inventory, amount-specific fill quote, authorization, and gas snapshot composition | +| `internal/liquidlane/strategies` | canonical fill routes, external-plan validation, and optional settlement gas pricing | +| `internal/liquidlane/strategies/greedy` | RFQ-like oracle normalization plus greedy quote/fill allocation and fill economics | +| `internal/liquidlane/gas` | shared YAML parsing and neutral acquire/allocate/deallocate/unknown route prediction from current adapter/vault facts | +| `internal/liquidlane/discounts` | signed-discount HTTP client, live-offer filtering, route matching, fill-quote construction, and fresh-signature validation | +| `internal/solvers/` | cadence, caches, strategy inputs, economics, protocol messages, calldata, and execution | + +The generic framework must not know about LiquidLane. Protocol execution is never shared: RFQ, LI.FI, +OEV, and UniswapX use different contracts, signatures, status models, and callbacks. LI.FI and UniswapX +do share the identical LiquidLane route-planning calculation before their local protocol adapters build +those different executions. + +## Canonical model + +Every route is one-way: + +```text +tokenIn -> LiquidLaneAdapter -> tokenOut +``` + +`tokenIn` is a member of `tokensToRedeem`; `tokenOut` is `adapter.vault().asset()`. External APIs may +use `asset`, `collateral`, or other names, but adapters must map them to `tokenIn`/`tokenOut` before the +facts reach a strategy. + +| Type | Meaning | +|---|---| +| `Adapter` | stable adapter, vault, output token, and output decimals | +| `Route` | stable adapter + `tokenIn` + `tokenOut` direction and decimals | +| `Inventory` | latest executable capacity/rate for one route | +| `FillQuote` | latest executable output for one concrete `amountIn` | +| `Auth` | direct caller authorization facts | +| `gas.Snapshot` | adapter-local owner/market-maker acquire balances plus vault-level shared free/withdrawable liquidity | + +Core field rules: + +- `MaxAssets` is the current output cap in `tokenOut` units. +- Direct inventory `MaxRate` is `getMaxRate(tokenIn)` and already includes `minDiscount`. A `FillQuote` + derives the same conservative fixed-point fact from `MaxAmountOut / AmountIn`, so fill-time private + offers are bounded without another RPC call. +- Discount `MaxRate` comes from the discounts backend and already includes its advertised discount. +- `GrossAmountOut` is raw `getAmountOut`; `MaxAmountOut` is the executable amount after discount. +- `MinDiscount` is the adapter's current lower bound for a fill. +- `ValidUntil` is an external offer deadline. Inventory does not carry a duplicate read timestamp; + solvers pass current chain/server time separately with each strategy decision. +- Shared values are copied at constructors and treated as immutable after entering a cache or strategy. + +Stable ids are lowercase and content-derived: + +```text +route:::: +capacity::: +candidate: +candidate::discount: +``` + +`CapacityID`, not `RouteID`, is the accounting boundary. Routes backed by the same vault/output pool are +not independent liquidity. + +## Reads and freshness + +LiquidLane reads always target RPC `latest` through ordinary `chain.Multicall`. + +- Do not use historical block tags or require archive-capable RPCs. +- Batch related calls once per logical read. A single Multicall is internally coherent enough for current + quoting; separate protocol reads may naturally observe adjacent heads. +- Do not attach an exact block number to latest inventory. Use decision-time chain state, TTLs, and + protocol deadlines. +- A latest snapshot is an estimate until transaction inclusion. Strategies apply reserve, two-sided price + movement, minimum profit, gas, and deadline padding. Execution contracts revalidate current rate and + capacity; where the protocol permits, they clamp to a signed economic floor before reverting atomically. +- Stable metadata (`vault`, asset, decimals, redeemable tokens) may be cached. Mutable inventory is + refreshed according to solver cadence or immediately before a fill. + +The shared reader exposes facts: + +```go +ResolveRoutes(ctx, adapters) ([]Route, error) +ReadInventory(ctx, routes) ([]Inventory, error) +ReadFillQuotes(ctx, routes, tokenIn, amountIn) ([]FillQuote, error) +ReadGasSnapshot(ctx, routes) (*gas.Snapshot, error) +ReadAdapterSnapshot(ctx, adapter, filler) (AdapterSnapshot, error) +ReadAuth(ctx, adapters, filler) ([]Auth, error) +FilterAuthorized(ctx, inventory, filler) ([]Inventory, error) +FilterAuthorizedRoutes(ctx, routes, filler) ([]Route, error) +``` + +Implementation rules: + +- Use generated `PackXxx`/`UnpackXxx` helpers and Multicall batches. +- Bound `tokensToRedeem`; reject invalid addresses, decimals, rates, caps, and discounts. +- Fail startup when a configured adapter's stable `vault`, output asset, output decimals, + `tokensToRedeem` list, or input-token decimals cannot be resolved. Silently running with a partial + configured adapter or route set is not allowed. +- Treat an unreadable `paused` or authorization result as unavailable. +- Treat an unreadable adapter-local `acquireBalance` as zero for gas prediction. This deliberately + selects an allocate/deallocate/unknown route with an equal or higher gas budget. +- Skip a bad route without hiding a batch transport error. +- Direct authorization is `filler == marketMaker || filler == owner || isFiller(marketMaker, filler)`. + +Block polling is allowed as a refresh trigger. Receipt confirmations and protocol epochs may also use block +numbers. The restriction is specifically against historical state calls and exact-block LiquidLane reads. + +## Strategy boundary + +The solver normally reads LiquidLane through shared snapshot composition and passes immutable `Inventory` or `FillQuote` facts into the +strategy. The solver owns refresh cadence, cache replacement, transaction submission, and one zero-value-ready +`CapacityLedger` for accepted fills. Strategies receive only its aggregate reservation snapshot; they do not +maintain a second per-order ledger. +Runtime values such as current block time, the txmanager fee cap, and `gas.Snapshot` are also facts. The +shared predictor owns adapter swap route units. Amount-specific RFQ-like protocols normalize inventory +against current per-physical-route `FillQuote`s through `NormalizeOracleInventory`; they never group +oracle prices by output token because the oracle, discount floor, and executable output belong to the +adapter route. Protocol adapters then map those facts into +`QuoteTask` or `FillTask`; the shared engine returns `QuoteSolution` or `FillSolution`. It ranks +already-priced candidates, enforces one alternative per physical route, solves exact input/output, +splits across route caps, allocates shared `CapacityID` budgets, applies explicit uncovered-input policy, +and, when an optional gas pricing model is supplied, converts a complete LiquidLane settlement gas +estimate into `tokenOut`. RFQ and UniswapX use the same quote engine. RFQ, LI.FI, and UniswapX use the +same fill engine; each then maps the solution into its +own wire response or executor plan. RFQ omits gas pricing, so its configuration and strategy payloads do +not gain gas fields; LI.FI and UniswapX supply gas pricing from their existing runtime facts. Candidate +discovery, protocol output resolution, +webhook DTOs, lifecycle, and calldata remain solver-local. A transaction-level calculation charges +settlement gas once, consumes acquire +balances per adapter, and consumes free/withdrawable liquidity once per shared vault. A strategy may reduce +all three budgets by its existing inventory reserve before route classification so a near-boundary plan is +priced as the next more expensive route. It must not add a standalone full-tx gas estimate for every route. + +If the set of reads is itself strategy-dependent, inject a narrow read-only capability such as `Pricing` +or `LiquidLaneState`. Do not inject a signer, tx manager, or unrestricted chain client into the strategy. +The capability must accept `context.Context`, batch calls, return typed facts, and be replaceable by a fake. + +Every fill plan crosses the same solver-owned execution boundary, whether it came from the built-in or +webhook strategy. The shared validator resolves every route reference back to a supplied candidate, +canonicalizes adapter/capacity/discount identity, checks amount totals, current output, gas floor, and +pending `CapacityID` reservations, then returns a cloned plan. Protocol deadlines, the executor's fixed +gas envelope, and wire semantics remain solver-local. + +Webhook strategies should receive the same facts in their request. A remote strategy may own its own RPC +only when that deployment deliberately accepts different freshness and availability from the local path. + +## Discounts and capacity + +Direct and signed-discount inventory for the same route are alternative ways to use the same capacity. +Never sum them. `internal/liquidlane/strategies/greedy` encodes the one-candidate-per-route rule for quote and fill +tasks across RFQ, LI.FI, and UniswapX; execution reservations use the shared `CapacityID`. + +For signed discounts: + +1. List and validate advertised offers for quote construction. +2. Never apply `discount` to backend `maxRate` a second time. +3. Resolve signatures again immediately before fill. +4. Recheck id, adapter, tokens, current discount bounds, and deadlines. +5. Reserve capacity for upward price movement: discount swaps release their full computed output and + cannot be reduced to a requested amount. +6. Pass a discount candidate only when the solver's executor can settle `discountSwap` atomically. + +Discount discovery, parsing, physical-route matching, cap/rate clipping, advertised fill-quote +construction, and fresh signed-term binding/deadline/output validation are shared. Solvers still own when +resolution happens: LI.FI pre-resolves a bounded candidate set and refreshes adapter state before deciding; +UniswapX resolves only the selected route; RFQ receives backend candidates and resolves selected legs. +Generated executor calldata and protocol lifecycle remain solver-local. + +## Solver profiles + +| Solver | LiquidLane usage | Solver-local responsibility | +|---|---|---| +| RFQ | amount-specific quote and fresh fill inventory; narrow pricing capability may read during strategy evaluation | RFQ order lifecycle and Reactor/Executor calldata | +| LI.FI | latest inventory on tick/block refresh; fresh `FillQuote` for each received order | range curves, OIF contexts, exclusivity, `AllowOpen`, and immediate-fill lifecycle | +| OEV | background latest inventory stored by Morpho market id | Morpho discovery, auction pricing, safety haircut, liquidation sizing, bundle/gas/deposit accounting | +| UniswapX | latest inventory either on request or from a short-lived background cache | quote request policy, Reactor orders, Permit2/cosigner rules, auction curve, order status, and fill calldata | + +3F does not use LiquidLane and should not be forced through these types. Shared code is justified by the +protocol dependency, not by making every solver look identical. + +## Adding another solver + +1. Resolve configured adapters into shared routes. +2. Choose latest-state refresh cadence and a stale-data policy. +3. Map shared facts into a small solver-specific strategy input. +4. Account capacity by `CapacityID` and in-flight execution. +5. Keep gas, margin, price movement, auctions, and exclusivity in the strategy. +6. Re-read amount-specific state and protocol status before sending funds-moving calldata. +7. Keep execution, signatures, wire DTOs, and failure state machines in the solver package. + +The shared package provides current LiquidLane facts. Solvers decide when those facts are sufficient and +contracts enforce the final executable truth. diff --git a/docs/OEV-PLAN.md b/docs/OEV-PLAN.md index 605a08f8..1fca363a 100644 --- a/docs/OEV-PLAN.md +++ b/docs/OEV-PLAN.md @@ -167,7 +167,7 @@ adapter is OEV-local because it parses directly into the OEV monitor snapshot. | `strategies/default/monitor.go` | Morpho API snapshot, atomic hot-path state, and adapter-scoped market filtering | | `strategies/default/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` | solver-owned on-chain reads: Executor accounting and adapter snapshot | +| `chainreader.go` | solver-owned Executor accounting plus mapping from the shared `liquidlane.Reader` snapshot into OEV strategy types | | `reservations.go` | in-flight auction reservation + pending-auction snapshot + 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, including auction identity/key hashing | diff --git a/docs/RFQ-PLAN.md b/docs/RFQ-PLAN.md index d41f5e2d..ec39e4db 100644 --- a/docs/RFQ-PLAN.md +++ b/docs/RFQ-PLAN.md @@ -14,7 +14,7 @@ push path; orders are found exclusively by polling the backend. - **HTTP server** — `POST /quote` (backend fans out a swap request carrying the candidate per-adapter inventory snapshot in `adapters[]`; the filler prices it, applies a discount, selects the best - adapter legs, persists the strategy by `quoteId`, and returns an `amountOut`), `GET /health`, and + adapter legs, and returns an `amountOut`), `GET /health`, and the code-first OpenAPI surface (`/openapi.json`, `/openapi.yaml`, `/docs`). `/quote` is gated by an `x-rfq-shared-secret` header (the backend peer). There is **no `/notify` endpoint**. - **Poller** — every `pollInterval`, `GET /orders?filler=&orderStatus=open` from the @@ -23,14 +23,16 @@ push path; orders are found exclusively by polling the backend. sends it; the `Executor` calls the `Reactor`, which calls back into `Executor.execute()` to run the adapter `swap`s and satisfy the order's outputs. Each on-chain `Swap`'s `vault` slot is set to the leg's **adapter** address. -- **State** — in-memory only: `strategies` (by `quoteId`), `orders` (state machine), `attempts`. +- **State** — in-memory only: `orders` (state machine) and `attempts`. -The `/quote` request inventory (`adapters[]`) and the strategy use **adapter/asset** terminology -(`adapter`, `asset`, `assetDecimals`, `maxAssets`, `maxRate`, `discountId`) — a 1:1 match for the TS -`solverQuoteRequestSchema`. Pricing leg types: **direct** (`discountId == null`, public adapter rate) -and **discount** (`discountId != null`, a signature-gated private rate negotiated off-chain via the -backend `/discounts` flow). Both are in scope for full parity — discount legs are built in **P3** (§4), -after the direct path is solid; they are sequenced last, not dropped. +The `/quote` request inventory (`adapters[]`) still matches the TS `solverQuoteRequestSchema`, but the +solver maps that boundary shape into the shared LiquidLane terms from +[`LIQUIDLANE-CONVENTIONS.md`](LIQUIDLANE-CONVENTIONS.md): `Inventory` is +`adapter + tokenIn + tokenOut + maxAssets + maxRate`, and RFQ's external `asset` field is the shared +`tokenOut`. Pricing leg types are **direct** (`discountId == null`, public adapter rate) and +**discount** (`discountId != null`, a signature-gated private rate negotiated off-chain via the backend +`/discounts` flow). Both are in scope for full parity — discount legs are built in **P3** (§4), after +the direct path is solid; they are sequenced last, not dropped. --- @@ -67,7 +69,9 @@ A new self-contained `internal/solvers/rfq/` implementing `solver.Solver` — no init'd Sentry for uncaught crashes. - **Fills go through the shared `txmanager`** (CLAUDE: solvers never send directly). The RFQ package builds the `Executor.fill` calldata; txmanager owns the nonce, send, and receipt/revert. -- **On-chain reads use `chain.Multicall`** (the adapter exposes many per-vault views per quote). +- **On-chain reads use the shared LiquidLane reader over `chain.Multicall`.** Exact-input pricing is + route-specific and reads the executable amount after the adapter's current `minDiscount`; adapters + that produce the same output asset are never collapsed into one oracle observation. - **Addresses + backend URL come from `solver.config`** (config-is-king); secrets (`backendSharedSecret`, the caller key) via `*Env` indirection (`os.Getenv` at point of use). - **Bindings** for `Executor`/`Reactor` (from the `rfq` build) and `LiquidLaneAdapter`/`UniversalDelegator`/ @@ -83,14 +87,14 @@ 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` (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) | +| `quote.ts` + `strategy.ts` | `quote.go` + `strategy.go` (quote-server wiring and typed input assembly) + `strategies/` (the pluggable decision layer: `default` = allocation, `webhook` = external decider) | +| `execution.ts` | `execution.go` (poll loop, order state machine, fill; fresh fill-plan production 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`) | +| `backend.ts` + `discounts.ts` | `backend.go` (thin adapter over the generated `api/rfqbackend` client for `/orders`) + shared `internal/liquidlane/discounts` (`/discounts`) | | `contracts.ts` + `inventories.ts` | `chainreader.go` (multicall adapter/vault reads) + shared `chain` | -| `domain.ts` | `store.go` types + `strategies/types` (strategy input/output, fill plan, legs, candidates) | +| `domain.ts` | `store.go` types + `strategies/types` (RFQ strategy input/output and fill plan) + shared `liquidlane.QuoteCandidate` | | `config/env.ts` + deployment manifests | `config.go` (typed `solver.config`) | -| `db`/repositories | `store.go` (in-memory strategies/orders/attempts) | +| `db`/repositories | `store.go` (in-memory orders/attempts) | | `metrics.ts` | `metrics.go` (collectors on the shared registry) + framework `internal/observability` (`/metrics` — see §2) | ### Pluggable strategy layer @@ -98,24 +102,74 @@ A new self-contained `internal/solvers/rfq/` implementing `solver.Solver` — no 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: +submission), validates protocol data, and normalizes backend inventory plus current adapter reads into +`[]liquidlane.QuoteCandidate`; the strategy owns only 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`). When aggregate +- **`default`** — the in-process greedy discount + leg selector. `BuildFillPlan` always decides again + from current LiquidLane candidates and re-binds the result to the awarded order + (`tokenIn`/`tokenOut`/`amountIn`, `quotedAmountOut ≥ required`). When aggregate adapter capacity cannot cover an exact-input request, it still returns the available `maxAssets` as output and assigns the residual input to the final leg, surfacing the shortfall as price impact instead of declining the quote. -- **`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. +- **`webhook`** — a transport-only adapter that delegates to an external decider over JSON. + `BuildFillPlan` re-calls the decider with current candidates and the order's + `amountIn`/`requiredAmountOut`. + +Quote and fill use the same input shape; fill simply supplies freshly normalized candidates plus the +awarded `requiredAmountOut`. Both strategies reuse one `FillPlanFromQuote` structural validation path. +There is no quote-plan cache or default/webhook-specific Executor mapping. + +`permissionedTokens` is both the membership set for `tokensToQuote` and, only when that scope is +`permissioned`, a solver-owned hard constraint. The solver sets `RequireSingleRoute` on both quote +and fill snapshots for admitted tokens in that scope. A strategy must choose one candidate that +covers the entire `amountIn`; partial candidates, including direct and discount variants of the same +adapter, cannot be combined. The default strategy chooses the best fully viable candidate and +declines if none exists. The solver independently rejects any quoted or fill plan whose leg count is +not exactly one, so webhook and fresh fill planning fail closed at the same boundary. The `all` and +`permissionless` scopes retain greedy multi-candidate aggregation. + +`minAmountsIn` is a second solver-owned quote gate, independent of the token scope: an optional map of +input-token address → minimum request size in that token's **base units** (decimal string). It is +evaluated in `quoteService.quote` right after the token-scope check and before any adapter filtering or +chain read, so a below-minimum request costs nothing and returns the usual no-quote (`nil, nil` ⇒ HTTP +204). The comparison is strict: `amountIn == min` still quotes. Keys are parsed into `common.Address`, +so configured checksum casing does not matter; values must parse as positive integers (zero, negative, +non-numeric, or a zero/invalid address key is a startup error, as is the same token listed twice in +different casing). Tokens absent from the map have no floor. This is how RWA inputs (HYBOND, deJAAA, +deJTRSY) enforce a redemption-sized minimum without a per-token code path. Covered by +`gating_test.go` (`TestParseConfigMinAmountsIn`, `TestParseConfigMinAmountsInErrors`, +`TestQuoteMinAmountIn`) and `server_test.go` (`TestServer_QuoteBelowMinAmountNoContent`). 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`. +brain; the solver only enforces its own structural and safety constraints) are documented once in +[`strategy-plan.md`](strategy-plan.md), shared with every solver. Shared LiquidLane fact conventions +are documented in [`LIQUIDLANE-CONVENTIONS.md`](LIQUIDLANE-CONVENTIONS.md). The concrete RFQ +input/output types (`QuoteInput`/`QuoteOutput`, `FillInput`/`FillPlan`) live in +`internal/solvers/rfq/strategies/types`; the candidate itself is the shared +`liquidlane.QuoteCandidate`. + +The RFQ solver is the protocol adapter around the shared LiquidLane reader. It obtains current +amount-specific `FillQuote`s, and `NormalizeOracleInventory` binds each backend inventory entry to its +physical route and derives direct rates from +the executable `MaxAmountOut` (gross `getAmountOut` after current `minDiscount`), then submits normalized candidates to the same `QuoteTask` engine used by +UniswapX. Because the RFQ request carries only output-asset decimals, the solver resolves and caches +`tokenIn` decimals before interpreting either direct or signed-discount rates. It also binds each advertised +adapter to its on-chain vault and asset (using startup-resolved metadata for configured adapters) and rejects +asset or decimal mismatches. Before normalization, candidates sharing the resulting vault `CapacityID` +receive one bounded allocation of that shared output capacity, so a multi-adapter quote cannot promise the +same vault assets twice. At execution the solver repeats the read and normalization from fresh RFQ +inventories. The default strategy +converts those typed candidates into the same `FillTask` used by LI.FI and UniswapX. The tasks explicitly request RFQ's residual-input-as-price-impact +semantics; deterministic splitting, direct/private alternatives, capacity, and output sizing stay inside +the shared engine. RFQ omits its optional gas pricing model, so no RFQ gas configuration or strategy +payload fields are required. The strategy has no chain/logger dependencies and only maps shared +solutions to RFQ response/Executor legs. + +Signed-discount HTTP transport, live-offer filtering, and selected-term identity/route/deadline validation +are shared through `internal/liquidlane/discounts`; RFQ keeps its backend candidate policy and executor ABI mapping. +Advertised offers are parsed once into typed addresses, ids, amounts, decimals, and deadlines before +they become `liquidlane.Inventory`; expired or malformed offers fail closed. RFQ keeps only its +executor-specific `discountSwap` calldata mapping. --- @@ -138,7 +192,9 @@ solvers: pollIntervalMs: 3000 orderLimit: 20 solverMode: external # "external" (default) | "internal" — see below - adapters: # LiquidLane adapter addresses (whitelist + recovery) + minAmountsIn: # optional per-input-token floor (base units) + "0x…tokenIn": "1000000000000000000" # below ⇒ no quote (204); equal ⇒ still quotes + adapters: # LiquidLane adapter addresses (whitelist + fill planning) - "0x…liquidLaneAdapter" # vault + collateral resolved on-chain at startup ``` @@ -148,19 +204,22 @@ adapter whitelist (it replaces the earlier separate `adapterWhitelistEnabled` / config still carrying either is rejected at startup so operators migrate): - **`external`** (default — the open-source filler external parties run): **never touches the discounts - API** — skips `GET /discounts` in recovery, never calls `POST /discounts` at fill (a surfacing discount + API** — skips `GET /discounts` in fill planning, never calls `POST /discounts` at fill (a surfacing discount leg is failed closed). It uses **only its own adapters**, which scope quoting/filling and are - **required** (no discounts fallback → an empty list is rejected at startup). The quote path is not - discount-filtered — the backend is trusted to send each solver the right adapters. -- **`internal`**: uses **public discounts** (`GET`/`POST /discounts`) and accepts **every adapter the backend - advertises** (no quote-time scoping). Its `adapters` are **optional extra permissioned inventory** used in - recovery alongside the discounts — **deduped** (`discountInventories` drops a discount whose adapter is + **required** (no discounts fallback → an empty list is rejected at startup). Before starting HTTP or + polling, every configured adapter must directly authorize the executor through `owner`, `marketMaker`, + or `isFiller`; startup fails otherwise and emits a structured error with mode, executor, configured + adapters, and the underlying authorization reason. +- **`internal`**: uses **public discounts** (`GET`/`POST /discounts`). Its optional `adapters` scope the quote + path and add permissioned inventory to fill planning; discount recovery during execution remains unrestricted. + Direct and signed-discount candidates are **deduped** (`discountInventories` drops a discount whose adapter is already in the configured/permissioned set). Both behaviours are **derived from `solverMode` on demand** — no redundant config fields. `Config` exposes -`usesDiscounts()` (`mode == internal`) and `restrictsToAdapters()` (`mode == external && len(adapters) > 0`), -which `buildServices` uses to wire the discount gate (into the execution service: recovery + fill) and the -adapter scoping (into both services). Covered by `discounts_disabled_test.go`, `config_test.go` +`usesDiscounts()` (`mode == internal`), `restrictsToAdapters()` (external execution with configured +adapters), and `quoteScopesToAdapters()` (either mode with configured adapters). `buildServices` uses those +facts to wire the discount gate and the two path-specific adapter scopes. Covered by +`discounts_disabled_test.go`, `config_test.go` (`TestParseConfig_SolverMode`), and `solver_test.go` (`TestBuildServices_WhitelistWiring`). The signing key is the framework `signer` (the caller EOA); `chain.rpcUrl/chainId` select the network. @@ -172,51 +231,59 @@ list serves two purposes: non-configured adapters in a `/quote` request are dropped (none left ⇒ 204), so an `internal`-mode filler advertises quotes only for its own adapter universe (e.g. a per-solver adapter). The **execution** path scopes to the configured `adapters` only in `external` mode (`restrictsToAdapters`): there backend - discounts with a non-configured adapter are ignored during recovery. `internal` mode never restricts - filling — discount-driven recovery may legitimately route through any advertised adapter — so with no + discounts with a non-configured adapter are ignored during fill planning. `internal` mode never restricts + filling — discount-driven planning may legitimately route through any advertised adapter — so with no `adapters` configured an `internal` filler quotes and fills through every advertised adapter. -- **Strategy recovery**: it bounds the candidate adapter universe the post-restart recovery - multicall scans (recovery's direct inventories are whitelisted by construction). +- **Fill planning**: it bounds the candidate adapter universe the fill-time multicall scans + (direct inventories are whitelisted by construction). --- ## 4. Build phases -All three phases are committed scope — the goal is full parity with the TS filler, including discount -legs. Phasing is about sequencing and reviewable increments, not dropping features. +All phases below are committed scope. Phasing is about sequencing and reviewable increments, not +dropping features. 0. **(done)** Vendor RFQ ABIs: `Executor`/`Reactor` from `../rfq/out`, and `LiquidLaneAdapter`/ `UniversalDelegator`/`IVaultV2`/`IERC4626` from a standalone `core-mirror` build → `api/bindings/rfq/` + `api/bindings/{delegator,vaultv2,erc4626}`. CGO-free build holds. -1. **(done) Quote path** — `config.go`, bindings, multicall reads (`getAmountOut` batched, decimals - cached), `strategy` pricing + discount + leg selection (direct legs), Huma HTTP server (`/quote`, +1. **(done) Quote path** — `config.go`, bindings, route-specific amount quote reads (`paused`, + `getMaxAssets`, `getAmountOut`, `minDiscount`; decimals cached), `strategy` pricing + discount + leg selection (direct legs), Huma HTTP server (`/quote`, `/health`, `/openapi.json` + `/docs`, shared-secret auth), in-memory store. Unit-tested (pricing golden numbers, config, httptest server). 2. **(done) Execution** — backend client (`/orders`), **poll-only** loop + order state machine (`queued→submitting→submitted→{filled|expired|failed}`), reactor-order decode + `Executor.fill` (mixed overload, golden selector test) via the shared txmanager (revert→failed), attempt tracking, - and on-chain **strategy recovery via a single multicall** over the configured per-vault adapters + signed-order filler/deadline/output terms as the execution source of truth with fail-closed backend + envelope consistency checks, + and on-chain **fresh fill planning via a single multicall** over the configured per-vault adapters (adapter views + `marketMaker`/`owner`/`isFiller` authorization filter). Direct legs only. Unit-tested (state machine with fakes, backend httptest). 3. **(done) Discount legs** — backend `/discounts` (`resolveDiscount` + `listDiscounts`), discount-swap encoding (`IReactorDiscountSwapInput` from the resolved signed discount) wired into `Executor.fill`, discount-aware strategy selection (legs price off the vault `maxRate`), and - discount inventories in recovery. Direct + discount fills now match the TS filler. Unit-tested + discount inventories in fill planning. Direct + discount fills now match the TS filler. Unit-tested (discount-leg selection, discount fill resolves + encodes). 4. **(done) Adapter whitelist** — port of TS filler PR #54: quoting/filling restricted to the configured `vaults[].adapter` set (originally `adapterWhitelistEnabled`; now auto-enabled by `solverMode: external` when `adapters` is non-empty — see §3), - recovery discounts filtered by the same set, and a fill-time guard that fails the order when a + fill-time discounts filtered by the same set, and a guard that fails the order when a backend-resolved discount's adapter differs from the quoted strategy leg's adapter (no tx is sent; a still-open order is re-armed and re-evaluated next poll, matching the TS lifecycle). Unit-tested (whitelist build/filter, config flag + zero-address rejection, factory wiring, quote - 200/204 paths incl. disabled toggle, recovery discount filter, mismatch → failed order with no + 200/204 paths incl. disabled toggle, fill-time discount filter, mismatch → failed order with no tx). - -**Reads are multicall-batched** end to end: the quote path issues one `getAmountOut` aggregate3 (with -cached `decimals`), and recovery issues one 3-views-per-adapter aggregate3 (`paused`, `getMaxAssets`, -`getMaxRate`) — each adapter's `vault` and collateral `asset` are resolved once at startup (from -`adapter.vault()` / `vault.asset()`), not re-read per recovery, so there are no per-read round-trips. +5. **(done) Permissioned-scope single-route constraint** — when `tokensToQuote` is `permissioned`, + quote and fill inputs use one candidate instead of aggregation, and the solver rejects multi-leg + strategy/webhook outputs before publication or calldata construction. Input beyond that route's + output capacity is absorbed as price impact, matching the other exact-input scopes. Cold fill + planning applies the same constraint. Unit-tested across scope gating, permissionless aggregation, + single-route capped output, webhook rejection, and fresh planning. + +**Reads are multicall-batched** end to end: amount-specific strategy evaluation uses the shared +per-route fill-quote batch (`paused`, `getMaxAssets`, `getAmountOut`, `minDiscount`), while inventory +refresh uses (`paused`, `getMaxAssets`, `getMaxRate`) — each adapter's `vault` and collateral `asset` are resolved once at startup (from +`adapter.vault()` / `vault.asset()`), not re-read per fill plan, so there are no per-read round-trips. --- @@ -226,7 +293,7 @@ cached `decimals`), and recovery issues one 3-views-per-adapter aggregate3 (`pau 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 + LiquidLane adapter address list (`vaults`; adapter whitelist + fill planning — each adapter's vault and collateral are resolved on-chain at startup; with the whitelist enabled an empty list declines every quote), the backend shared secret, and the caller key (last two via env). Hoodi addresses are known from the TS deployment manifest; local from the rfq-integration local-stack deploy. @@ -236,9 +303,10 @@ 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** — 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). +- **Pricing follows the TS greedy port for all inputs** — permissioned inputs additionally use the + single-route constraint above. 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. @@ -246,14 +314,15 @@ cached `decimals`), and recovery issues one 3-views-per-adapter aggregate3 (`pau ### Parity with the current TS filler -**Status (verified against the current TS `rfq-filler` working tree): full functional parity.** The +**Status (verified against the current TS `rfq-filler` working tree): functional parity plus the +permissioned-scope single-route constraint described above.** The pricing/sizing/leg-selection math, the `Executor.fill` selector + nested tuple encoding, the backend endpoints actually used (`GET /orders` ×3 query shapes, `GET /discounts`, `POST /discounts` resolve), -and the recovery RPC read/authorization set are all 1:1. The Go port adds a few **fail-closed +and the fill-time RPC read/authorization set are all 1:1. The Go port adds a few **fail-closed hardenings the TS filler lacks** — an order-deadline check before fill, a strategy↔order `tokenIn`/`tokenOut`/`amountIn` binding, txHash validation on reconcile, a single-entry guard on the -batch discount-resolve shape, and TTL eviction of stale strategy/order cache entries (TS maps grow -unbounded). A few **intentional, non-fund-moving divergences** remain, by design: +batch discount-resolve shape, and TTL eviction of stale terminal orders (TS maps grow unbounded). +A few **intentional, non-fund-moving divergences** remain, by design: - **Quote-time oracle revert** — a reverting `getAmountOut` makes the Go quote *skip that asset and price the rest* (multicall `allowFailure`), whereas the TS filler throws and fails the whole quote. @@ -265,9 +334,10 @@ unbounded). A few **intentional, non-fund-moving divergences** remain, by design each deployment's `backendUrl` accordingly (mismatch ⇒ 404 on every backend call). - **Internal discounts path** — the discounts API is internal-only and served under `/api-internal/v1` (orders stay on `/api/v1`). Rather than regenerate the client for a routing detail, - `internalDiscountTransport` (`backend.go`) rewrites the generated `/api/v1/discount(s)` requests to - `/api-internal/v1/...` at the transport layer; orders pass through unchanged. Covered by the - `backend_test.go` httptest assertions. + the shared discounts client rewrites generated `/api/v1/discount(s)` requests to + `/api-internal/v1/...` at its transport boundary; orders pass through unchanged. RFQ uses it through + `internal/liquidlane/discounts`; LIFI reuses the same client and validation for its discount-backed + fills. Covered by httptest assertions. - The `{adapter, tokenToRedeem}` discount-resolve selector exists in TS types but is unused by execution (both sides resolve by `discountId`); Go omits it. Cosmetic. @@ -278,7 +348,7 @@ unbounded). A few **intentional, non-fund-moving divergences** remain, by design (not `vault`): `discountTerms`/`discountListItem` parse `json:"adapter"`; the on-chain `Discount.vault` slot is then filled from that adapter address (positional binding name unchanged). A wrong tag here silently zero-fills and breaks every fill, so these are pinned by tests. -- **Discount-recovery filter** — matches TS exactly: keep discounts where the adapter is +- **Fill-time discount filter** — matches TS exactly: keep discounts where the adapter is whitelisted, `tokenToRedeem == tokenIn`, and the adapter is not already permissioned; the `asset == tokenOut` check is left to the strategy evaluator (no extra collateral pre-filter). - **Adapter whitelist** — ports TS PR #54: the whitelist is the configured `vaults[].adapter` set @@ -286,8 +356,8 @@ unbounded). A few **intentional, non-fund-moving divergences** remain, by design explicit `adapterWhitelistEnabled` flag (the TS `RFQ_FILLER_ADAPTER_WHITELIST_ENABLED` env); that flag has since been folded into **`solverMode`** (§3), and scoping is now per-path. The **quote** whitelist is enabled whenever `adapters` is non-empty in either mode (`quoteScopesToAdapters`); the **execution** - whitelist (recovery discount filtering) is `external`-only (`restrictsToAdapters`). Enforcement points: - `/quote` adapter filtering (none left ⇒ 204) — quote-scoped; recovery discount filtering — execution-scoped; + whitelist (fill-time discount filtering) is `external`-only (`restrictsToAdapters`). Enforcement points: + `/quote` adapter filtering (none left ⇒ 204) — quote-scoped; fill-time discount filtering — execution-scoped; and the unconditional fill-time resolved-discount ↔ strategy-leg adapter equality check (mismatch ⇒ order failed, no tx; while the backend still lists the order open it is re-armed on the next poll and the discount re-resolved, so a transient mis-resolution self-heals — same lifecycle as TS). diff --git a/docs/UNISWAPX-PLAN.md b/docs/UNISWAPX-PLAN.md index 65c4e128..4f5084c0 100644 --- a/docs/UNISWAPX-PLAN.md +++ b/docs/UNISWAPX-PLAN.md @@ -10,48 +10,58 @@ 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. +UniswapX RFQ ("Exclusive Dutch Auction") has the same broad shape as our own Symbiotic RFQ — signed orders, +a settlement Reactor, off-chain quoters/fillers — but owns a different wire contract, order codec, lifecycle, +and strategy boundary. The two solvers reuse neutral LiquidLane primitives and framework services, not one +shared strategy facade. - **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 + URL we register with them. The UniswapX strategy prices direct LiquidLane routes from `getAmountOut` and + can select advertised signed-discount candidates, then applies its price buffer and optional gas floor. We respond + `200` with `amountOut` and our `filler` = the on-chain `LiquidLaneUniswapXExecutor` address, or decline with + an empty `204`. Exact-input and exact-output requests are supported for quote protocols `v1` and + `v2`. That wire field is not used to infer indicative versus hard phase: Uniswap intentionally hides + the phase from quoters. +- **Order ingestion.** If our quote wins, Uniswap's per-order cosigner finalizes the auction terms 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). + `orderHash`. The generated client follows upstream spec version 2.0.0 and its typed `DutchV2OrderEntity`. + Uniswap's `order-notification` push webhooks are **deprecated for new integrations** (Filler FAQ), so + polling is the only implemented delivery channel (§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. + `LiquidLaneUniswapXExecutor` implements `IReactorCallback.reactorCallback`, sources the output token from + one or more `LiquidLaneAdapter`s, and approves it back to the Reactor. Inputs are pulled from the swapper + via Permit2. The current prototype supports typed direct `swap` and signed `discountSwap` routes, plus + multiple same-token outputs (including fee outputs). A decaying exact-output order may resolve to more + input in the execution block than the solver planned against; the routes consume their planned input and + the executor retains the positive difference as filler surplus. +- **Safety.** Fail-closed pre-fill validation gates, quote-state epochs, post-fill snapshot refresh, and a + fade-aware circuit breaker (Uniswap penalizes win-but-don't-fill — see §4, §6). +- **State** — in-memory only: an immutable refreshed inventory and optional gas snapshot, its epoch, pending-fill capacity + reservations, exclusive-obligation reconciliation state, order dedup/retry state, and breaker timestamps; + 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. | +| Liquidity source | **Symbiotic vaults first, including signed private discounts**; secondary-DEX hop later | Reuse LiquidLane direct and discount liquidity; widen pairs later. | +| Order version | **V2 first (mainnet)** | "Goal is mainnet" (V2). Add a codec boundary when a second real order version is implemented. | +| Pricing v1 | **Redemption rate − fixed haircut**, optional gas-aware floor | Ship fast, tune later (matches how `3f`/`rfq` shipped). | +| On-chain executor | **UniswapX-specific** `LiquidLaneUniswapXExecutor.sol` | Smallest, auditable surface; no multi-venue abstraction yet. | +| Code organization | **UniswapX-local `default` + `webhook` strategies** | The solver owns its protocol-specific decision contract; neutral LiquidLane facts/math and webhook transport stay shared (§2.1). | **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. +`tokenIn` must be a token-to-redeem on a configured direct route or a valid advertised signed-discount route, +and `tokenOut` must be that adapter's ERC-20 vault asset. Native-ETH output is outside the initial route model +and is declined in v1; §7 records an optional WETH-unwrapping extension. 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. +**Out of scope (v1):** legacy V1 limit orders, V3/Tempo order type, mixed-token or native-token outputs, +secondary-DEX sourcing, self-funding, a competitive +exclusivity-override pricing controller, and quoting any pair our vaults can't settle. Exact on-chain +`exclusivityOverrideBps` is still applied when evaluating a public order during another filler's window. --- @@ -59,94 +69,73 @@ economics, and quoting any pair our vaults can't settle. 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. +(see `docs/strategy-plan.md`) and the shared LiquidLane read/type conventions +(`docs/LIQUIDLANE-CONVENTIONS.md`). §2.5 is the consolidated reuse-vs-delta implementation checklist. + +### 2.1 Solver-local strategy, shared LiquidLane primitives + +UniswapX owns its strategy contract and registry under `internal/solvers/uniswapx/strategies/`: ``` -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 +internal/solvers/uniswapx/ + strategy.go + strategies/ + registry.go + types/types.go # DecideQuote / DecideFill + default/ + webhook/ ``` -- **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. +The solver owns order decoding, Dutch amount resolution, quote serving, pending-fill reservations, chain +snapshots, exclusive-obligation reconciliation, preflight, and transaction lifecycle. For each RFQ request, +the strategy receives its concrete amount plus the latest inventory and optional gas snapshot and returns one +`amountIn`/`amountOut` pair. At fill time it receives a fresh chain snapshot and returns an immediately +executable LiquidLane route plan. A candidate may carry a `DiscountID`; offer discovery and fill-time +resolution of signed terms stay solver-owned. + +Only proven neutral packages are shared: `internal/liquidlane` for route/inventory types, capacity IDs, +fixed-point math, readers, and signed-discount client/types; `internal/liquidlane/snapshot` for the +common direct/physical inventory, amount-specific fill, and optional gas snapshot read path; +`internal/liquidlane/strategies/greedy` for normalized `QuoteTask`/`FillTask` solving, capacity accounting, +minimum-output distribution, and gas conversion; `internal/liquidlane/strategies` for canonical fill +routes, pending-capacity reservations, gas pricing, and webhook-plan validation; and +`internal/webhook` for bounded remote-decision transport. The RFQ solver normalizes its protocol facts +to shared `QuoteCandidate`s; UniswapX and LI.FI strategies start from shared `Inventory`. Their default +strategies feed the same quote engine. RFQ, LI.FI, and UniswapX pass fresh amount-specific fill +candidates to the same fill engine, then adapt its result to RFQ Executor, OIF, or Reactor lifecycle. +Public strategy contracts, registries, config decoding, webhook payloads, +protocol output/deadline handling, and calldata remain solver-local. +The shared discounts package also owns offer-to-route matching, advertised fill-quote derivation, and +fresh selected-term validation. UniswapX owns only request timing, token policy, resolve-selected +orchestration, and mapping validated terms into its executor ABI. Its chain reader accepts explicit +executor, caller, and route facts rather than the protocol config. Reactor binding remains a deployment +assertion because the PR19 ABI has no getter. + +- **`EXACT_OUTPUT`:** the strategy solves the concrete requested output against current capacity, price + buffer, and gas, and returns the required input. There is no published range or cached quote route. +- **Candidate construction:** UniswapX receives no inventory in the quote request, so the solver refreshes + configured direct inventory and `getAmountOut` values in the background. In internal mode it also resolves + advertised `(adapter, tokenIn)` routes on-chain; configured adapters scope quotes when present, while an + empty list produces discount-only quotes from all valid advertisements. Direct and signed-discount + candidates for one physical route share the same capacity domain. ### 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` +- **The quote server is a bounded strict-JSON stdlib handler.** The public quote schema is not available in + the order-service OpenAPI and remains a hand-vendored, tested boundary (§4.1, §4.3). +- **`/metrics`** is the framework's shared registry; the solver registers bounded quote, poll, fill, + readiness, and breaker collectors via `deps.Metrics.Registerer()`. +- **Fills go through the shared `txmanager` asynchronously** (CLAUDE: solvers never send directly). The + solver builds `LiquidLaneUniswapXExecutor.execute` calldata; txmanager owns nonce/send/receipt and applies + the configured confirmation count. Pending capacity stays reserved through that completion, then remains + unavailable to quotes until a fresh post-fill snapshot is published. +- **On-chain reads use `chain.Multicall`** through the solver's LiquidLane reader; the strategy receives + validated inventory plus gas snapshots and current fee inputs only when gas accounting is configured. +- **Addresses + URLs come from `solver.config`**; secrets (`UNISWAP_API_KEY`, the solver 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`, +- **Signer** — the framework's single EOA is the UniswapX **solver** (holds the role on `LiquidLaneUniswapXExecutor`, 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. @@ -154,111 +143,121 @@ internal/ | 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`. +| `solver.go` | factory, dependency wiring, startup validation, and `Run` lifecycle | mirror `rfq` | +| `config.go` | typed config: addresses, servers, optional gas feeds, breaker, adapters/token policy, strategy | mirror `rfq` | +| `server.go` / `apitypes.go` / `middleware.go` | bounded quote webhook (`POST /quote`), `/health`, `/healthz`, `/ready`; source-IP auth stays at ingress | net-new | +| `quote_refresh.go` | background inventory and optional gas snapshots, epoch binding, and atomic publication | net-new | +| `polling.go` | exclusive and public V2 polling; dedup/retry admission and exclusive reconciliation | net-new | +| `execution.go` | fill planning, discount resolution, executor calldata, preflight, async submission, and completion | mirror `rfq` + net-new | +| `chainreader.go` | config-independent executor/route checks plus refreshed inventory/rate and optional gas snapshots | reader port | +| `strategies/` | UniswapX-local contract, registry, `default`, and `webhook` decisions (§2.1) | net-new | +| `order.go` | V2 Dutch codec, hashes, signature/exclusivity validation | net-new | +| `orderclient.go` | generated-client adapter, authenticated polling, one ≤6 RPS limiter, pagination/body bounds | net-new | +| `state.go` / `health.go` / `metrics.go` | reservations, quote epochs, exclusive obligations, dedup/backoff/breakers, readiness and metrics | net-new | + +**On-chain:** the RFQ contracts repository owns `LiquidLaneUniswapXExecutor.sol`, its interfaces, and +contract tests. `rfq-integration` consumes a pinned RFQ contracts revision, while this solver vendors only +the executor ABI and generated binding under `api/bindings/uniswapx/` — see §7/P3. ### 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. +`txManager` / `observability` come from the framework block, unchanged from the `rfq` profile. The current +profile is [`config/uniswapx.example.yaml`](../config/uniswapx.example.yaml); the abbreviated shape is: ```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" + executor: "0x…LiquidLaneUniswapXExecutor" + adapters: ["0x…liquidLaneAdapter"] + solverMode: internal + tokensToQuote: permissioned + permissionedTokens: ["0x…tokenToRedeem"] + quoteServer: { listenAddress: ":42080", refreshInterval: 12s, quoteTtl: 30s } + orderServer: + baseUrl: "https://api.uniswap.org/v2" + apiKeyEnv: UNISWAPX_ORDER_API_KEY + pollInterval: 1s + sources: { exclusiveV2: true, publicV2: true } + discounts: { baseUrl: "https://rfq.example", httpTimeout: 2s, minimumValidity: 15s } + gas: + nativeUsdFeed: "0x…" + nativeMaxAge: 1h + tokenUsdFeeds: [{ token: "0x…asset", feed: "0x…", maxAge: 1h }] + breaker: { maxFailures: 3, window: 5m } + strategy: { name: default, config: { priceBufferBps: 20 } } ``` +The `gas:` block is optional. When omitted, quote and fill decisions do not subtract gas and the solver +skips gas-state and Chainlink reads. Transaction submission still uses the tx manager's current fee, so the +solver pays that cost without passing it through to the quote. + +Startup scans the executor's indexed `callers(uint256)` entries for the framework signer and checks +executor bytecode. In external mode it also requires every configured adapter to authorize the executor as a +direct filler. The PR19 ABI has no `isCaller` helper or Reactor getter, so the configured Reactor must still +be matched to the implementation's immutable during deployment. +`solverMode: external` (default) forbids `discounts`, requires a non-empty `adapters` list, and requires every +configured adapter to authorize the executor as a direct filler. `solverMode: internal` requires `discounts` +and makes `adapters` optional. +When the list is non-empty it scopes quote candidates and direct fills; fill-time signed-discount recovery +remains unrestricted, exactly as in RFQ. Without configured adapters the solver operates discount-only. +Every advertised route is resolved on-chain and accepted only when its asset/decimals, current capacity and +rate, minimum discount, token policy, and—when gas accounting is enabled—configured gas feed are valid. Direct candidates are always +restricted to configured adapter addresses, so a dynamically discovered signed route cannot silently become +a direct route. If the discounts API is unavailable, internal mode continues with configured direct routes; +without configured adapters it publishes an empty quote state until the API recovers. + ### 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.) +deliberately different from it, or an operational step `rfq` never needed. (Re-verified against the local +RFQ and UniswapX code on 2026-07-20.) **Reused as-is:** -- `internal/liquidlanemath/` — the LiquidLane fixed-point rate math (`AmountOutForRate`, - `MaxAmountInForRate`, `MinAmountInForAmountOut`, `RateForAmountOut`, `RATE_SCALE` 1e18), verbatim. +- `internal/liquidlane/` — shared LiquidLane route/capacity types, fixed-point math, readers, gas helpers, + and signed-discount client/types. - `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()`. +- A thin UniswapX reader composes those shared readers for startup route resolution, authorization, + inventory/rate snapshots, optional gas snapshots, and fill-time quotes (§2.1). +- Solver scaffolding patterns: `init()` registration + factory, solver-local strategy selection through + `strategy: {name, config}`, bounded quote server, poll loop, and calldata-only submission through the + shared `txmanager`. **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 | +| 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 direct adapters plus internal advertised discount routes. Price from the background-refreshed snapshot (≤500ms) — §2.1 | +| 2 | Quote wire contract | Backend schema, `x-rfq-shared-secret`, 204 decline, 422 on schema violation | UniswapX quote schema, **`204` decline**, `requestId` echo, independent opposing-probe handling; published source IPs are enforced at ingress, not through an invented application header — §4.1/§10.1 | +| 3 | Quoted price policy | Quotes the raw oracle `getAmountOut` (no margin) | UniswapX-local strategy applies the configured price buffer and optional gas-aware floor; below an enabled floor ⇒ decline — §2.1, §5 | +| 4 | `EXACT_OUTPUT` | Hard-rejected at validation | UniswapX prices the concrete requested output with current capacity and optional gas, returning the required input — §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 | +| 6 | Pre-fill validation | Order-deadline + strategy↔order binding checks | Those **plus**: cosignature recovers to the swapper-authorized per-order `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 | `LiquidLaneUniswapXExecutor.execute(SignedOrder, FillCall)` → Uniswap reactor `executeWithCallback` → callback routes through direct `swap` or signed `discountSwap`; native output is declined in v1 — §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 | +| 9 | Private discounts | Offer discovery, fill-time signed resolution, and `discountSwap` calldata | Reuse shared discovery, route matching, candidate construction, and fresh-term validation; keep resolve timing and executor calldata inside UniswapX; implemented across P3/P5 | **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 +- On-chain: land and deploy `LiquidLaneUniswapXExecutor`, configure the tx-sending EOA as a caller, and 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` +- Vendoring: `LiquidLaneUniswapXExecutor` ABI → `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, +Collected and source-verified during planning; **re-verified 2026-07-20** 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. @@ -283,8 +282,7 @@ program-critical, not just polite. - 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 +- The chain matrix keeps expanding — **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) @@ -293,8 +291,7 @@ program-critical, not just polite. |---|---| | V2 Dutch Order Reactor | `0x00000011F84B9aa48e5f8aA8B9897600006289Be` | | V3 Dutch Order Reactor | `0x0000000015757c461808EA25Eb309638B62681cf` | -| ExclusiveDutchOrderReactor (V1) | `0x6000da47483062A0D734Ba3dc7576Ce6A0B645C4` | -| OrderQuoter | `0x54539967a06Fc0E3C3ED0ee320Eb67362D13C5fF` *(docs report several variants — verify per chain)* | +| OrderQuoter | `0xc6ef4C96Ee89e48Eff1C35545DBEED4Ad8dAC9D4` | | Permit2 (all chains except zkSync Era — out of scope) | `0x000000000022D473030F116dDEE9F6B43aC78BA3` | | Arbitrum V3 Reactor | `0xB274d5F4b833b61B340b654d600A864fB604a87c` | | Base DutchV3 Reactor | `0x000000008a8330B5d1F43A62Bf4C673A49f27ba0` | @@ -308,13 +305,12 @@ on these chains, so the quote/order-delivery half can't be driven by Uniswap the | 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 +(Permit2 canonical on all incl. Sepolia). The public `uniswapx-tool` supports production-chain quote/order +flows; `Env.Beta` and `Env.Prod` both use the normal gateway with an environment flag. Testnets are reachable only for direct on-chain settlement. ### 3.3 V2 Dutch order struct & cosignature @@ -324,7 +320,7 @@ SignedOrder { bytes order; bytes sig } // order = ABI-encoded V2DutchOrder; V2DutchOrder { OrderInfo{ reactor, swapper, nonce, deadline, additionalValidationContract, additionalValidationData } - address cosigner // per-order field (Uniswap Labs in prod); no reactor ctor/setter + address cosigner // swapper-authorized per-order field; no global config/setter DutchInput baseInput // token, startAmount, endAmount DutchOutput[] baseOutputs // token, startAmount, endAmount, recipient CosignerData{ decayStartTime, decayEndTime, exclusiveFiller, exclusivityOverrideBps, inputAmount, outputAmounts[] } @@ -341,6 +337,10 @@ Digest = `keccak256(orderHash ‖ abi.encode(cosignerData))`, signed **raw** (no `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). +The solver does **not** pin a cosigner address in YAML. The swapper's Permit2 witness signature commits to +`order.cosigner`, and the Reactor requires `cosignature` to recover to that address. Keeping a second static +allowlist in solver config would add no protocol validation and would fail closed on legitimate key rotation. + ### 3.4 Settlement interfaces (Uniswap's reactor) ```solidity @@ -363,23 +363,23 @@ 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`. +- **Method/timeout:** `axios.post`, `application/json`; the current public requirement is **500ms on + Ethereum** and **250ms on other chains**. This implementation targets Ethereum and configures a 450ms + server timeout. - **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. +- **Auth:** the public quote schema specifies no signed/header scheme. The FAQ publishes source IPs to + allowlist (Beta `3.135.148.114`, Prod `3.138.88.28`); confirm any additional shared header during + onboarding. Until then, authenticate at the ingress by source IP rather than inventing a required header. +- **Decline:** empty **`204 No Content`**, as required by the current Become a Quoter guide and FAQ. Never + use `404`, which is an error rather than a normal non-quote. - **Response must echo the (obfuscated) `requestId` received** or it's dropped (`RFQ_FAIL_REQUEST_MATCH`). +- **Breaker notification:** the same endpoint receives `{blockUntilTimestamp}` without a normal quote + `requestId`; a trusted notification updates the quote breaker and zero clears it. **Request body** (`PostQuoteRequestBodyJoi`, `QuoteRequest.toCleanJSON()`): ```jsonc @@ -393,18 +393,25 @@ and `uniswapx-service` (order pool, Joi + an OpenAPI `swagger.json`). "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 + "protocol": "v1" | "v2", // protocol version, not an indicative/hard phase signal + "quoteId": string // generated per wire request by Uniswap's WebhookQuoter } ``` +The inbound `PostQuoteRequestBodyJoi` used before fan-out omits `quoteId`, but `WebhookQuoter` assigns a +separate UUID to the real and opposing clean requests before posting them to quoter endpoints. The public +Become a Quoter guide documents the same wire field. The solver requires and echoes it for protocol +conformance and correlation, but does not treat it as a capacity lock. A captured Beta payload is still +required to verify the complete operational envelope and auth, not to resolve whether `quoteId` is sent. +The quote-time `swapper` may be the zero address, so quote admission must not require the final swapper. + **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 + "tokenOut": string, "amountOut": string, + "filler": string, // our LiquidLaneUniswapXExecutor address "quoteId": string } ``` @@ -413,33 +420,47 @@ and `uniswapx-service` (order pool, Joi + an OpenAPI `swagger.json`). **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. +"fillers should start with polling for orders and rate limit at 6 RPS". The implementation is poll-only; +no push abstraction is carried before a second real delivery source exists. -**POLL — `GET https://api.uniswap.org/v2/orders`** (mainnet; Beta base `https://beta.api.uniswap.org/v2`), -**≤6 RPS**: +**POLL — `GET https://api.uniswap.org/v2/orders`** (mainnet), **≤6 RPS**. The exact Beta polling +transport remains an onboarding confirmation item (§10.4): - 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. +- Response: `{ orders: OrderEntity[], cursor? }`; under upstream spec version 2.0.0, a Dutch V2 variant is + the typed `DutchV2OrderEntity` with `encodedOrder`, `signature`, nested + `cosignerData{decayStartTime, decayEndTime, exclusiveFiller, inputOverride, outputOverrides[]}`, + `cosignature`, `createdAt`, `input`, `outputs[]`, `orderHash`, `chainId`, `swapper`, optional `txHash`, + `quoteId`, and `requestId`. The `encodedOrder` + swapper `signature` *is* our + `SignedOrder{order, sig}` — directly fillable. + +**Ingestion design:** poll at 500–1000ms, inside the 6 RPS budget. Exclusive V2 then public V2 are fetched +independently under one limiter; each source has bounded pagination. Dedup by `orderHash`. Both sources use +the configured V2 Reactor/Executor pair and `GET /orders`; there is no legacy `/limit-orders` runtime path. +After each successful exclusive poll, tracked obligations past `decayStartTime` are reconciled by hash in +bounded batches through the same endpoint. Only a canonical successful fill whose block time is at or before +the deadline discharges the obligation. This includes another filler's soft override. A later fill by any +filler, including our executor, or any final non-filled state is a local fade. An order that is still `open`, +missing/unknown API data, or an unreadable receipt makes exclusive state unknown and blocks quotes without +opening the breaker; the next successful reconciliation retries it. + +**Hard-quote phase** is run and cosigned by Uniswap; **we do not host it**. The same webhook receives both +indicative and hard RFQs, and the quoter cannot distinguish them. A fresh `quoteId` is generated for each +RFQ call, so reserving every response would make the first indicative round consume capacity and can cause +the later hard round for the same user intent to self-decline. The simple quoter is therefore stateless: +each request is priced from the latest snapshot, and only a fill transaction accepted for submission creates +a pending capacity reservation. The finalized cosigned order is authoritative; it is decoded, signature- +checked, repriced from current chain state, and simulated immediately before submission. ### 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.) +- **Poll client:** vendor upstream `uniswapx-service/swagger.json` at spec version 2.0.0 and generate + `api/uniswapxservice` with the pinned Java openapi-generator. `DutchV2OrderEntity` and its nested fields + come directly from that vendored contract. Normalization only supplies generator metadata; it does not + invent response fields or patch generated Go. +- **Hand-vendored structs (no OpenAPI):** the quote webhook request/response is transcribed from the public + guide plus the source `WebhookQuoter` wire construction into solver-local structs and decoded by a bounded + strict-JSON handler. Replay a captured Beta payload before go-live to detect operational drift. - **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). @@ -449,25 +470,29 @@ cosigner sets `exclusiveFiller` to the `filler` we returned (with a nonzero defa 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 +1. Map the request → internal; **decline (`204`) fast** on: unsupported protocol/type, wrong/`!=` chainId, unfillable + direction (§1: `tokenIn` must be redeemable on an in-scope direct or signed-discount route *and* + `tokenOut` that adapter's vault asset; native-ETH `tokenOut` is declined in v1 — + this rule also auto-declines the opposing probe), or no viable inventory. +2. Read the atomically published direct LiquidLane inventory/rate snapshot, its optional gas snapshot, and its valid advertised + signed-discount candidates. Direct and private candidates share physical-route capacity. +3. The UniswapX-local `Strategy.DecideQuote` selects a provisional route only to calculate the concrete + request's executable output and, when gas accounting is configured, full estimated fill gas. It returns + one `amountIn`/`amountOut` pair; below the enabled gas-aware floor or outside current capacity ⇒ decline. +4. Exact input returns the net output after price buffer and optional gas. Exact output uses the same greedy route + selection in output units, adds buffer and gas, and converts the selected output legs directly to input + with upward rounding. It neither binary-searches input nor enumerates route combinations; any produced + output above the signed requirement remains executor surplus. No ladder, amount range, allocation, or + quote-time route is published or retained. +5. Before publishing the result, recheck the snapshot pointer, quote epoch, and every blocking condition. + Any fill reservation, breaker, exclusive-state change, or snapshot replacement during strategy execution + turns the result into a decline. +6. Echo `requestId` and `quoteId`, and return `200` with `amountIn`, `amountOut`, and `filler` = + `LiquidLaneUniswapXExecutor`. Do not mutate capacity on this path. + +Same-token multi-output orders are priced by their total output and settled with one aggregate approval; +the Reactor distributes that token among recipients. Mixed-token outputs are declined during parsing and +rejected by the executor. 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. @@ -480,78 +505,121 @@ Uniswap penalizes **win-but-don't-fill** ("fade"): a temporary disable starting 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. +- **Fail-closed pre-fill gates** (before spending gas): cosignature recovers to the swapper-authorized + per-order `cosigner`; `cosignerData.exclusiveFiller == our executor` (we actually won); order + deadline/decay window still fillable; current strategy economics; and a final `eth_call` simulation + against the current block. Any failure ⇒ skip, no tx. +- **Quote from bounded current capacity** — the latest inventory snapshot, optional gas floor, and reservations of + already-submitted fills. Quote requests themselves stay stateless because their phase is unknowable. This + means simultaneous winning hard quotes can contend; current-chain replanning and simulation fail closed, + while the cold-start window and fade breakers limit the operational risk. +- **Invalidate quotes across state transitions** — a request may return only against the same snapshot epoch + and blocker state it started with. A completed fill invalidates the snapshot before releasing its + reservation, and the released capacity remains unavailable until a post-fill chain refresh publishes the + next epoch. +- **Local breaker** halts quoting after repeated public-order preflight/submission failures; exclusive + attempts are classified only by their tracked terminal reconciliation. Successful settlement resets it. +- **Honor trusted `blockUntilTimestamp` notifications** from Uniswap and expose the block/readiness state; + readiness also fails when the latest published snapshot has no quotable inventory, while health remains + liveness-only. +- **Track exclusive obligations locally:** every valid order assigned to our executor is tracked until + `decayStartTime`, then reconciled in batches against terminal order state and the canonical fill receipt. + Only a successful on-chain fill at or before the deadline clears the obligation; this makes another + filler's timely soft override non-fade. A fill only after the deadline—including one mined through our + executor—or a final non-filled state opens an independent local fade breaker: Uniswap still counts the + original quoter as faded once exclusivity expires unfilled. Consequently, an unrelated or late successful + fill cannot clear this timed breaker. An `open` order, unknown status, or unknown receipt time invalidates + quotes and retries reconciliation without guessing fade. The trusted + `blockUntilTimestamp` remains the authoritative external penalty window. --- -## 7. On-chain settlement contract — `UniswapXExecutor.sol` +## 7. On-chain settlement contract — `LiquidLaneUniswapXExecutor.sol` -New contract in the sibling `rfq` repo (`src/uniswapx/`), UniswapX-specific (no multi-venue abstraction), -mirroring our existing `Executor.sol` role-gating: +The contract belongs in the canonical RFQ contracts repository. It is UniswapX-specific (no multi-venue +abstraction): - `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 +- **Native-ETH output is unsupported in v1.** LiquidLane routes settle an ERC-20 vault asset, so the quote + server and order validation currently reject zero-address output. +- `execute(SignedOrder, FillCall)` — caller-gated entrypoint matching the RFQ executor's owner-managed + `setCallers` model; the executor contract is a transparent proxy, while the Reactor-facing filler address + remains stable and the implementation's Reactor address is immutable. +- `FillCall.routes[]` carries only adapter, input amount, and required output. Typed `discountRoutes[]` + carry adapter, input amount, and the signed discount/protocol terms; they intentionally have no duplicate + `minAmountOut` field. The caller selects adapters, and the executor carries no second on-chain adapter + allowlist. Any positive difference between resolved and routed input remains in the executor as filler + surplus. This makes calldata planned in one block safe when an exact-output V2 Dutch input increases before + execution, without timestamp prediction or waiting for `decayEndTime`. Direct adapters enforce their + requested output, discount adapters enforce signed terms, and the Reactor atomically enforces aggregate + order outputs. Pricing, capacity, and gas policy remain off-chain in the strategy. +- Owner-managed caller list and no sweep entrypoint. The published ABI exposes indexed `callers(uint256)` + reads, but no caller-membership helper or Reactor getter. Startup scans those indexed entries with a safety + bound and fails unless it finds the tx-sending EOA; it also validates executor bytecode. External mode + additionally validates configured adapter authorization; internal mode filters unauthorized direct routes + from each snapshot. Deployment must still bind the implementation to the expected Reactor out of band + because that immutable cannot be read through the published ABI. +- ABI vendored → `api/bindings/uniswapx/`; executor calldata packed via abigen `--v2` (never `abi.Pack("...")`). -- **Forge fork integration test** mirrors UniswapX's own `test/integration/*.t.sol` (self-cosign loop, §8). +- Contract coverage includes mock-Reactor Forge tests. The integration harness also carries a captured-order + mainnet-Reactor replay; a self-cosigned canonical-Reactor test remains P6. + +**Optional follow-up — native output (not a launch blocker).** Canonical UniswapX Reactors accept native +output from a callback executor (see Uniswap's +[`SwapRouter02Executor`](https://github.com/Uniswap/UniswapX/blob/main/src/sample-executors/SwapRouter02Executor.sol)). +Supporting it later does not require native vaults or adapters: for an order +whose output token is the native sentinel, map the route to a configured WETH vault asset, receive WETH from +`LiquidLaneAdapter`, verify the WETH balance delta, unwrap exactly the required output, and forward that ETH +to the immutable Reactor. The executor already accepts ETH and forwards callback ETH to the Reactor; the +remaining work is explicit WETH configuration, exact-amount unwrap logic, and quote/order/fork coverage. +Never unwrap the executor's full standing WETH balance. --- ## 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. +The public quoter onboarding flow is a **mainnet/Beta** program. The public `uniswapx-tool` currently exposes +production chain profiles; separately, the SDK maps a deployed **Sepolia Dutch V2 reactor** +(`0x0e22B6…BEBcd`) + canonical Permit2. Therefore settlement can be exercised on Sepolia or a mainnet fork, +while the quote-request half stays synthetic until we capture real Beta traffic. Confirm continued Sepolia +support and the exact Beta order transport during onboarding (§10.1). **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. +harness + a small local mock POSTs schema-faithful requests (incl. the opposing probe with its distinct +obfuscated `requestId`, §4.1) at our webhook — asserting pricing, empty-`204` decline, successful `200` +response shape, and `requestId` echo. The public guide's `quoteId` differs from the public Joi schema, so a +captured Beta payload remains required before calling the fixture bit-for-bit faithful (§4.1, §10.1). **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). +`LiquidLaneAdapter` + vault + `LiquidLaneUniswapXExecutor` on Sepolia. 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 +(per-order cosigner) → `LiquidLaneUniswapXExecutor.execute(signedOrder, callbackData)` → assert the 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 — full local loop and stress matrix.** Mock RFQ server → our webhook → self-cosign → fork fill, +end-to-end in one harness. The integration runner separately gates protocol E2E, quote conformance, +concurrent quote/fill load, quote-capacity backpressure, forced signed-discount-only fills, a bounded soak, +and restart/backend/RPC recovery. The fill burst asserts one successful transaction per order across equal +exclusive and public V2 waves; the resilience case keeps an order open across a solver restart +and requires exactly one Reactor `Fill` event. 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 +nonzero `exclusivityOverrideBps`) are permissionlessly fillable, so once `LiquidLaneUniswapXExecutor` 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 +(`UNISWAP_API_KEY`), drive orders with the public **`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. @@ -564,30 +632,42 @@ promotion. Do Layers 1–3 exhaustively first; use **minimum-size orders** in Be ## 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). +Each phase is a reviewable increment. A cross-repository phase is complete only after its changes are landed +in the owning repository and the integration harness pins the resulting revision. + +- [x] **P0 — Scaffold + codegen.** Vendor UniswapX V2 reactor + Permit2 ABIs → `api/bindings/uniswapx/`; + vendor `uniswapx-service/swagger.json` spec version 2.0.0 → generated typed poll client; scaffold the + `uniswapx` package + `init()` register + blank-import from `main`. CGO-free build holds. +- [x] **P1 — UniswapX-local strategy layer.** Local contract + registry + `default`/`webhook`, background + chain and optional gas snapshot, request-scoped exact-input/output pricing, and independent fill decision tests are present. `DiscountID` flows + through strategy plans and the solver resolves fresh signed terms before execution. RFQ and UniswapX + default quoting reuse `QuoteTask`; RFQ, LI.FI, and UniswapX default filling reuse `FillTask` while + keeping their strategy contracts, protocol mapping, and lifecycle separate. +- [x] **P2 — V2 order codec.** V2 Dutch serialize/parse and Permit2 witness/cosignature validation have + golden/parity coverage. Legacy V1 limit orders are deliberately unsupported; no premature V3 abstraction. +- [ ] **P3 — `LiquidLaneUniswapXExecutor.sol`.** Land the contract in the canonical RFQ repository with typed + direct + signed-discount routes, actual balance-delta enforcement, same-token multi-output settlement, and + mock-Reactor coverage. Regenerate and vendor the ABI here, pin the landed RFQ revision in `rfq-integration`, + and add a canonical-Reactor self-cosign test. Native output remains an optional post-v1 extension (§7). +- [ ] **P4 — Quote webhook completion.** The bounded stateless server, local strategy pricing, health, + readiness, metrics, phase-agnostic `v1`/`v2` and zero-swapper handling, empty-`204` decline, ingress-owned + source-IP authentication, native-output decline, and request tests exist. Replay a captured Beta payload + before marking the phase complete. +- [x] **P5 — Ingestion + execution completion.** Authenticated bounded polling, validation, preflight, + async txmanager submission, receipts, pending-fill reservations, breaker, retries, and signed-discount + discovery/resolution/calldata exist. Txmanager's configured confirmations are honored; released capacity + stays unavailable until a post-fill snapshot. Exclusive obligations are tracked through `decayStartTime` + and batch-reconciled before either clearing them or opening the independent local fade breaker. +- [ ] **P6 — Packaging + E2E.** The isolated local stack now passes quote → order → on-chain fill for + exclusive V2 exact-input/exact-output/same-token multi-output, decaying public V2, public V2 with an + exclusivity override, and a forced signed-discount-only route; the smoke test decodes executor calldata + and verifies direct/private route + selection. The local matrix also covers 240-request quote bursts, replay/collision/body/auth conformance, + three concurrent fill waves, forced-discount concurrency, phase-agnostic stateless quote pressure, soak, solver + restart, and temporary discount-backend/RPC outages. The integration harness contains a captured-order + replay against the canonical mainnet Reactor. Remaining: land and pin the executor, add the self-cosigned + canonical-Reactor case, then complete the Beta five-fill qualification and record the transaction hashes + (§10). --- @@ -596,60 +676,66 @@ Each phase is a reviewable increment; all are committed scope. 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. +- [ ] **Testnet posture:** the SDK maps a Sepolia Dutch V2 reactor (`0x0e22B6…BEBcd`, §3.2), while public + quoter onboarding targets mainnet/Beta. Confirm whether Sepolia settlement remains supported and + whether any testnet quote/order service exists. - [ ] **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] **Decline status code:** empty HTTP `204`, per the current Become a Quoter guide. - [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). +- [ ] **Live chain matrix** for RFQ quoting and the order versions enabled for our onboarding account. - [ ] **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**. +- [x] **Published limits:** quote response ≤500ms on Ethereum and ≤250ms on other chains; quote traffic is + expected at about 1 RPS on Ethereum; order polling is capped at 6 RPS. +- [x] **`uniswapx-tool` source access:** the repository is public. Any separate reference quoter or private + onboarding artifact still needs to be requested explicitly. - [ ] **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. +- [x] **Order-service spec** — upstream GitHub `swagger.json` version 2.0.0 is vendored and generates the + current typed Dutch V2 response. Confirm separately 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**. +- [ ] Generate `UNISWAP_API_KEY` at developers.uniswap.org; provision the CLI submit key (`UNISWAP_PRIVATE_KEY` + for `uniswapx-tool` only — distinct from our tx-sending EOA). +- [ ] Install and validate the public `Uniswap/uniswapx-tool` for Beta qualification. +- [ ] Hand Uniswap our **quote-server URL** + **filler (`LiquidLaneUniswapXExecutor`) 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. +- [ ] Land, review, and deploy `LiquidLaneUniswapXExecutor.sol` (mainnet). +- [ ] Deploy it with the V2 Reactor, owner, and initial caller addresses; fund the tx-sending caller EOA + with ETH for gas (the prototype Reactor address is immutable, not owner-set). +- [ ] Configure the production signed-discount offer source. Local discovery, fill-time term resolution, + calldata, and accounting pass end-to-end; keep production discount quoting disabled until the + executor and backend configuration are deployed and revalidated together, then switch the deployment + to `solverMode: internal`. - [ ] Confirm the `LiquidLaneAdapter`(s) we'll source from authorize our executor as filler - (`isFiller`/`marketMaker` — the adapter validates the swap *actor*, not the caller). + (`isFiller`/`marketMaker` — the adapter validates the swap *actor*, not the caller) for direct routes. + Signed-discount-only routes use the adapter's signed authorization and are filtered independently. - [ ] 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. + §10.1) and confirm the exact Beta order endpoint/auth with Uniswap before changing the poller 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. +- [ ] On promotion: switch to the confirmed production order environment; widen order sizes per risk. ### 10.5 Deferred (post-v1) -- [ ] V3 order codec + reactor target (Tempo + L2s) behind the same `OrderCodec` interface. +- [ ] V3 order codec + reactor target where required; introduce a shared codec interface only when the + second implementation proves it useful. - [ ] 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. +- [ ] Self-funding loops (keep solver-gas / pay-bid pots fed from profit) if needed. --- @@ -659,9 +745,10 @@ Tracked operational and onboarding steps — **update as items start/finish/drop - 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 +- Become a Quoter — https://developers.uniswap.org/docs/liquidity/uniswapx/filling/mainnet/become-a-quoter +- Filling on Mainnet / Filler overview — https://developers.uniswap.org/docs/liquidity/uniswapx/filling/mainnet/filling-on-mainnet +- Filler FAQ — https://developers.uniswap.org/docs/liquidity/uniswapx/filling/faq +- Deployments — https://developers.uniswap.org/docs/liquidity/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 @@ -681,30 +768,35 @@ Tracked operational and onboarding steps — **update as items start/finish/drop `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 + https://developers.uniswap.org/docs/liquidity/uniswapx/filling/faq - 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` + (spec version 2.0.0 in an OpenAPI 3.0.0 document, base `https://api.uniswap.org/v2`, paths `/orders` and + `/limit-orders`; this solver only polls `/orders`) ### 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**. +- The public CLI sends Beta/Prod trading commands to `https://trade-api.gateway.uniswap.org` with an + environment flag. The exact Beta order-poll endpoint and auth for a filler are onboarding facts still to + confirm; do not infer them from the CLI trading gateway. -### Internal (this monorepo) +### Internal repositories - 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) + LiquidLane allocator + `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/`) +- The RFQ contracts repository owns on-chain adapters and the UniswapX executor. `rfq-integration` pins the + landed RFQ contracts revision. This solver repository contains only the generated/vendored executor ABI + binding needed to build calldata. ### 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). +- Quote SLA: **≤500ms on Ethereum, ≤250ms on other chains**; decline with an empty HTTP `204` (§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 + `tokenIn`/`tokenOut`; we **decline** (empty `204`) anything we can't price. Our fillable universe = + pairs where `tokenIn` is redeemable through a configured direct route or a valid advertised signed-discount + route **and** `tokenOut` is that adapter's ERC-20 vault asset. Native-ETH output is currently declined and + remains an optional post-v1 extension (§7); the universe remains narrow by construction until the secondary-DEX hop. diff --git a/docs/strategy-plan.md b/docs/strategy-plan.md index 73a94329..1acef5ba 100644 --- a/docs/strategy-plan.md +++ b/docs/strategy-plan.md @@ -9,33 +9,37 @@ that solver's own plan under `docs/` and in its `strategies/types` package — n 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. + chain/API state, validating untrusted protocol data, and normalizing it into the shared domain types + the strategy consumes, 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 +solver maps external state to typed facts → strategy decides → solver executes the output ``` -**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. +**The strategy is trusted for economic decisions, and it is the core of the solver.** The solver does +not re-price, clamp, re-rank, or replace the strategy's allocation. Pricing, sizing, ranking, and route +selection live *inside* the strategy implementation. Before moving funds, the solver still verifies +execution integrity against the fresh snapshot it supplied: route identity, token pair, exact input +coverage, achievable output, shared capacity, gas floor, and protocol timing. Protocol parsing, +token/address admission, freshness reads, replay, caching, and recovery also stay in the solver +skeleton. This keeps the boundary crisp: swapping in a different strategy (including an external one) +never adds economic decision logic to the solver, while malformed or stale calldata cannot cross the +execution boundary. 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 solver provides **typed facts, not decisions**. Protocol DTOs and contract return values are + converted once, at the solver boundary, into canonical entities such as `liquidlane.Route`, + `Inventory`, `QuoteCandidate`, and `FillQuote`. It does not rank routes or choose an allocation. +- The strategy returns a **complete economic plan**. The solver canonicalizes it against the supplied + snapshot and rejects inconsistencies; it never changes which routes won or their economics. The + solver also owns transaction-only values such as nonce, signature, and EIP-712 domain, which the + strategy can never supply. ## The contract @@ -43,7 +47,7 @@ Each solver defines its own decision interface — one method per decision point `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. +input of validated domain facts in, a strategy-decided output out. For direction, the 3F solver's interface looks like this: @@ -60,6 +64,18 @@ exposes a quote decision and a fill decision; a bidding solver a single bid deci 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. +Solvers that use LiquidLane liquidity also follow +[`LIQUIDLANE-CONVENTIONS.md`](LIQUIDLANE-CONVENTIONS.md): shared LiquidLane packages define +read-side facts (`Route`, `Inventory`, `QuoteCandidate`, `FillQuote`, authorization, ids, freshness). The shared snapshot +reader composes direct and physical state plus gas facts for LI.FI and UniswapX. RFQ-like exact-input +paths can normalize amount-independent inventory against current per-route oracle quotes. The RFQ solver +performs that protocol-to-LiquidLane normalization before calling its strategy; UniswapX and LI.FI already +enter their strategies as typed LiquidLane inventory. Their default strategies build the same `QuoteTask`; RFQ, LI.FI, and +UniswapX normalize fresh execution facts into the same `FillTask`. The shared engine owns LiquidLane +route selection, capacity, input coverage, buffer, minimum-output, and gas calculations. Each solver +still owns candidate discovery, protocol input/output mapping, lifecycle, strategy interface, calldata, +and the fixed gas envelope around its protocol-specific executor call. + ## Selection and configuration Strategy selection is solver-local: the generic framework does not parse, validate, or route strategy @@ -83,7 +99,7 @@ type StrategySpec struct { Config yaml.Node } -type StrategyFactory func(raw yaml.Node, deps StrategyDeps) (types.Strategy, error) +type StrategyFactory func(raw yaml.Node) (types.Strategy, error) ``` Each solver keeps a local registry/factory. A strategy self-registers from its own package `init()` @@ -99,11 +115,13 @@ Two strategy kinds are conventional across solvers: 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. + in-process handler is transport-only and adds no economic decision logic 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. +Both plug into the same decision boundary: the solver validates and executes their output the same +way, so a solver is never coupled to which strategy is loaded. RFQ and LI.FI share `internal/tokenpolicy` +for `tokensToQuote` admission. Both mark admitted inputs as single-route only in `permissioned` scope +and reject strategy output that aggregates routes; route selection and economics remain strategy-owned. ## Adding your own strategy @@ -111,7 +129,8 @@ 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 +the solver runs it subject to the same solver-owned structural and safety constraints as an in-tree +strategy. 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 @@ -120,7 +139,7 @@ that solver's interface (each is unique — you implement the one the target sol 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 +2. Add a `NewFromConfig(raw yaml.Node) (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. @@ -131,9 +150,52 @@ that solver's interface (each is unique — you implement the one the target sol 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. +LiquidLane quote/fill strategies intentionally receive no chain client or logger through their registry: +all current reads are represented in the typed input. A different workflow may define explicit +strategy-owned dependencies only when the strategy itself genuinely owns that I/O. + +## Shared LiquidLane strategy: `internal/liquidlane/strategies/greedy` + +The current shared LiquidLane algorithm is explicitly named `greedy`. Adding another algorithm means a +sibling package under `internal/liquidlane/strategies`; it does not require a second runtime registry or +solver config knob until a real deployment needs selectable behavior. Sharing this pure decision engine +does not create a cross-solver `Strategy` facade. `QuoteTask` accepts +normalized, already-priced candidates, an exact input or output, route limit, buffer, an optional gas +pricing model, and an explicit input-coverage rule. `SolveQuote` owns deterministic ranking, direct/private +alternative selection, route splitting, gas deduction, and fixed-point sizing. Exact input uses the RFQ-style +forward allocator. Exact output uses the same one-pass greedy route selection in output units, adds buffer +and gas, and converts each selected output leg directly to input with upward rounding. It neither binary +searches input nor enumerates route combinations; harmless excess output is executor surplus. RFQ +supplies the price-impact coverage rule without gas pricing; UniswapX supplies strict coverage plus its +buffer and gas pricing. + +LI.FI adapts the same exact-input task to its standing range wire format. It solves each geometric range at +both endpoints, then caps that endpoint price with a linear conservative floor over route alternatives, +worst-case complete-plan gas, and rounding. This keeps every interior amount executable without binary +search or route-combination enumeration. + +For fills, RFQ, LI.FI, and UniswapX pass current amount-specific `FillQuote`s to `SolveFill`. `FillTask` +also carries pending `CapacityID` reservations, freshness, route limit, buffer, input coverage, and an +optional gas pricing model. The engine selects routes, enforces shared capacity, charges complete-plan +gas once when that model is present, and returns `FillSolution`, exposing `MaxAmountOut` followed by +`Finalize(requiredAmountOut)`. RFQ maps it to Executor legs without introducing RFQ gas config; LI.FI +resolves OIF `OutputContext`/`FillAfter`; UniswapX resolves signed-order output/deadline. LI.FI and +UniswapX build the gas model from their existing runtime facts. +The canonical `FillRoute` and webhook fill validator are shared one level above `greedy`; the solver-owned +pending-capacity ledger lives in `internal/liquidlane`. Allocation policy remains replaceable, while local and remote strategies use the same +route identity, capacity, amount, and gas-floor invariants. +Public strategy interfaces, webhook DTOs, caches, protocol lifecycle, and calldata remain solver-local. + +The adjacent `internal/liquidlane/discounts` package owns the discount rules shared by RFQ, LI.FI, and +UniswapX: parse and filter live offers, bind offers to physical routes, cap advertised rate/capacity, +derive amount-specific candidates, and revalidate resolved id/adapter/token/deadlines plus the current +output floor. Resolution timing is deliberately not hidden behind a common strategy facade: LI.FI +pre-resolves and refreshes state, while UniswapX and RFQ resolve selected routes. Each solver maps the +validated `discounts.Signed` into its own generated executor binding. + ## Shared transport: `internal/webhook` -The only shared strategy-adjacent package is `internal/webhook`, a generic HTTP JSON client: +`internal/webhook` is a generic HTTP JSON client: - HTTP JSON `POST`, configurable timeout, request/response body byte caps (default 1 MiB each) - literal or env-backed headers (parsed config retains only the env-var name; `NewClient` resolves it) diff --git a/go.mod b/go.mod index cb08dff5..105e3880 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 go.uber.org/zap v1.28.0 - golang.org/x/sync v0.21.0 + golang.org/x/sync v0.22.0 gopkg.in/validator.v2 v2.0.1 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 80c7103f..e0cdf3a2 100644 --- a/go.sum +++ b/go.sum @@ -241,8 +241,8 @@ golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOe golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= 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/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.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= diff --git a/hack/lifi-openapi-normalize.py b/hack/lifi-openapi-normalize.py deleted file mode 100644 index 50e09018..00000000 --- a/hack/lifi-openapi-normalize.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env python3 -# Normalize the LI.FI order-server OpenAPI spec so the Java openapi-generator emits compiling Go. -# -# The vendored openapi/lifi-order.openapi.json is the RAW contract of record (pulled verbatim from the -# order server's Scalar /docs page) and MUST stay unedited. Two upstream defects in that raw spec make the -# generated Go uncompilable, so `make refresh-lifi-client` pipes the raw spec through this shim first. This -# reads the raw spec on stdin and writes the normalized spec to stdout, applying ONLY these two -# deterministic fixes: -# -# (a) Dangling oneOf $refs. QuoteDto.order is `oneOf: [Oif3009OrderDto, OifEscrowOrderDto, -# OifUserOpenIntentOrderDto]`, but none of those three schemas are defined in components.schemas -# (upstream forgot to register the NestJS DTOs — likely a missing @ApiExtraModels). The generator -# then emits a oneOf wrapper referencing three undefined Go types. Any property/schema whose value is -# a `$ref` (or a oneOf/anyOf/allOf of $refs) pointing at a missing schema is replaced with a -# permissive `{"type": "object", "additionalProperties": true}` passthrough — the solver flow does -# not need the nested order type inside the quote object. -# -# (b) Multi-tag operations. `/quote/request` is tagged ["Quotes","Bridge API"] and `/quotes/submit` -# ["Quotes","Solver API"]. openapi-generator emits each operation's request struct into EVERY tag's -# api_.go file, so a multi-tagged operation yields duplicate package-level types. Each -# operation's `tags` is collapsed to a single entry: prefer a tag ending in "API" (the concrete -# surface, e.g. "Bridge API"), else the first tag. -import json -import sys - - -def _is_ref_to_missing(node: dict, defined: set) -> bool: - ref = node.get("$ref") - return isinstance(ref, str) and ref.startswith("#/components/schemas/") and ref.rsplit("/", 1)[-1] not in defined - - -def _has_dangling_ref(node, defined: set) -> bool: - # A schema node is "dangling" if it is (or is composed via oneOf/anyOf/allOf of) a $ref whose target - # is not defined in components.schemas. - if not isinstance(node, dict): - return False - if _is_ref_to_missing(node, defined): - return True - for kw in ("oneOf", "anyOf", "allOf"): - members = node.get(kw) - if isinstance(members, list) and any(_is_ref_to_missing(m, defined) for m in members if isinstance(m, dict)): - return True - return False - - -PASSTHROUGH = {"type": "object", "additionalProperties": True} - - -def _fix_dangling(node, defined: set): - # Recursively replace any schema node that points at a missing $ref with a permissive passthrough, - # preserving the node's own description/example if present. - if isinstance(node, list): - return [_fix_dangling(v, defined) for v in node] - if not isinstance(node, dict): - return node - if _has_dangling_ref(node, defined): - out = dict(PASSTHROUGH) - for keep in ("description", "example", "title"): - if keep in node: - out[keep] = node[keep] - return out - return {k: _fix_dangling(v, defined) for k, v in node.items()} - - -def _collapse_tags(spec: dict) -> None: - methods = {"get", "put", "post", "delete", "patch", "options", "head", "trace"} - for path_item in spec.get("paths", {}).values(): - if not isinstance(path_item, dict): - continue - for method, op in path_item.items(): - if method.lower() not in methods or not isinstance(op, dict): - continue - tags = op.get("tags") - if isinstance(tags, list) and len(tags) > 1: - api_tags = [t for t in tags if isinstance(t, str) and t.strip().endswith("API")] - op["tags"] = [api_tags[0] if api_tags else tags[0]] - - -def main() -> None: - spec = json.load(sys.stdin) - defined = set(spec.get("components", {}).get("schemas", {}).keys()) - if "components" in spec and "schemas" in spec["components"]: - spec["components"]["schemas"] = _fix_dangling(spec["components"]["schemas"], defined) - if "paths" in spec: - spec["paths"] = _fix_dangling(spec["paths"], defined) - _collapse_tags(spec) - json.dump(spec, sys.stdout, indent=2, ensure_ascii=False) - sys.stdout.write("\n") - - -if __name__ == "__main__": - main() diff --git a/hack/uniswapx-openapi-normalize.py b/hack/uniswapx-openapi-normalize.py new file mode 100644 index 00000000..51645fb6 --- /dev/null +++ b/hack/uniswapx-openapi-normalize.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Normalize the upstream UniswapX OpenAPI document for local client generation. + +The vendored swagger.json stays byte-for-byte upstream apart from jq formatting. Chain 31337 is added +only to the generated client's enum so the same strict decoder can exercise the local integration stack. +The order response gets a discriminator over its existing type field so openapi-generator can select the +documented oneOf variant without its incompatible validator.v2 fallback. +""" + +import json +import sys + + +document = json.load(sys.stdin) + +chain_id = document["components"]["schemas"]["ChainId"] +if 31337 not in chain_id["enum"]: + chain_id["enum"].append(31337) + +orders = document["components"]["schemas"]["GetOrdersResponse"]["properties"]["orders"]["items"] +orders["discriminator"] = { + "propertyName": "type", + "mapping": { + "Dutch": "#/components/schemas/DutchOrderEntity", + "DutchLimit": "#/components/schemas/DutchOrderEntity", + "Limit": "#/components/schemas/DutchOrderEntity", + "Dutch_V2": "#/components/schemas/DutchV2OrderEntity", + "Dutch_V3": "#/components/schemas/DutchV3OrderEntity", + "Priority": "#/components/schemas/PriorityOrderEntity", + "Hybrid": "#/components/schemas/HybridOrderEntity", + "Relay": "#/components/schemas/RelayOrderEntity", + }, +} + +json.dump(document, sys.stdout, indent=2) +sys.stdout.write("\n") diff --git a/internal/chain/fallback_test.go b/internal/chain/fallback_test.go index 40872930..015b8288 100644 --- a/internal/chain/fallback_test.go +++ b/internal/chain/fallback_test.go @@ -270,6 +270,46 @@ func TestDial_WriteRPCRoutesOnlyBroadcasts(t *testing.T) { } } +func TestMulticallUsesLatestBlockTag(t *testing.T) { + var callParams []json.RawMessage + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &req) + result := `"0x7a69"` + if req.Method == "eth_call" { + callParams = req.Params + // ABI encoding of an empty aggregate3 Result[] return. + result = `"0x0000000000000000000000000000000000000000000000000000000000000020` + + `0000000000000000000000000000000000000000000000000000000000000000"` + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":` + string(req.ID) + `,"result":` + result + `}`)) + })) + defer server.Close() + + const multicall = "0xcA11bde05977b3631167028862bE2a173976CA11" + c, err := Dial(t.Context(), []string{server.URL}, "", multicall, logr.Discard()) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer c.Close() + if _, err = c.Multicall(t.Context(), nil); err != nil { + t.Fatalf("Multicall: %v", err) + } + if len(callParams) != 2 { + t.Fatalf("eth_call params = %s", callParams) + } + var blockTag string + if err = json.Unmarshal(callParams[1], &blockTag); err != nil || blockTag != "latest" { + t.Fatalf("eth_call block tag = %q, err=%v", blockTag, err) + } +} + // TestDial_NoWriteRPCReusesPrimary confirms that with no writeRpcUrl, broadcasts fall back to the // primary endpoint (unchanged behaviour). func TestDial_NoWriteRPCReusesPrimary(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index 998b2c9f..ebbba316 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ package config import ( "bytes" + "math" "os" "github.com/go-errors/errors" @@ -66,10 +67,14 @@ type SignerConfig struct { type TxManagerConfig struct { // Confirmations to wait for before treating a transaction as final. Confirmations uint64 `yaml:"confirmations"` - // MaxFeeGwei caps the EIP-1559 max fee per gas; 0 means "derive from base fee". + // MaxFeeGwei is the required absolute EIP-1559 max fee per gas. MaxFeeGwei float64 `yaml:"maxFeeGwei"` // TipGwei is the EIP-1559 priority fee; 0 means "use the node's suggestion". TipGwei float64 `yaml:"tipGwei"` + // ReplacementIntervalMs is how often a pending transaction is fee-bumped. + ReplacementIntervalMs int `yaml:"replacementIntervalMs"` + // PendingTimeoutMs switches a still-pending call to a same-nonce cancellation. + PendingTimeoutMs int `yaml:"pendingTimeoutMs"` } // SolverConfig names the solver implementation and carries its opaque, deferred config. @@ -82,6 +87,11 @@ type SolverConfig struct { // DefaultConfirmations is used when TxManager.Confirmations is unset. const DefaultConfirmations = 2 +const ( + DefaultReplacementIntervalMs = 30_000 + DefaultPendingTimeoutMs = 300_000 +) + // DefaultObservabilityAddr is used when Observability.Addr is unset. const DefaultObservabilityAddr = ":9090" @@ -121,6 +131,12 @@ func (c *Config) applyDefaults() { if c.TxManager.Confirmations == 0 { c.TxManager.Confirmations = DefaultConfirmations } + if c.TxManager.ReplacementIntervalMs == 0 { + c.TxManager.ReplacementIntervalMs = DefaultReplacementIntervalMs + } + if c.TxManager.PendingTimeoutMs == 0 { + c.TxManager.PendingTimeoutMs = DefaultPendingTimeoutMs + } if c.Observability.Addr == "" { c.Observability.Addr = DefaultObservabilityAddr } @@ -142,6 +158,22 @@ func (c *Config) Validate() error { if c.Chain.ChainID == 0 { return errors.New("chain.chainId is required") } + if c.TxManager.MaxFeeGwei <= 0 || + math.IsNaN(c.TxManager.MaxFeeGwei) || + math.IsInf(c.TxManager.MaxFeeGwei, 0) { + return errors.New("txManager.maxFeeGwei must be finite and positive") + } + if c.TxManager.TipGwei < 0 || + math.IsNaN(c.TxManager.TipGwei) || + math.IsInf(c.TxManager.TipGwei, 0) { + return errors.New("txManager.tipGwei must be finite and non-negative") + } + if c.TxManager.ReplacementIntervalMs <= 0 { + return errors.New("txManager.replacementIntervalMs must be positive") + } + if c.TxManager.PendingTimeoutMs < c.TxManager.ReplacementIntervalMs { + return errors.New("txManager.pendingTimeoutMs must be at least replacementIntervalMs") + } if err := c.Signer.validate(); err != nil { return err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f49723fe..87b2a668 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -22,6 +22,8 @@ chain: chainId: 11155111 signer: keyEnv: SOLVER_PRIVATE_KEY +txManager: + maxFeeGwei: 100 solvers: - name: 3f-bridge-facilitator config: @@ -37,6 +39,10 @@ func TestLoad_ValidAppliesDefaults(t *testing.T) { if cfg.TxManager.Confirmations != DefaultConfirmations { t.Fatalf("expected default confirmations %d, got %d", DefaultConfirmations, cfg.TxManager.Confirmations) } + if cfg.TxManager.ReplacementIntervalMs != DefaultReplacementIntervalMs || + cfg.TxManager.PendingTimeoutMs != DefaultPendingTimeoutMs { + t.Fatalf("unexpected tx replacement defaults: %+v", cfg.TxManager) + } if cfg.Observability.Addr != DefaultObservabilityAddr { t.Fatalf("expected default addr %q, got %q", DefaultObservabilityAddr, cfg.Observability.Addr) } @@ -48,6 +54,8 @@ chain: chainId: 11155111 signer: keyEnv: SOLVER_PRIVATE_KEY +txManager: + maxFeeGwei: 100 solvers: - name: 3f-bridge-facilitator config: {apiBaseUrl: https://bf.dev.gcp.3f.xyz} @@ -73,6 +81,8 @@ chain: chainId: 11155111 signer: keyEnv: SOLVER_PRIVATE_KEY +txManager: + maxFeeGwei: 100 solvers: - name: rfq-filler config: {} @@ -110,6 +120,8 @@ chain: chainId: 11155111 signer: keyEnv: SOLVER_PRIVATE_KEY +txManager: + maxFeeGwei: 100 solvers: - name: x config: {} @@ -136,6 +148,8 @@ chain: chainId: 1 signer: keyEnv: SOLVER_PRIVATE_KEY +txManager: + maxFeeGwei: 100 solvers: - name: x config: {} @@ -160,6 +174,8 @@ chain: chainId: 1 signer: keyEnv: SOLVER_PRIVATE_KEY +txManager: + maxFeeGwei: 100 solvers: - name: x config: {} @@ -180,6 +196,7 @@ func TestLoad_ExpandsEnvInSolverConfigBlock(t *testing.T) { body := ` chain: {rpcUrl: http://x, chainId: 1} signer: {keyEnv: K} +txManager: {maxFeeGwei: 100} solvers: - name: x config: @@ -230,13 +247,43 @@ solvers: [{name: x}] "missing solver name": ` chain: {rpcUrl: http://x, chainId: 1} signer: {keyEnv: K} +txManager: {maxFeeGwei: 100} solvers: [{}] +`, + "missing max fee cap": ` +chain: {rpcUrl: http://x, chainId: 1} +signer: {keyEnv: K} +solvers: [{name: x}] +`, + "non-finite max fee cap": ` +chain: {rpcUrl: http://x, chainId: 1} +signer: {keyEnv: K} +txManager: {maxFeeGwei: .nan} +solvers: [{name: x}] +`, + "negative tip": ` +chain: {rpcUrl: http://x, chainId: 1} +signer: {keyEnv: K} +txManager: {maxFeeGwei: 100, tipGwei: -1} +solvers: [{name: x}] `, "unknown field": ` chain: {rpcUrl: http://x, chainId: 1} signer: {keyEnv: K} solvers: [{name: x}] bogus: true +`, + "negative replacement interval": ` +chain: {rpcUrl: http://x, chainId: 1} +signer: {keyEnv: K} +txManager: {maxFeeGwei: 100, replacementIntervalMs: -1} +solvers: [{name: x}] +`, + "timeout below replacement interval": ` +chain: {rpcUrl: http://x, chainId: 1} +signer: {keyEnv: K} +txManager: {maxFeeGwei: 100, replacementIntervalMs: 30000, pendingTimeoutMs: 10000} +solvers: [{name: x}] `, } for name, body := range cases { diff --git a/internal/liquidlane/authorization.go b/internal/liquidlane/authorization.go new file mode 100644 index 00000000..bdadbfa6 --- /dev/null +++ b/internal/liquidlane/authorization.go @@ -0,0 +1,22 @@ +package liquidlane + +import "github.com/ethereum/go-ethereum/common" + +// UnauthorizedAdapters returns configured adapter addresses absent from the authorized route set. +// Authorization is adapter-wide, so duplicate physical routes produce one address in config order. +func UnauthorizedAdapters(routes, authorized []Route) []common.Address { + allowed := make(map[common.Address]bool, len(authorized)) + for _, route := range authorized { + allowed[route.Adapter] = true + } + + seen := make(map[common.Address]bool) + missing := make([]common.Address, 0) + for _, route := range routes { + if !allowed[route.Adapter] && !seen[route.Adapter] { + missing = append(missing, route.Adapter) + seen[route.Adapter] = true + } + } + return missing +} diff --git a/internal/liquidlane/authorization_test.go b/internal/liquidlane/authorization_test.go new file mode 100644 index 00000000..15da829f --- /dev/null +++ b/internal/liquidlane/authorization_test.go @@ -0,0 +1,26 @@ +package liquidlane + +import ( + "slices" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestUnauthorizedAdapters(t *testing.T) { + adapterA := common.HexToAddress("0x000000000000000000000000000000000000000a") + adapterB := common.HexToAddress("0x000000000000000000000000000000000000000b") + adapterC := common.HexToAddress("0x000000000000000000000000000000000000000c") + routes := []Route{ + {Adapter: adapterA}, + {Adapter: adapterB}, + {Adapter: adapterB}, + {Adapter: adapterC}, + } + + got := UnauthorizedAdapters(routes, []Route{{Adapter: adapterB}}) + want := []common.Address{adapterA, adapterC} + if !slices.Equal(got, want) { + t.Fatalf("UnauthorizedAdapters() = %v, want %v", got, want) + } +} diff --git a/internal/liquidlane/discounts/client.go b/internal/liquidlane/discounts/client.go new file mode 100644 index 00000000..416a54d6 --- /dev/null +++ b/internal/liquidlane/discounts/client.go @@ -0,0 +1,201 @@ +// Package discounts wraps the LiquidLane signed-discounts API shared by solvers. +package discounts + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/api/rfqbackend" +) + +const ( + defaultTimeout = 10 * time.Second + publicAPIPrefix = "/api/v1" + internalAPIPrefix = "/api-internal/v1" +) + +// Terms is the signed discount the LiquidLane adapter's discountSwap verifies. +// Amounts/nonce stay as wire strings until a solver maps them into its executor-specific calldata. +type Terms struct { + Adapter string + TokenToRedeem string + Discount string + Signer string + Protocol string + Nonce string + Deadline int64 +} + +// Resolved is the fresh signed discount returned at fill time. +type Resolved struct { + RequestID string + DiscountID string + Discount Terms + SignerSignature string + ProtocolDeadline int64 + ProtocolSignature string +} + +// ListItem is one currently advertised private discount. +type ListItem struct { + DiscountID string + Adapter string + TokenToRedeem string + Collateral string + CollateralDecimals int + Discount string + Signer string + Deadline int64 + MaxRate string + MaxAssets string +} + +// List is the GET /discounts response projected into solver-owned types. +type List struct { + RequestID string + Protocol string + Discounts []ListItem +} + +// Client is a small adapter over the generated rfqbackend client for the shared signed-discount +// endpoints. The generated client emits /api/v1/discount(s); rewriteTransport routes only those calls +// to the backend's /api-internal/v1 path. +type Client struct { + api *rfqbackend.APIClient +} + +func NewClient(baseURL string) *Client { + cfg := rfqbackend.NewConfiguration() + cfg.Servers = rfqbackend.ServerConfigurations{{URL: strings.TrimRight(baseURL, "/")}} + cfg.HTTPClient = &http.Client{ + Timeout: defaultTimeout, + Transport: rewriteTransport{base: http.DefaultTransport}, + } + return &Client{api: rfqbackend.NewAPIClient(cfg)} +} + +// rewriteTransport routes private-discount requests to the backend's internal API prefix. Other +// generated-client requests pass through unchanged. +type rewriteTransport struct { + base http.RoundTripper +} + +func (t rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.base + if base == nil { + base = http.DefaultTransport + } + if index := strings.LastIndex(req.URL.Path, publicAPIPrefix+"/discount"); index >= 0 { + req = req.Clone(req.Context()) + req.URL.Path = req.URL.Path[:index] + + internalAPIPrefix + + strings.TrimPrefix(req.URL.Path[index:], publicAPIPrefix) + req.URL.RawPath = "" + } + return base.RoundTrip(req) +} + +// Resolve fetches a fresh signed discount for discountID. +// +// The backend response is an anyOf union of a single resolved discount and a batch. Solvers resolve one +// discountId at a time, so a batch is accepted only when it has exactly one entry. +func (c *Client) Resolve(ctx context.Context, discountID string) (*Resolved, error) { + body := rfqbackend.NewApiV1DiscountsPostRequest() + body.SetDiscountId(discountID) + resp, httpResp, err := c.api.RFQAPI.ApiV1DiscountsPost(ctx).ApiV1DiscountsPostRequest(*body).Execute() + closeResp(httpResp) + if err != nil { + return nil, errors.Errorf("private discounts: resolve: %w", err) + } + if resp == nil { + return nil, errors.New("private discounts: resolve: empty response") + } + if single := resp.ResolveDiscountResponseAnyOf; single != nil { + return resolvedFromSingle(single), nil + } + if batch := resp.ResolveDiscountResponseAnyOf1; batch != nil { + items := batch.GetDiscounts() + if len(items) != 1 { + return nil, errors.Errorf("private discounts: resolve: expected a single discount, got %d", len(items)) + } + return resolvedFromBatchItem(batch.GetRequestId(), &items[0]), nil + } + return nil, errors.New("private discounts: resolve: response matched neither discount shape") +} + +func resolvedFromSingle(s *rfqbackend.ResolveDiscountResponseAnyOf) *Resolved { + return &Resolved{ + RequestID: s.GetRequestId(), + DiscountID: s.GetDiscountId(), + Discount: termsFromModel(s.GetDiscount()), + SignerSignature: s.GetSignerSignature(), + ProtocolDeadline: int64(s.GetProtocolDeadline()), + ProtocolSignature: s.GetProtocolSignature(), + } +} + +func resolvedFromBatchItem(requestID string, it *rfqbackend.ResolveDiscountResponseAnyOf1DiscountsInner) *Resolved { + return &Resolved{ + RequestID: requestID, + DiscountID: it.GetDiscountId(), + Discount: termsFromModel(it.GetDiscount()), + SignerSignature: it.GetSignerSignature(), + ProtocolDeadline: int64(it.GetProtocolDeadline()), + ProtocolSignature: it.GetProtocolSignature(), + } +} + +func termsFromModel(d rfqbackend.PublishDiscountRequestDiscount) Terms { + return Terms{ + Adapter: d.GetAdapter(), + TokenToRedeem: d.GetTokenToRedeem(), + Discount: d.GetDiscount(), + Signer: d.GetSigner(), + Protocol: d.GetProtocol(), + Nonce: d.GetNonce(), + Deadline: int64(d.GetDeadline()), + } +} + +// ListDiscounts lists currently advertised private discounts. +func (c *Client) ListDiscounts(ctx context.Context) (*List, error) { + resp, httpResp, err := c.api.RFQAPI.ApiV1DiscountsGet(ctx).Execute() + closeResp(httpResp) + if err != nil { + return nil, errors.Errorf("private discounts: list: %w", err) + } + out := &List{} + if resp == nil { + return out, nil + } + out.RequestID = resp.GetRequestId() + out.Protocol = resp.GetProtocol() + gen := resp.GetDiscounts() + out.Discounts = make([]ListItem, 0, len(gen)) + for i := range gen { + d := &gen[i] + out.Discounts = append(out.Discounts, ListItem{ + DiscountID: d.GetDiscountId(), + Adapter: d.GetAdapter(), + TokenToRedeem: d.GetTokenToRedeem(), + Collateral: d.GetCollateral(), + CollateralDecimals: int(d.GetCollateralDecimals()), + Discount: d.GetDiscount(), + Signer: d.GetSigner(), + Deadline: int64(d.GetDeadline()), + MaxRate: d.GetMaxRate(), + MaxAssets: d.GetMaxAssets(), + }) + } + return out, nil +} + +func closeResp(resp *http.Response) { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } +} diff --git a/internal/liquidlane/discounts/client_test.go b/internal/liquidlane/discounts/client_test.go new file mode 100644 index 00000000..a609d9fd --- /dev/null +++ b/internal/liquidlane/discounts/client_test.go @@ -0,0 +1,138 @@ +package discounts + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClientResolveSingle(t *testing.T) { + var gotPath, gotMethod, gotID string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotMethod = r.URL.Path, r.Method + var body map[string]any + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + gotID, _ = body["discountId"].(string) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"requestId":"00000000-0000-0000-0000-000000000000",` + + `"discountId":"0x` + hash64 + `",` + + `"discount":{"adapter":"0x0000000000000000000000000000000000000abc",` + + `"tokenToRedeem":"0x0000000000000000000000000000000000000def",` + + `"discount":"123","signer":"0x0000000000000000000000000000000000000aaa",` + + `"protocol":"0x0000000000000000000000000000000000000bbb","nonce":"0x2","deadline":1900000000},` + + `"signerSignature":"0xdead","protocolDeadline":1900000001,"protocolSignature":"0xbeef"}`)) + })) + defer srv.Close() + + id := "0x" + hash64 + res, err := NewClient(srv.URL).Resolve(context.Background(), id) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if gotPath != "/api-internal/v1/discounts" || gotMethod != http.MethodPost || gotID != id { + t.Fatalf("request = path %q method %q id %q", gotPath, gotMethod, gotID) + } + if res.Discount.Adapter != "0x0000000000000000000000000000000000000abc" || + res.Discount.Discount != "123" || res.Discount.Nonce != "0x2" || + res.Discount.Deadline != 1900000000 { + t.Fatalf("discount terms = %+v", res.Discount) + } + if res.SignerSignature != "0xdead" || res.ProtocolSignature != "0xbeef" || res.ProtocolDeadline != 1900000001 { + t.Fatalf("resolved = %+v", res) + } +} + +func TestClientResolveBatchSingleEntryAccepted(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"requestId":"00000000-0000-0000-0000-000000000000",` + + `"discounts":[{"discountId":"0x` + hash64 + `",` + + `"discount":{"adapter":"0x0000000000000000000000000000000000000abc",` + + `"tokenToRedeem":"0x0000000000000000000000000000000000000def",` + + `"discount":"123","signer":"0x0000000000000000000000000000000000000aaa",` + + `"protocol":"0x0000000000000000000000000000000000000bbb","nonce":"0x2","deadline":1900000000},` + + `"signerSignature":"0xdead","protocolDeadline":1900000001,"protocolSignature":"0xbeef"}]}`)) + })) + defer srv.Close() + + res, err := NewClient(srv.URL).Resolve(context.Background(), "0x"+hash64) + if err != nil { + t.Fatalf("Resolve batch: %v", err) + } + if res.Discount.Adapter != "0x0000000000000000000000000000000000000abc" || res.SignerSignature != "0xdead" { + t.Fatalf("resolved from batch = %+v", res) + } +} + +func TestClientResolveBatchMultipleRejected(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + entry := `{"discountId":"0x` + hash64 + `",` + + `"discount":{"adapter":"0x0000000000000000000000000000000000000abc",` + + `"tokenToRedeem":"0x0000000000000000000000000000000000000def",` + + `"discount":"1","signer":"0x0000000000000000000000000000000000000aaa",` + + `"protocol":"0x0000000000000000000000000000000000000bbb","nonce":"0x2","deadline":1900000000},` + + `"signerSignature":"0xdead","protocolDeadline":1900000001,"protocolSignature":"0xbeef"}` + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"requestId":"00000000-0000-0000-0000-000000000000","discounts":[` + + entry + `,` + entry + `]}`)) + })) + defer srv.Close() + + if _, err := NewClient(srv.URL).Resolve(context.Background(), "0x"+hash64); err == nil { + t.Fatalf("expected an error when the backend resolves more than one discount") + } +} + +func TestClientListDiscounts(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"requestId":"00000000-0000-0000-0000-000000000000",` + + `"protocol":"0x0000000000000000000000000000000000000bbb","discounts":[` + + `{"discountId":"0x` + hash64 + `","adapter":"0x0000000000000000000000000000000000000abc",` + + `"tokenToRedeem":"0x0000000000000000000000000000000000000def",` + + `"collateral":"0x0000000000000000000000000000000000000c01","collateralDecimals":6,` + + `"discount":"10","signer":"0x0000000000000000000000000000000000000aaa","deadline":1900000000,` + + `"maxRate":"1000000","maxAssets":"5000"}]}`)) + })) + defer srv.Close() + + resp, err := NewClient(srv.URL).ListDiscounts(context.Background()) + if err != nil { + t.Fatalf("ListDiscounts: %v", err) + } + if gotPath != "/api-internal/v1/discounts" { + t.Fatalf("path = %q", gotPath) + } + if len(resp.Discounts) != 1 || resp.Discounts[0].CollateralDecimals != 6 || + resp.Discounts[0].MaxAssets != "5000" || resp.Discounts[0].Deadline != 1900000000 { + t.Fatalf("discounts = %+v", resp.Discounts) + } +} + +func TestClientPreservesBaseURLPathPrefix(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte( + `{"requestId":"00000000-0000-0000-0000-000000000000",` + + `"protocol":"0x0000000000000000000000000000000000000001","discounts":[]}`, + )) + })) + defer srv.Close() + + if _, err := NewClient(srv.URL + "/backend").ListDiscounts(t.Context()); err != nil { + t.Fatalf("ListDiscounts: %v", err) + } + if gotPath != "/backend/api-internal/v1/discounts" { + t.Fatalf("path = %q, want prefixed internal path", gotPath) + } +} + +const hash64 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" diff --git a/internal/liquidlane/discounts/offers.go b/internal/liquidlane/discounts/offers.go new file mode 100644 index 00000000..f9b2a5f4 --- /dev/null +++ b/internal/liquidlane/discounts/offers.go @@ -0,0 +1,204 @@ +package discounts + +import ( + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +// OfferIssue describes an advertised discount rejected as malformed or inconsistent with current state. +type OfferIssue struct { + DiscountID string + Err error +} + +// MatchOptions contains solver policy applied before an offer becomes a LiquidLane candidate. +type MatchOptions struct { + Now time.Time + AllowsToken func(common.Address) bool +} + +// LiveOffers parses advertised discounts and removes entries already expired at now. +func LiveOffers(listed *List, now time.Time) ([]Offer, []OfferIssue) { + if listed == nil { + return nil, nil + } + offers := make([]Offer, 0, len(listed.Discounts)) + issues := make([]OfferIssue, 0) + for _, item := range listed.Discounts { + offer, err := ParseOffer(item) + if err != nil { + issues = append(issues, OfferIssue{DiscountID: item.DiscountID, Err: err}) + continue + } + if offer.Deadline > now.Unix() { + offers = append(offers, *offer) + } + } + return offers, issues +} + +// MatchInventories maps advertised discounts onto current physical routes. +func MatchInventories( + listed *List, + physical []liquidlane.Inventory, + options MatchOptions, +) ([]liquidlane.Inventory, []OfferIssue) { + byRoute := inventoryByRoute(physical) + offers, issues := LiveOffers(listed, options.Now) + seen := make(map[common.Hash]bool, len(offers)) + inventory := make([]liquidlane.Inventory, 0, len(offers)) + for _, offer := range offers { + if seen[offer.DiscountID] || !tokenAllowed(offer.TokenToRedeem, options) { + continue + } + base, ok := byRoute[newRouteKey(offer.Adapter, offer.TokenToRedeem, offer.Collateral)] + if !ok || offer.CollateralDecimals != base.TokenOutDecimals { + continue + } + if base.MaxRate == nil || base.MaxRate.Sign() <= 0 || offer.MaxRate.Cmp(base.MaxRate) > 0 { + issues = append(issues, OfferIssue{ + DiscountID: offer.DiscountID.Hex(), + Err: errors.New("advertised discount rate exceeds current adapter max rate"), + }) + continue + } + if base.AdapterMinDiscount == nil || base.AdapterMinDiscount.Sign() < 0 || + offer.Discount.Cmp(base.AdapterMinDiscount) < 0 { + issues = append(issues, OfferIssue{ + DiscountID: offer.DiscountID.Hex(), + Err: errors.New("advertised discount is below current adapter minimum"), + }) + continue + } + maxAssets := minPositive(offer.MaxAssets, base.MaxAssets) + if maxAssets.Sign() <= 0 { + continue + } + seen[offer.DiscountID] = true + candidate := liquidlane.DiscountInventory( + base.Route, + maxAssets, + offer.MaxRate, + offer.DiscountID, + time.Unix(offer.Deadline, 0), + ) + candidate.AdapterMinDiscount = liquidlane.CloneBig(base.AdapterMinDiscount) + inventory = append(inventory, candidate) + } + return inventory, issues +} + +// AdvertisedFillQuotes prices advertised discounts against current amount-specific adapter quotes. +func AdvertisedFillQuotes( + listed *List, + physical []liquidlane.FillQuote, + options MatchOptions, +) ([]liquidlane.FillQuote, []OfferIssue) { + byRoute := fillQuotesByRoute(physical) + offers, issues := LiveOffers(listed, options.Now) + seen := make(map[common.Hash]bool, len(offers)) + quotes := make([]liquidlane.FillQuote, 0, len(offers)) + for _, offer := range offers { + if seen[offer.DiscountID] || !tokenAllowed(offer.TokenToRedeem, options) { + continue + } + base, ok := byRoute[newRouteKey(offer.Adapter, offer.TokenToRedeem, offer.Collateral)] + if !ok || offer.CollateralDecimals != base.TokenOutDecimals { + continue + } + if base.MaxRate == nil || base.MaxRate.Sign() <= 0 || offer.MaxRate.Cmp(base.MaxRate) > 0 { + issues = append(issues, OfferIssue{ + DiscountID: offer.DiscountID.Hex(), + Err: errors.New("advertised discount rate exceeds current adapter max rate"), + }) + continue + } + if base.MinDiscount == nil || base.MinDiscount.Sign() < 0 || offer.Discount.Cmp(base.MinDiscount) < 0 { + issues = append(issues, OfferIssue{ + DiscountID: offer.DiscountID.Hex(), + Err: errors.New("advertised discount is below current adapter minimum"), + }) + continue + } + amountOut := liquidlane.AmountOutAfterDiscount(base.GrossAmountOut, offer.Discount) + currentRate := liquidlane.RateForAmountOut( + amountOut, + base.AmountIn, + base.TokenInDecimals, + base.TokenOutDecimals, + ) + maxRate := minPositive(currentRate, offer.MaxRate) + maxAmountOut := liquidlane.AmountOutForRate( + base.AmountIn, + maxRate, + base.TokenInDecimals, + base.TokenOutDecimals, + ) + maxAssets := minPositive(offer.MaxAssets, base.MaxAssets) + if maxRate.Sign() <= 0 || maxAmountOut.Sign() <= 0 || maxAssets.Sign() <= 0 { + continue + } + seen[offer.DiscountID] = true + inventory := liquidlane.DiscountInventory( + base.Route, + maxAssets, + maxRate, + offer.DiscountID, + time.Unix(offer.Deadline, 0), + ) + inventory.AdapterMinDiscount = liquidlane.CloneBig(base.AdapterMinDiscount) + quotes = append(quotes, liquidlane.FillQuote{ + Inventory: inventory, + AmountIn: liquidlane.CloneBig(base.AmountIn), + GrossAmountOut: liquidlane.CloneBig(base.GrossAmountOut), + MaxAmountOut: maxAmountOut, + MinDiscount: liquidlane.CloneBig(offer.Discount), + }) + } + return quotes, issues +} + +type routeKey struct { + adapter common.Address + tokenIn common.Address + tokenOut common.Address +} + +func newRouteKey(adapter, tokenIn, tokenOut common.Address) routeKey { + return routeKey{adapter: adapter, tokenIn: tokenIn, tokenOut: tokenOut} +} + +func inventoryByRoute(inventory []liquidlane.Inventory) map[routeKey]liquidlane.Inventory { + byRoute := make(map[routeKey]liquidlane.Inventory, len(inventory)) + for _, item := range inventory { + byRoute[newRouteKey(item.Adapter, item.TokenIn, item.TokenOut)] = item + } + return byRoute +} + +func fillQuotesByRoute(quotes []liquidlane.FillQuote) map[routeKey]liquidlane.FillQuote { + byRoute := make(map[routeKey]liquidlane.FillQuote, len(quotes)) + for _, quote := range quotes { + byRoute[newRouteKey(quote.Adapter, quote.TokenIn, quote.TokenOut)] = quote + } + return byRoute +} + +func tokenAllowed(token common.Address, options MatchOptions) bool { + return options.AllowsToken == nil || options.AllowsToken(token) +} + +func minPositive(left, right *big.Int) *big.Int { + if left == nil || right == nil || left.Sign() <= 0 || right.Sign() <= 0 { + return new(big.Int) + } + if left.Cmp(right) <= 0 { + return liquidlane.CloneBig(left) + } + return liquidlane.CloneBig(right) +} diff --git a/internal/liquidlane/discounts/offers_test.go b/internal/liquidlane/discounts/offers_test.go new file mode 100644 index 00000000..ec449e92 --- /dev/null +++ b/internal/liquidlane/discounts/offers_test.go @@ -0,0 +1,198 @@ +package discounts + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +const testOfferID = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +func TestMatchInventoriesScopesCapsAndKeepsAdvertisedNetRate(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + base := testPhysicalInventory() + netRate := big.NewInt(800_000_000_000_000_000) + listed := &List{Discounts: []ListItem{ + testOffer(base, "2000", netRate.String(), now.Add(time.Minute)), + { + DiscountID: testOfferID, + Adapter: common.HexToAddress("0xdead").Hex(), TokenToRedeem: base.TokenIn.Hex(), + Collateral: base.TokenOut.Hex(), CollateralDecimals: base.TokenOutDecimals, + Discount: "100000", Deadline: now.Add(time.Minute).Unix(), MaxRate: netRate.String(), MaxAssets: "2000", + }, + }} + + inventory, issues := MatchInventories(listed, []liquidlane.Inventory{base}, MatchOptions{Now: now}) + if len(issues) != 0 || len(inventory) != 1 { + t.Fatalf("inventory=%+v issues=%+v", inventory, issues) + } + if inventory[0].MaxAssets.String() != "1000" || inventory[0].MaxRate.Cmp(netRate) != 0 { + t.Fatalf("capped inventory = %+v", inventory[0]) + } + if inventory[0].DiscountID == nil || inventory[0].DiscountID.Hex() != testOfferID { + t.Fatalf("discount id = %v", inventory[0].DiscountID) + } + if !inventory[0].ValidUntil.Equal(now.Add(time.Minute)) { + t.Fatalf("valid until = %s", inventory[0].ValidUntil) + } +} + +func TestMatchInventoriesRejectsDiscountBelowCurrentAdapterMinimum(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + base := testPhysicalInventory() + offer := testOffer(base, "1000", base.MaxRate.String(), now.Add(time.Minute)) + offer.Discount = new(big.Int).Sub(base.AdapterMinDiscount, big.NewInt(1)).String() + + inventory, issues := MatchInventories( + &List{Discounts: []ListItem{offer}}, + []liquidlane.Inventory{base}, + MatchOptions{Now: now}, + ) + if len(inventory) != 0 || len(issues) != 1 { + t.Fatalf("inventory=%+v issues=%+v", inventory, issues) + } +} + +func TestAdvertisedFillQuotesUseCurrentOracleAmountAndPolicy(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + base := testPhysicalInventory() + listed := &List{Discounts: []ListItem{ + testOffer(base, "100", "2000000000000000000", now.Add(time.Minute)), + }} + listed.Discounts[0].Discount = "100000" + physical := []liquidlane.FillQuote{{ + Inventory: testInventoryWithMinDiscount( + liquidlane.DirectInventory(base.Route, big.NewInt(100), big.NewInt(2_000_000_000_000_000_000)), + new(big.Int), + ), + AmountIn: big.NewInt(10), GrossAmountOut: big.NewInt(20), MaxAmountOut: big.NewInt(20), + MinDiscount: new(big.Int), + }} + + quotes, issues := AdvertisedFillQuotes(listed, physical, MatchOptions{ + Now: now, + AllowsToken: func(token common.Address) bool { return token == base.TokenIn }, + }) + if len(issues) != 0 || len(quotes) != 1 || quotes[0].MaxAmountOut.Cmp(big.NewInt(18)) != 0 { + t.Fatalf("quotes=%+v issues=%+v", quotes, issues) + } + blocked, _ := AdvertisedFillQuotes(listed, physical, MatchOptions{ + Now: now, AllowsToken: func(common.Address) bool { return false }, + }) + if len(blocked) != 0 { + t.Fatalf("blocked quotes = %+v", blocked) + } +} + +func TestAdvertisedFillQuotesRejectStaleAdapterEconomics(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + base := testPhysicalInventory() + physical := []liquidlane.FillQuote{{ + Inventory: testInventoryWithMinDiscount( + liquidlane.DirectInventory(base.Route, big.NewInt(100), big.NewInt(900)), + big.NewInt(100_000), + ), + AmountIn: big.NewInt(10), GrossAmountOut: big.NewInt(10), MaxAmountOut: big.NewInt(9), + MinDiscount: big.NewInt(100_000), + }} + + tests := []struct { + name string + discount string + maxRate string + }{ + {name: "rate above current maximum", discount: "100000", maxRate: "901"}, + {name: "discount below current minimum", discount: "99999", maxRate: "900"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + offer := testOffer(base, "100", tt.maxRate, now.Add(time.Minute)) + offer.Discount = tt.discount + quotes, issues := AdvertisedFillQuotes( + &List{Discounts: []ListItem{offer}}, physical, MatchOptions{Now: now}, + ) + if len(quotes) != 0 || len(issues) != 1 { + t.Fatalf("quotes=%+v issues=%+v", quotes, issues) + } + }) + } +} + +func FuzzAdvertisedFillQuotesStayInsideCurrentFacts(f *testing.F) { + f.Add(uint32(1_000), uint32(900), uint32(100_000), uint32(1_000), uint64(1_000_000_000_000_000_000)) + f.Fuzz(func( + t *testing.T, + rawAmountIn, rawGross, rawDiscount, rawMaxAssets uint32, + rawMaxRate uint64, + ) { + amountIn := int64(rawAmountIn%1_000_000 + 1) + gross := int64(rawGross%1_000_000 + 1) + discount := int64(rawDiscount % uint32(liquidlane.DiscountPrecision+1)) + maxAssets := int64(rawMaxAssets%1_000_000 + 1) + maxRate := new(big.Int).SetUint64(rawMaxRate%2_000_000_000_000_000_000 + 1) + now := time.Unix(1_800_000_000, 0) + base := testPhysicalInventory() + base.MaxAssets = big.NewInt(maxAssets) + base.MaxRate = maxRate + base.AdapterMinDiscount = new(big.Int) + physical := []liquidlane.FillQuote{{ + Inventory: base, + AmountIn: big.NewInt(amountIn), + GrossAmountOut: big.NewInt(gross), + MaxAmountOut: big.NewInt(gross), + MinDiscount: new(big.Int), + }} + offer := testOffer(base, big.NewInt(maxAssets).String(), maxRate.String(), now.Add(time.Minute)) + offer.Discount = big.NewInt(discount).String() + + quotes, issues := AdvertisedFillQuotes( + &List{Discounts: []ListItem{offer}}, physical, MatchOptions{Now: now}, + ) + if len(issues) != 0 || len(quotes) == 0 { + return + } + quote := quotes[0] + if quote.MaxAmountOut.Sign() <= 0 || quote.MaxAmountOut.Cmp(big.NewInt(gross)) > 0 { + t.Fatalf("amountOut = %s, gross = %d", quote.MaxAmountOut, gross) + } + if quote.MaxAssets.Sign() <= 0 || quote.MaxAssets.Cmp(big.NewInt(maxAssets)) > 0 { + t.Fatalf("maxAssets = %s, physical = %d", quote.MaxAssets, maxAssets) + } + if quote.MaxRate.Cmp(base.MaxRate) > 0 { + t.Fatalf("maxRate = %s, physical = %s", quote.MaxRate, base.MaxRate) + } + }) +} + +func testPhysicalInventory() liquidlane.Inventory { + route := liquidlane.NewRoute( + 1, + common.HexToAddress("0x1111111111111111111111111111111111111111"), + common.HexToAddress("0x2222222222222222222222222222222222222222"), + common.HexToAddress("0x3333333333333333333333333333333333333333"), + common.HexToAddress("0x4444444444444444444444444444444444444444"), + 6, + 6, + ) + inventory := liquidlane.DirectInventory(route, big.NewInt(1_000), big.NewInt(900_000_000_000_000_000)) + inventory.AdapterMinDiscount = big.NewInt(100_000) + return inventory +} + +func testInventoryWithMinDiscount(inventory liquidlane.Inventory, minDiscount *big.Int) liquidlane.Inventory { + inventory.AdapterMinDiscount = liquidlane.CloneBig(minDiscount) + return inventory +} + +func testOffer(base liquidlane.Inventory, maxAssets, maxRate string, deadline time.Time) ListItem { + return ListItem{ + DiscountID: testOfferID, + Adapter: base.Adapter.Hex(), TokenToRedeem: base.TokenIn.Hex(), Collateral: base.TokenOut.Hex(), + CollateralDecimals: base.TokenOutDecimals, Discount: "100000", Deadline: deadline.Unix(), + MaxRate: maxRate, MaxAssets: maxAssets, + } +} diff --git a/internal/liquidlane/discounts/signed.go b/internal/liquidlane/discounts/signed.go new file mode 100644 index 00000000..558e96db --- /dev/null +++ b/internal/liquidlane/discounts/signed.go @@ -0,0 +1,213 @@ +package discounts + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +// Selection identifies the route and economic floor chosen before resolving a fresh discount. +type Selection struct { + DiscountID common.Hash + Adapter common.Address + TokenIn common.Address + TokenOut common.Address + AmountIn *big.Int + + MinAmountOut *big.Int +} + +// Provider is the shared signed-discount API surface used by direct LiquidLane clients. +type Provider interface { + ListDiscounts(ctx context.Context) (*List, error) + Resolve(ctx context.Context, discountID string) (*Resolved, error) +} + +// ValidateSigned verifies fresh signed terms against the selected route and current on-chain quote. +// It returns the currently executable output after the signed discount. +func ValidateSigned( + signed *Signed, + selection Selection, + base liquidlane.FillQuote, + validAfter time.Time, +) (*big.Int, error) { + if err := ValidateSelection(signed, selection, validAfter); err != nil { + return nil, err + } + if base.GrossAmountOut == nil || base.GrossAmountOut.Sign() <= 0 || + base.MinDiscount == nil || base.MinDiscount.Sign() < 0 { + return nil, errors.New("resolved discount has no current on-chain quote") + } + if signed.Terms.Discount == nil { + return nil, errors.New("resolved discount is missing discount terms") + } + if signed.Terms.Discount.Cmp(base.MinDiscount) < 0 { + return nil, errors.New("resolved discount is below the adapter minimum") + } + amountOut := liquidlane.AmountOutAfterDiscount(base.GrossAmountOut, signed.Terms.Discount) + if selection.MinAmountOut != nil && amountOut.Cmp(selection.MinAmountOut) < 0 { + return nil, errors.New("resolved discount no longer meets the selected minimum output") + } + return amountOut, nil +} + +// ValidateSelection verifies signed identity, route binding, and execution deadlines. +func ValidateSelection(signed *Signed, selection Selection, validAfter time.Time) error { + if signed == nil { + return errors.New("resolved discount is nil") + } + if signed.DiscountID != selection.DiscountID { + return errors.New("resolved discount id does not match selected route") + } + if signed.Adapter != selection.Adapter || signed.Terms.TokenToRedeem != selection.TokenIn { + return errors.New("resolved discount route does not match selected route") + } + if signed.Terms.Deadline == nil || signed.ProtocolDeadline == nil { + return errors.New("resolved discount is missing deadlines") + } + cutoff := big.NewInt(validAfter.Unix()) + if signed.Terms.Deadline.Cmp(cutoff) <= 0 || signed.ProtocolDeadline.Cmp(cutoff) <= 0 { + return errors.New("resolved discount expires before the execution safety window") + } + return nil +} + +// ResolveAndValidate fetches fresh signed terms and binds them to a selected route and current quote. +func ResolveAndValidate( + ctx context.Context, + provider Provider, + selection Selection, + base liquidlane.FillQuote, + validAfter time.Time, +) (*Signed, error) { + if provider == nil { + return nil, errors.New("discount route cannot be resolved") + } + resolved, err := provider.Resolve(ctx, selection.DiscountID.Hex()) + if err != nil { + return nil, err + } + return ParseAndValidate(resolved, selection, base, validAfter) +} + +// ParseAndValidate parses a resolved backend payload and validates it against current route facts. +func ParseAndValidate( + resolved *Resolved, + selection Selection, + base liquidlane.FillQuote, + validAfter time.Time, +) (*Signed, error) { + signed, err := ParseSigned(resolved) + if err != nil { + return nil, err + } + if _, err := ValidateSigned(signed, selection, base, validAfter); err != nil { + return nil, err + } + return signed, nil +} + +// ResolveSelected fetches and validates the signed discount chosen for one exact fill route. +func ResolveSelected( + ctx context.Context, + provider Provider, + selection Selection, + physical []liquidlane.FillQuote, + validAfter time.Time, +) (*Signed, error) { + base, ok := FindFillQuote( + physical, + selection.Adapter, + selection.TokenIn, + selection.TokenOut, + selection.AmountIn, + ) + if !ok { + return nil, errors.New("resolved discount has no current on-chain quote") + } + return ResolveAndValidate(ctx, provider, selection, base, validAfter) +} + +// RefreshFillQuotes rebinds resolved discount candidates to a newer physical adapter snapshot. +func RefreshFillQuotes( + candidates []liquidlane.FillQuote, + resolved map[common.Hash]*Signed, + physical []liquidlane.FillQuote, + now time.Time, +) ([]liquidlane.FillQuote, []OfferIssue) { + baseByRoute := make(map[liquidlane.RouteID]liquidlane.FillQuote, len(physical)) + for _, base := range physical { + baseByRoute[base.ID] = base + } + quotes := make([]liquidlane.FillQuote, 0, len(candidates)) + issues := make([]OfferIssue, 0) + for _, candidate := range candidates { + if candidate.DiscountID == nil { + continue + } + signed := resolved[*candidate.DiscountID] + base, ok := baseByRoute[candidate.ID] + if signed == nil || !ok { + continue + } + if candidate.MaxRate == nil || base.MaxRate == nil || candidate.MaxRate.Cmp(base.MaxRate) > 0 { + issues = append(issues, OfferIssue{ + DiscountID: candidate.DiscountID.Hex(), + Err: errors.New("resolved discount rate exceeds refreshed adapter max rate"), + }) + continue + } + candidate.MaxAssets = minPositive(candidate.MaxAssets, base.MaxAssets) + if candidate.MaxAssets.Sign() <= 0 { + continue + } + maxAmountOut, err := ValidateSigned(signed, Selection{ + DiscountID: *candidate.DiscountID, + Adapter: candidate.Adapter, + TokenIn: candidate.TokenIn, + }, base, now) + if err != nil { + issues = append(issues, OfferIssue{DiscountID: candidate.DiscountID.Hex(), Err: err}) + continue + } + candidate.AmountIn = liquidlane.CloneBig(base.AmountIn) + candidate.GrossAmountOut = liquidlane.CloneBig(base.GrossAmountOut) + candidate.MaxAmountOut = maxAmountOut + candidate.MinDiscount = liquidlane.CloneBig(base.MinDiscount) + candidate.ValidUntil = ValidUntil(signed) + quotes = append(quotes, candidate) + } + return quotes, issues +} + +// FindFillQuote returns the current physical quote matching a selected route and exact amount. +func FindFillQuote( + quotes []liquidlane.FillQuote, + adapter, tokenIn, tokenOut common.Address, + amountIn *big.Int, +) (liquidlane.FillQuote, bool) { + for _, quote := range quotes { + if quote.Adapter == adapter && quote.TokenIn == tokenIn && quote.TokenOut == tokenOut && + amountIn != nil && quote.AmountIn != nil && quote.AmountIn.Cmp(amountIn) == 0 { + return quote, true + } + } + return liquidlane.FillQuote{}, false +} + +// ValidUntil returns the earliest signed discount deadline. +func ValidUntil(signed *Signed) time.Time { + if signed == nil || signed.Terms.Deadline == nil || signed.ProtocolDeadline == nil { + return time.Time{} + } + deadline := signed.Terms.Deadline + if signed.ProtocolDeadline.Cmp(deadline) < 0 { + deadline = signed.ProtocolDeadline + } + return time.Unix(deadline.Int64(), 0) +} diff --git a/internal/liquidlane/discounts/signed_test.go b/internal/liquidlane/discounts/signed_test.go new file mode 100644 index 00000000..61ba75cf --- /dev/null +++ b/internal/liquidlane/discounts/signed_test.go @@ -0,0 +1,113 @@ +package discounts + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +type fakeProvider struct { + resolved *Resolved + requested string +} + +func (f *fakeProvider) ListDiscounts(context.Context) (*List, error) { + return &List{}, nil +} + +func (f *fakeProvider) Resolve(_ context.Context, discountID string) (*Resolved, error) { + f.requested = discountID + return f.resolved, nil +} + +func TestValidateSignedChecksSelectionDeadlinesAndOutput(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + baseInventory := testPhysicalInventory() + id := common.HexToHash(testOfferID) + signed := &Signed{ + DiscountID: id, + Adapter: baseInventory.Adapter, + Terms: SignedTerms{ + TokenToRedeem: baseInventory.TokenIn, + Discount: big.NewInt(100_000), + Deadline: big.NewInt(now.Add(time.Minute).Unix()), + }, + ProtocolDeadline: big.NewInt(now.Add(2 * time.Minute).Unix()), + } + base := liquidlane.FillQuote{ + Inventory: baseInventory, + AmountIn: big.NewInt(1_000), GrossAmountOut: big.NewInt(1_000), + MinDiscount: big.NewInt(100_000), + } + selection := Selection{ + DiscountID: id, Adapter: baseInventory.Adapter, TokenIn: baseInventory.TokenIn, + MinAmountOut: big.NewInt(900), + } + amountOut, err := ValidateSigned(signed, selection, base, now) + if err != nil || amountOut.Cmp(big.NewInt(900)) != 0 { + t.Fatalf("amountOut=%v err=%v", amountOut, err) + } + + selection.MinAmountOut = big.NewInt(901) + if _, err := ValidateSigned(signed, selection, base, now); err == nil { + t.Fatal("expected minimum output rejection") + } + selection.MinAmountOut = big.NewInt(900) + if _, err := ValidateSigned(signed, selection, base, now.Add(time.Minute)); err == nil { + t.Fatal("expected deadline rejection") + } +} + +func TestFindFillQuoteRequiresExactRouteAndAmount(t *testing.T) { + base := testPhysicalInventory() + quote := liquidlane.FillQuote{Inventory: base, AmountIn: big.NewInt(10)} + if _, ok := FindFillQuote( + []liquidlane.FillQuote{quote}, base.Adapter, base.TokenIn, base.TokenOut, big.NewInt(10), + ); !ok { + t.Fatal("expected matching quote") + } + if _, ok := FindFillQuote( + []liquidlane.FillQuote{quote}, base.Adapter, base.TokenIn, base.TokenOut, big.NewInt(11), + ); ok { + t.Fatal("unexpected amount mismatch") + } +} + +func TestResolveSelectedBindsFreshTermsToExactPhysicalQuote(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + base := testPhysicalInventory() + id := common.HexToHash(testOfferID) + provider := &fakeProvider{resolved: &Resolved{ + DiscountID: testOfferID, + Discount: Terms{ + Adapter: base.Adapter.Hex(), TokenToRedeem: base.TokenIn.Hex(), Discount: "100000", + Signer: common.HexToAddress("0x5555555555555555555555555555555555555555").Hex(), + Protocol: common.HexToAddress("0x6666666666666666666666666666666666666666").Hex(), + Nonce: "0x1", Deadline: now.Add(time.Minute).Unix(), + }, + SignerSignature: "0x1234", ProtocolDeadline: now.Add(time.Minute).Unix(), + ProtocolSignature: "0x5678", + }} + physical := []liquidlane.FillQuote{{ + Inventory: base, AmountIn: big.NewInt(1_000), GrossAmountOut: big.NewInt(1_000), + MaxAmountOut: big.NewInt(900), MinDiscount: big.NewInt(100_000), + }} + selection := Selection{ + DiscountID: id, Adapter: base.Adapter, TokenIn: base.TokenIn, TokenOut: base.TokenOut, + AmountIn: big.NewInt(1_000), MinAmountOut: big.NewInt(900), + } + + signed, err := ResolveSelected(context.Background(), provider, selection, physical, now) + if err != nil || signed == nil || provider.requested != testOfferID { + t.Fatalf("signed=%+v requested=%q err=%v", signed, provider.requested, err) + } + selection.AmountIn = big.NewInt(999) + if _, err := ResolveSelected(context.Background(), provider, selection, physical, now); err == nil { + t.Fatal("expected exact amount binding failure") + } +} diff --git a/internal/liquidlane/discounts/types.go b/internal/liquidlane/discounts/types.go new file mode 100644 index 00000000..4f2eadf4 --- /dev/null +++ b/internal/liquidlane/discounts/types.go @@ -0,0 +1,199 @@ +package discounts + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +const maxUint48 = int64(1<<48 - 1) + +// Offer is one validated advertised discount. It is safe to pass into solver candidate construction. +type Offer struct { + DiscountID common.Hash + Adapter common.Address + TokenToRedeem common.Address + Collateral common.Address + CollateralDecimals int + Discount *big.Int + Deadline int64 + // MaxRate is already net of Discount. The backend derives it from the + // adapter oracle output and the advertised discount terms. + MaxRate *big.Int + MaxAssets *big.Int +} + +// Signed is one validated fill-time discount with both signatures decoded. +type Signed struct { + DiscountID common.Hash + Adapter common.Address + Terms SignedTerms + + SignerSignature []byte + ProtocolDeadline *big.Int + ProtocolSignature []byte +} + +type SignedTerms struct { + TokenToRedeem common.Address + Discount *big.Int + Signer common.Address + Protocol common.Address + Nonce *big.Int + Deadline *big.Int +} + +func ParseOffer(item ListItem) (*Offer, error) { + id, err := parseHash(item.DiscountID, "discountId") + if err != nil { + return nil, err + } + adapter, err := parseAddress(item.Adapter, "adapter") + if err != nil { + return nil, err + } + tokenToRedeem, err := parseAddress(item.TokenToRedeem, "tokenToRedeem") + if err != nil { + return nil, err + } + collateral, err := parseAddress(item.Collateral, "collateral") + if err != nil { + return nil, err + } + discount, err := parseNonNegativeDecimal(item.Discount, "discount") + if err != nil { + return nil, err + } + if discount.Cmp(big.NewInt(liquidlane.DiscountPrecision)) > 0 { + return nil, errors.Errorf("discount: must be <= %d", liquidlane.DiscountPrecision) + } + maxRate, err := parsePositiveDecimal(item.MaxRate, "maxRate") + if err != nil { + return nil, err + } + maxAssets, err := parsePositiveDecimal(item.MaxAssets, "maxAssets") + if err != nil { + return nil, err + } + if item.CollateralDecimals < 0 || item.CollateralDecimals > 255 { + return nil, errors.Errorf("collateralDecimals: must be in [0,255], got %d", item.CollateralDecimals) + } + if item.Deadline <= 0 { + return nil, errors.New("deadline: must be positive") + } + return &Offer{ + DiscountID: id, Adapter: adapter, TokenToRedeem: tokenToRedeem, + Collateral: collateral, CollateralDecimals: item.CollateralDecimals, + Discount: discount, Deadline: item.Deadline, + MaxRate: maxRate, MaxAssets: maxAssets, + }, nil +} + +func ParseSigned(resolved *Resolved) (*Signed, error) { + if resolved == nil { + return nil, errors.New("resolved discount is nil") + } + id, err := parseHash(resolved.DiscountID, "discountId") + if err != nil { + return nil, err + } + adapter, err := parseAddress(resolved.Discount.Adapter, "adapter") + if err != nil { + return nil, err + } + tokenToRedeem, err := parseAddress(resolved.Discount.TokenToRedeem, "tokenToRedeem") + if err != nil { + return nil, err + } + discount, err := parseNonNegativeDecimal(resolved.Discount.Discount, "discount") + if err != nil { + return nil, err + } + if discount.Cmp(big.NewInt(liquidlane.DiscountPrecision)) > 0 { + return nil, errors.Errorf("discount: must be <= %d", liquidlane.DiscountPrecision) + } + signer, err := parseAddress(resolved.Discount.Signer, "signer") + if err != nil { + return nil, err + } + protocol, err := parseAddress(resolved.Discount.Protocol, "protocol") + if err != nil { + return nil, err + } + nonce, err := hexutil.DecodeBig(resolved.Discount.Nonce) + if err != nil { + return nil, errors.Errorf("nonce: %w", err) + } + if nonce.Sign() < 0 { + return nil, errors.New("nonce: must be non-negative") + } + signerSignature, err := hexutil.Decode(resolved.SignerSignature) + if err != nil { + return nil, errors.Errorf("signerSignature: %w", err) + } + protocolSignature, err := hexutil.Decode(resolved.ProtocolSignature) + if err != nil { + return nil, errors.Errorf("protocolSignature: %w", err) + } + if len(signerSignature) == 0 || len(protocolSignature) == 0 { + return nil, errors.New("discount signatures must not be empty") + } + if resolved.Discount.Deadline <= 0 || resolved.ProtocolDeadline <= 0 { + return nil, errors.New("discount deadlines must be positive") + } + if resolved.Discount.Deadline > maxUint48 || resolved.ProtocolDeadline > maxUint48 { + return nil, errors.New("discount deadlines exceed uint48") + } + return &Signed{ + DiscountID: id, Adapter: adapter, + Terms: SignedTerms{ + TokenToRedeem: tokenToRedeem, Discount: discount, Signer: signer, Protocol: protocol, + Nonce: nonce, Deadline: big.NewInt(resolved.Discount.Deadline), + }, + SignerSignature: signerSignature, ProtocolDeadline: big.NewInt(resolved.ProtocolDeadline), + ProtocolSignature: protocolSignature, + }, nil +} + +func parseAddress(raw, field string) (common.Address, error) { + if !common.IsHexAddress(raw) { + return common.Address{}, errors.Errorf("%s: invalid address %q", field, raw) + } + address := common.HexToAddress(raw) + if address == (common.Address{}) { + return common.Address{}, errors.Errorf("%s: zero address", field) + } + return address, nil +} + +func parseHash(raw, field string) (common.Hash, error) { + decoded, err := hexutil.Decode(raw) + if err != nil || len(decoded) != common.HashLength { + return common.Hash{}, errors.Errorf("%s: invalid bytes32 %q", field, raw) + } + hash := common.BytesToHash(decoded) + if hash == (common.Hash{}) { + return common.Hash{}, errors.Errorf("%s: zero bytes32", field) + } + return hash, nil +} + +func parsePositiveDecimal(raw, field string) (*big.Int, error) { + out, ok := new(big.Int).SetString(raw, 10) + if !ok || out.Sign() <= 0 { + return nil, errors.Errorf("%s: invalid positive decimal %q", field, raw) + } + return out, nil +} + +func parseNonNegativeDecimal(raw, field string) (*big.Int, error) { + out, ok := new(big.Int).SetString(raw, 10) + if !ok || out.Sign() < 0 { + return nil, errors.Errorf("%s: invalid non-negative decimal %q", field, raw) + } + return out, nil +} diff --git a/internal/liquidlane/discounts/types_test.go b/internal/liquidlane/discounts/types_test.go new file mode 100644 index 00000000..21fc7d82 --- /dev/null +++ b/internal/liquidlane/discounts/types_test.go @@ -0,0 +1,116 @@ +package discounts + +import ( + "strings" + "testing" +) + +func TestParseOffer(t *testing.T) { + offer, err := ParseOffer(ListItem{ + DiscountID: "0x" + hash64, + Adapter: "0x0000000000000000000000000000000000000abc", + TokenToRedeem: "0x0000000000000000000000000000000000000def", + Collateral: "0x0000000000000000000000000000000000000c01", + CollateralDecimals: 6, + Discount: "100000", + Deadline: 1_900_000_000, + MaxRate: "1000000000000000000", + MaxAssets: "5000", + }) + if err != nil { + t.Fatalf("ParseOffer: %v", err) + } + if offer.MaxAssets.String() != "5000" || offer.Discount.String() != "100000" || offer.CollateralDecimals != 6 { + t.Fatalf("offer = %+v", offer) + } +} + +func TestParseOfferRejectsInvalidDiscount(t *testing.T) { + item := ListItem{ + DiscountID: "0x" + hash64, + Adapter: "0x0000000000000000000000000000000000000abc", + TokenToRedeem: "0x0000000000000000000000000000000000000def", + Collateral: "0x0000000000000000000000000000000000000c01", + CollateralDecimals: 6, + Discount: "1000001", + Deadline: 1_900_000_000, + MaxRate: "1000000000000000000", + MaxAssets: "5000", + } + if _, err := ParseOffer(item); err == nil { + t.Fatal("expected out-of-range discount error") + } + item.Discount = "" + if _, err := ParseOffer(item); err == nil { + t.Fatal("expected missing discount error") + } +} + +func TestParseOfferRejectsMalformedIDAndExpiredShape(t *testing.T) { + item := ListItem{ + DiscountID: "0x01", + Adapter: "0x0000000000000000000000000000000000000abc", + TokenToRedeem: "0x0000000000000000000000000000000000000def", + Collateral: "0x0000000000000000000000000000000000000c01", + CollateralDecimals: 6, + Discount: "100000", + Deadline: 1_900_000_000, + MaxRate: "1", + MaxAssets: "1", + } + if _, err := ParseOffer(item); err == nil { + t.Fatal("expected malformed id error") + } + item.DiscountID = "0x" + hash64 + item.Deadline = 0 + if _, err := ParseOffer(item); err == nil { + t.Fatal("expected deadline error") + } + item.Deadline = 1_900_000_000 + item.DiscountID = "0x" + strings.Repeat("0", 64) + if _, err := ParseOffer(item); err == nil { + t.Fatal("expected zero id error") + } +} + +func TestParseSigned(t *testing.T) { + parsed, err := ParseSigned(&Resolved{ + DiscountID: "0x" + hash64, + Discount: Terms{ + Adapter: "0x0000000000000000000000000000000000000abc", + TokenToRedeem: "0x0000000000000000000000000000000000000def", + Discount: "123", + Signer: "0x0000000000000000000000000000000000000aaa", + Protocol: "0x0000000000000000000000000000000000000bbb", + Nonce: "0x2", + Deadline: 1_900_000_000, + }, + SignerSignature: "0xdead", ProtocolDeadline: 1_900_000_001, ProtocolSignature: "0xbeef", + }) + if err != nil { + t.Fatalf("ParseSigned: %v", err) + } + if parsed.Terms.Discount.String() != "123" || parsed.Terms.Nonce.String() != "2" { + t.Fatalf("parsed = %+v", parsed) + } +} + +func TestParseSignedAcceptsZeroAndRejectsOutOfRangeDiscount(t *testing.T) { + resolved := &Resolved{ + DiscountID: "0x" + hash64, + Discount: Terms{ + Adapter: "0x0000000000000000000000000000000000000abc", + TokenToRedeem: "0x0000000000000000000000000000000000000def", + Discount: "0", Signer: "0x0000000000000000000000000000000000000aaa", + Protocol: "0x0000000000000000000000000000000000000bbb", Nonce: "0x2", Deadline: 1_900_000_000, + }, + SignerSignature: "0xdead", ProtocolDeadline: 1_900_000_001, ProtocolSignature: "0xbeef", + } + if _, err := ParseSigned(resolved); err != nil { + t.Fatalf("zero discount: %v", err) + } + resolved.Discount.Discount = "1000001" + if _, err := ParseSigned(resolved); err == nil { + t.Fatal("expected out-of-range discount error") + } +} diff --git a/internal/liquidlane/gas/config.go b/internal/liquidlane/gas/config.go new file mode 100644 index 00000000..1dbbc9d5 --- /dev/null +++ b/internal/liquidlane/gas/config.go @@ -0,0 +1,68 @@ +package gas + +import ( + "strconv" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/parse" +) + +// RawConfig is the shared YAML representation of LiquidLane gas oracle configuration. +type RawConfig struct { + NativeUSDFeed string `yaml:"nativeUsdFeed"` + NativeMaxAge string `yaml:"nativeMaxAge"` + TokenUSDFeeds []RawTokenFeed `yaml:"tokenUsdFeeds"` +} + +type RawTokenFeed struct { + Token string `yaml:"token"` + Feed string `yaml:"feed"` + MaxAge string `yaml:"maxAge"` +} + +// ParseConfig validates the shared gas YAML without changing its gas.* field paths. +func ParseConfig(raw RawConfig) (OracleConfig, error) { + nativeFeed, err := parse.NonZeroAddress(raw.NativeUSDFeed, "gas.nativeUsdFeed") + if err != nil { + return OracleConfig{}, err + } + nativeMaxAge, err := parse.Duration(raw.NativeMaxAge, 0, "gas.nativeMaxAge") + if err != nil { + return OracleConfig{}, err + } + if nativeMaxAge <= 0 { + return OracleConfig{}, errors.New("gas.nativeMaxAge is required") + } + feeds := make(map[common.Address]USDFeed, len(raw.TokenUSDFeeds)) + for index, item := range raw.TokenUSDFeeds { + field := "gas.tokenUsdFeeds[" + strconv.Itoa(index) + "]" + token, tokenErr := parse.NonZeroAddress(item.Token, field+".token") + if tokenErr != nil { + return OracleConfig{}, tokenErr + } + feed, feedErr := parse.NonZeroAddress(item.Feed, field+".feed") + if feedErr != nil { + return OracleConfig{}, feedErr + } + maxAge, ageErr := parse.Duration(item.MaxAge, 0, field+".maxAge") + if ageErr != nil { + return OracleConfig{}, ageErr + } + if maxAge <= 0 { + return OracleConfig{}, errors.Errorf("%s.maxAge is required", field) + } + if _, duplicate := feeds[token]; duplicate { + return OracleConfig{}, errors.Errorf("%s.token: duplicate token %s", field, token.Hex()) + } + feeds[token] = USDFeed{Address: feed, MaxAge: maxAge} + } + if len(feeds) == 0 { + return OracleConfig{}, errors.New("gas.tokenUsdFeeds must contain at least one token feed") + } + return OracleConfig{ + NativeUSDFeed: USDFeed{Address: nativeFeed, MaxAge: nativeMaxAge}, + TokenUSDFeeds: feeds, + }, nil +} diff --git a/internal/liquidlane/gas/gas.go b/internal/liquidlane/gas/gas.go index 6cdc8ff6..1346df9c 100644 --- a/internal/liquidlane/gas/gas.go +++ b/internal/liquidlane/gas/gas.go @@ -1,9 +1,9 @@ -// Package gas predicts gas used by LiquidLane adapter swap routes. +// Package gas provides LiquidLane route gas prediction and Chainlink-backed gas conversion facts. // // It is intentionally limited to LiquidLane adapter swap accounting: callers provide // expected swap demands plus a compact adapter liquidity snapshot, and the package -// returns route labels and route gas units. Solver-specific settlement overhead, -// auction/executor gas limits, price updates, bids, and profitability stay outside. +// returns route labels and route gas units. Solver-specific settlement and payload overhead, +// auction/executor gas limits, price updates, bids, and economics stay outside. package gas const ( @@ -36,10 +36,6 @@ func RouteUnits(routes []Route) uint64 { return total } -func UnitsForRoute(route Route) uint64 { - return UnitsForRouteAt(route, false) -} - func UnitsForRouteAt(route Route, first bool) uint64 { switch route { case RouteAcquire: diff --git a/internal/liquidlane/gas/gas_test.go b/internal/liquidlane/gas/gas_test.go index c72007be..6554c709 100644 --- a/internal/liquidlane/gas/gas_test.go +++ b/internal/liquidlane/gas/gas_test.go @@ -82,6 +82,66 @@ func TestPredictionConsumesSharedBudgets(t *testing.T) { } } +func TestPredictAdaptersSharesVaultStateAndKeepsFirstSwapTierPerAdapter(t *testing.T) { + adapterA := common.HexToAddress("0x00000000000000000000000000000000000000a1") + adapterB := common.HexToAddress("0x00000000000000000000000000000000000000b1") + vault := common.HexToAddress("0x00000000000000000000000000000000000000f1") + coll := common.HexToAddress("0x00000000000000000000000000000000000000ca") + snapshot := &Snapshot{ + Adapters: map[common.Address]*AdapterState{ + adapterA: {Vault: vault, Acquire: map[common.Address]*big.Int{coll: big.NewInt(100)}}, + adapterB: {Vault: vault, Acquire: map[common.Address]*big.Int{}}, + }, + Vaults: map[common.Address]*VaultState{ + vault: {FreeAssets: big.NewInt(100), Withdrawable: big.NewInt(200)}, + }, + } + prediction := PredictAdapters([]AdapterDemand{ + {Adapter: adapterA, Vault: vault, Demand: Demand{Collateral: coll, AmountOut: big.NewInt(60)}}, + {Adapter: adapterB, Vault: vault, Demand: Demand{Collateral: coll, AmountOut: big.NewInt(60)}}, + {Adapter: adapterA, Vault: vault, Demand: Demand{Collateral: coll, AmountOut: big.NewInt(110)}}, + }, snapshot) + want := UnitsForRouteAt(RouteAcquire, true) + + UnitsForRouteAt(RouteAllocate, true) + + UnitsForRouteAt(RouteDeallocate, false) + if prediction.Units != want { + t.Fatalf("units = %d, want %d", prediction.Units, want) + } + if got := RoutesString(prediction.Routes); got != "acquire,allocate,deallocate" { + t.Fatalf("routes = %q", got) + } + if snapshot.Adapters[adapterA].Acquire[coll].String() != "100" || snapshot.Vaults[vault].FreeAssets.String() != "100" { + t.Fatalf("PredictAdapters mutated input snapshot: %+v", snapshot) + } +} + +func TestWithReserveBpsPricesNextRouteNearBoundary(t *testing.T) { + adapter := common.HexToAddress("0x00000000000000000000000000000000000000a1") + vault := common.HexToAddress("0x00000000000000000000000000000000000000f1") + coll := common.HexToAddress("0x00000000000000000000000000000000000000ca") + snapshot := &Snapshot{ + Adapters: map[common.Address]*AdapterState{ + adapter: {Vault: vault, Acquire: map[common.Address]*big.Int{}}, + }, + Vaults: map[common.Address]*VaultState{ + vault: {FreeAssets: big.NewInt(100), Withdrawable: big.NewInt(200)}, + }, + } + demands := []AdapterDemand{{ + Adapter: adapter, Vault: vault, Demand: Demand{Collateral: coll, AmountOut: big.NewInt(95)}, + }} + if got := RoutesString(PredictAdapters(demands, snapshot).Routes); got != "allocate" { + t.Fatalf("unreserved routes = %q", got) + } + reserved := WithReserveBps(snapshot, 1_000) + if got := RoutesString(PredictAdapters(demands, reserved).Routes); got != "deallocate" { + t.Fatalf("reserved routes = %q", got) + } + if snapshot.Vaults[vault].FreeAssets.String() != "100" { + t.Fatalf("WithReserveBps mutated input snapshot: %+v", snapshot) + } +} + func demandsFor(coll common.Address, outs ...int64) []Demand { demands := make([]Demand, len(outs)) for i, out := range outs { diff --git a/internal/liquidlane/gas/oracle.go b/internal/liquidlane/gas/oracle.go new file mode 100644 index 00000000..a5b95adf --- /dev/null +++ b/internal/liquidlane/gas/oracle.go @@ -0,0 +1,243 @@ +package gas + +import ( + "context" + "encoding/json" + "math/big" + "slices" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/api/bindings/chainlink/aggregator" + "github.com/symbioticfi/vault-solver/internal/chain" +) + +const maxOracleDecimals = 36 + +var chainlinkFeed = aggregator.NewAggregatorV3() + +type OracleConfig struct { + NativeUSDFeed USDFeed + TokenUSDFeeds map[common.Address]USDFeed +} + +type USDFeed struct { + Address common.Address + MaxAge time.Duration +} + +type Token struct { + Address common.Address + Decimals int +} + +type PriceSnapshot struct { + tokenOutPerNative map[common.Address]*big.Int +} + +func NewPriceSnapshot(rates map[common.Address]*big.Int) *PriceSnapshot { + out := make(map[common.Address]*big.Int, len(rates)) + for token, rate := range rates { + if rate != nil { + out[token] = new(big.Int).Set(rate) + } + } + return &PriceSnapshot{tokenOutPerNative: out} +} + +func (s *PriceSnapshot) TokenOutPerNative(token common.Address) *big.Int { + if s == nil || s.tokenOutPerNative[token] == nil { + return nil + } + return new(big.Int).Set(s.tokenOutPerNative[token]) +} + +func (s *PriceSnapshot) MarshalJSON() ([]byte, error) { + rates := map[common.Address]*big.Int(nil) + if s != nil { + rates = s.tokenOutPerNative + } + return json.Marshal(struct { + TokenOutPerNative map[common.Address]*big.Int `json:"tokenOutPerNative"` + }{TokenOutPerNative: rates}) +} + +type multicaller interface { + Multicall(ctx context.Context, calls []chain.Call) ([]chain.CallResult, error) +} + +type OracleReader struct { + chain multicaller + cfg OracleConfig +} + +func NewOracleReader(c multicaller, cfg OracleConfig) (*OracleReader, error) { + if c == nil { + return nil, errors.New("gas oracle: chain client is required") + } + if cfg.NativeUSDFeed.Address == (common.Address{}) { + return nil, errors.New("gas oracle: native USD feed is required") + } + if cfg.NativeUSDFeed.MaxAge <= 0 { + return nil, errors.New("gas oracle: native USD feed max age must be positive") + } + if len(cfg.TokenUSDFeeds) == 0 { + return nil, errors.New("gas oracle: at least one token USD feed is required") + } + feeds := make(map[common.Address]USDFeed, len(cfg.TokenUSDFeeds)) + for token, feed := range cfg.TokenUSDFeeds { + if token == (common.Address{}) || feed.Address == (common.Address{}) { + return nil, errors.New("gas oracle: token and feed addresses must be non-zero") + } + if feed.MaxAge <= 0 { + return nil, errors.Errorf("gas oracle: token %s feed max age must be positive", token.Hex()) + } + feeds[token] = feed + } + cfg.TokenUSDFeeds = feeds + return &OracleReader{chain: c, cfg: cfg}, nil +} + +func (r *OracleReader) ValidateTokens(tokens []Token) error { + decimals := make(map[common.Address]int, len(tokens)) + for _, token := range tokens { + if token.Address == (common.Address{}) { + return errors.New("gas oracle: token address must be non-zero") + } + if current, ok := decimals[token.Address]; ok && current != token.Decimals { + return errors.Errorf("gas oracle: token %s has inconsistent decimals %d and %d", + token.Address.Hex(), current, token.Decimals) + } + decimals[token.Address] = token.Decimals + } + for _, token := range uniqueTokens(tokens) { + if token.Decimals < 0 || token.Decimals > maxOracleDecimals { + return errors.Errorf("gas oracle: token %s decimals %d exceed supported range [0,%d]", + token.Address.Hex(), token.Decimals, maxOracleDecimals) + } + if r.cfg.TokenUSDFeeds[token.Address].Address == (common.Address{}) { + return errors.Errorf("gas oracle: missing USD feed for token %s", token.Address.Hex()) + } + } + return nil +} + +func (r *OracleReader) Read(ctx context.Context, tokens []Token, now time.Time) (*PriceSnapshot, error) { + if err := r.ValidateTokens(tokens); err != nil { + return nil, err + } + tokens = uniqueTokens(tokens) + calls := make([]chain.Call, 0, 2+2*len(tokens)) + calls = appendFeedCalls(calls, r.cfg.NativeUSDFeed.Address) + for _, token := range tokens { + calls = appendFeedCalls(calls, r.cfg.TokenUSDFeeds[token.Address].Address) + } + results, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, errors.Errorf("gas oracle: multicall: %w", err) + } + if len(results) != len(calls) { + return nil, errors.Errorf("gas oracle: got %d results, want %d", len(results), len(calls)) + } + native, err := decodeFeed( + results[:2], r.cfg.NativeUSDFeed.Address, now, r.cfg.NativeUSDFeed.MaxAge, + ) + if err != nil { + return nil, err + } + rates := make(map[common.Address]*big.Int, len(tokens)) + for i, token := range tokens { + feed := r.cfg.TokenUSDFeeds[token.Address] + price, decodeErr := decodeFeed(results[2+i*2:4+i*2], feed.Address, now, feed.MaxAge) + if decodeErr != nil { + return nil, decodeErr + } + rate := tokenPerNative(native, price, token.Decimals) + if rate.Sign() <= 0 { + return nil, errors.Errorf("gas oracle: token/native rate for %s rounded to zero", token.Address.Hex()) + } + rates[token.Address] = rate + } + return NewPriceSnapshot(rates), nil +} + +type feedPrice struct { + answer *big.Int + decimals uint8 +} + +func appendFeedCalls(calls []chain.Call, feed common.Address) []chain.Call { + return append(calls, + chain.Call{Target: feed, AllowFailure: true, Data: chainlinkFeed.PackLatestRoundData()}, + chain.Call{Target: feed, AllowFailure: true, Data: chainlinkFeed.PackDecimals()}, + ) +} + +func decodeFeed(results []chain.CallResult, feed common.Address, now time.Time, maxAge time.Duration) (feedPrice, error) { + if len(results) != 2 || !results[0].Success || !results[1].Success { + return feedPrice{}, errors.Errorf("gas oracle: feed %s call failed", feed.Hex()) + } + round, err := chainlinkFeed.UnpackLatestRoundData(results[0].ReturnData) + if err != nil { + return feedPrice{}, errors.Errorf("gas oracle: feed %s latestRoundData: %w", feed.Hex(), err) + } + decimals, err := chainlinkFeed.UnpackDecimals(results[1].ReturnData) + if err != nil { + return feedPrice{}, errors.Errorf("gas oracle: feed %s decimals: %w", feed.Hex(), err) + } + if round.RoundId == nil || round.Answer == nil || round.UpdatedAt == nil { + return feedPrice{}, errors.Errorf("gas oracle: feed %s returned nil round data", feed.Hex()) + } + if round.RoundId.Sign() <= 0 || round.Answer.Sign() <= 0 || + round.UpdatedAt.Sign() <= 0 || !round.UpdatedAt.IsInt64() { + return feedPrice{}, errors.Errorf("gas oracle: feed %s returned invalid round data", feed.Hex()) + } + const maxFutureSkewSeconds = 15 + age := now.Unix() - round.UpdatedAt.Int64() + if age < -maxFutureSkewSeconds { + return feedPrice{}, errors.Errorf( + "gas oracle: feed %s updated %ds in the future", feed.Hex(), -age, + ) + } + // A new Ethereum block can land between the caller's timestamp read and this latest-state + // multicall. Accept only that small race, not arbitrary future timestamps. + age = max(age, 0) + maxAgeSeconds := int64(maxAge / time.Second) + if maxAge%time.Second != 0 { + maxAgeSeconds++ + } + if age > maxAgeSeconds { + return feedPrice{}, errors.Errorf("gas oracle: feed %s is stale: age %ds, max %ds", feed.Hex(), age, maxAgeSeconds) + } + if decimals > maxOracleDecimals { + return feedPrice{}, errors.Errorf("gas oracle: feed %s decimals %d exceed %d", feed.Hex(), decimals, maxOracleDecimals) + } + return feedPrice{answer: new(big.Int).Set(round.Answer), decimals: decimals}, nil +} + +func tokenPerNative(native, token feedPrice, tokenDecimals int) *big.Int { + numerator := new(big.Int).Mul(native.answer, pow10(int(token.decimals)+tokenDecimals)) + denominator := new(big.Int).Mul(token.answer, pow10(int(native.decimals))) + return numerator.Div(numerator, denominator) +} + +func uniqueTokens(tokens []Token) []Token { + byAddress := make(map[common.Address]Token, len(tokens)) + for _, token := range tokens { + if current, ok := byAddress[token.Address]; !ok || token.Decimals > current.Decimals { + byAddress[token.Address] = token + } + } + out := make([]Token, 0, len(byAddress)) + for _, token := range byAddress { + out = append(out, token) + } + slices.SortFunc(out, func(a, b Token) int { return a.Address.Cmp(b.Address) }) + return out +} + +func pow10(decimals int) *big.Int { + return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil) +} diff --git a/internal/liquidlane/gas/oracle_test.go b/internal/liquidlane/gas/oracle_test.go new file mode 100644 index 00000000..a2e8060a --- /dev/null +++ b/internal/liquidlane/gas/oracle_test.go @@ -0,0 +1,185 @@ +package gas + +import ( + "context" + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/api/bindings/chainlink/aggregator" + "github.com/symbioticfi/vault-solver/internal/chain" +) + +type oracleMulticaller struct { + results []chain.CallResult +} + +func (f oracleMulticaller) Multicall(context.Context, []chain.Call) ([]chain.CallResult, error) { + return f.results, nil +} + +func TestOracleReaderComposesTokenPerNative(t *testing.T) { + nativeFeed := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenFeed := common.HexToAddress("0x2222222222222222222222222222222222222222") + token := common.HexToAddress("0x3333333333333333333333333333333333333333") + now := time.Unix(1_800_000_000, 0) + reader, err := NewOracleReader(oracleMulticaller{results: []chain.CallResult{ + oracleRoundResult(t, 2000_00000000, now.Unix()), oracleDecimalsResult(t), + oracleRoundResult(t, 2_00000000, now.Unix()), oracleDecimalsResult(t), + }}, OracleConfig{ + NativeUSDFeed: USDFeed{Address: nativeFeed, MaxAge: time.Minute}, + TokenUSDFeeds: map[common.Address]USDFeed{ + token: {Address: tokenFeed, MaxAge: time.Minute}, + }, + }) + if err != nil { + t.Fatalf("NewOracleReader: %v", err) + } + snapshot, err := reader.Read(t.Context(), []Token{{Address: token, Decimals: 6}}, now) + if err != nil { + t.Fatalf("Read: %v", err) + } + if got := snapshot.TokenOutPerNative(token); got == nil || got.String() != "1000000000" { + t.Fatalf("token per native = %v, want 1000000000", got) + } +} + +func TestOracleReaderRejectsMissingAndStaleFeeds(t *testing.T) { + nativeFeed := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenFeed := common.HexToAddress("0x2222222222222222222222222222222222222222") + token := common.HexToAddress("0x3333333333333333333333333333333333333333") + now := time.Unix(1_800_000_000, 0) + reader, err := NewOracleReader(oracleMulticaller{results: []chain.CallResult{ + oracleRoundResult(t, 2000_00000000, now.Add(-2*time.Minute).Unix()), oracleDecimalsResult(t), + oracleRoundResult(t, 2_00000000, now.Unix()), oracleDecimalsResult(t), + }}, OracleConfig{ + NativeUSDFeed: USDFeed{Address: nativeFeed, MaxAge: time.Minute}, + TokenUSDFeeds: map[common.Address]USDFeed{ + token: {Address: tokenFeed, MaxAge: time.Minute}, + }, + }) + if err != nil { + t.Fatalf("NewOracleReader: %v", err) + } + if err := reader.ValidateTokens([]Token{{Address: common.HexToAddress("0x4444444444444444444444444444444444444444"), Decimals: 6}}); err == nil { + t.Fatal("expected missing token feed error") + } + if _, err := reader.Read(t.Context(), []Token{{Address: token, Decimals: 6}}, now); err == nil || + !strings.Contains(err.Error(), "stale") { + t.Fatalf("stale Read error = %v", err) + } +} + +func TestOracleReaderAcceptsFeedUpdatedInNewerBlock(t *testing.T) { + nativeFeed := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenFeed := common.HexToAddress("0x2222222222222222222222222222222222222222") + token := common.HexToAddress("0x3333333333333333333333333333333333333333") + now := time.Unix(1_800_000_000, 0) + reader, err := NewOracleReader(oracleMulticaller{results: []chain.CallResult{ + oracleRoundResult(t, 2000_00000000, now.Add(12*time.Second).Unix()), oracleDecimalsResult(t), + oracleRoundResult(t, 2_00000000, now.Add(12*time.Second).Unix()), oracleDecimalsResult(t), + }}, OracleConfig{ + NativeUSDFeed: USDFeed{Address: nativeFeed, MaxAge: time.Minute}, + TokenUSDFeeds: map[common.Address]USDFeed{ + token: {Address: tokenFeed, MaxAge: time.Minute}, + }, + }) + if err != nil { + t.Fatalf("NewOracleReader: %v", err) + } + if _, err := reader.Read(t.Context(), []Token{{Address: token, Decimals: 6}}, now); err != nil { + t.Fatalf("Read: %v", err) + } +} + +func TestOracleReaderRejectsFeedFarInTheFuture(t *testing.T) { + nativeFeed := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenFeed := common.HexToAddress("0x2222222222222222222222222222222222222222") + token := common.HexToAddress("0x3333333333333333333333333333333333333333") + now := time.Unix(1_800_000_000, 0) + reader, err := NewOracleReader(oracleMulticaller{results: []chain.CallResult{ + oracleRoundResult(t, 2000_00000000, now.Add(time.Minute).Unix()), oracleDecimalsResult(t), + oracleRoundResult(t, 2_00000000, now.Unix()), oracleDecimalsResult(t), + }}, OracleConfig{ + NativeUSDFeed: USDFeed{Address: nativeFeed, MaxAge: time.Minute}, + TokenUSDFeeds: map[common.Address]USDFeed{ + token: {Address: tokenFeed, MaxAge: time.Minute}, + }, + }) + if err != nil { + t.Fatalf("NewOracleReader: %v", err) + } + if _, err := reader.Read(t.Context(), []Token{{Address: token, Decimals: 6}}, now); err == nil || + !strings.Contains(err.Error(), "in the future") { + t.Fatalf("future Read error = %v", err) + } +} + +func TestOracleReaderIgnoresDeprecatedAnsweredInRound(t *testing.T) { + nativeFeed := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenFeed := common.HexToAddress("0x2222222222222222222222222222222222222222") + token := common.HexToAddress("0x3333333333333333333333333333333333333333") + now := time.Unix(1_800_000_000, 0) + reader, err := NewOracleReader(oracleMulticaller{results: []chain.CallResult{ + oracleRoundResultWithAnsweredInRound(t, 2000_00000000, now.Unix(), 0), oracleDecimalsResult(t), + oracleRoundResultWithAnsweredInRound(t, 2_00000000, now.Unix(), 0), oracleDecimalsResult(t), + }}, OracleConfig{ + NativeUSDFeed: USDFeed{Address: nativeFeed, MaxAge: time.Minute}, + TokenUSDFeeds: map[common.Address]USDFeed{ + token: {Address: tokenFeed, MaxAge: time.Minute}, + }, + }) + if err != nil { + t.Fatalf("NewOracleReader: %v", err) + } + if _, err := reader.Read(t.Context(), []Token{{Address: token, Decimals: 6}}, now); err != nil { + t.Fatalf("Read: %v", err) + } +} + +func oracleRoundResult(t *testing.T, answer, updatedAt int64) chain.CallResult { + t.Helper() + return oracleRoundResultWithAnsweredInRound(t, answer, updatedAt, 10) +} + +func oracleRoundResultWithAnsweredInRound( + t *testing.T, + answer, updatedAt, answeredInRound int64, +) chain.CallResult { + t.Helper() + parsed := oracleABI(t) + data, err := parsed.Methods["latestRoundData"].Outputs.Pack( + big.NewInt(10), + big.NewInt(answer), + big.NewInt(updatedAt-1), + big.NewInt(updatedAt), + big.NewInt(answeredInRound), + ) + if err != nil { + t.Fatalf("pack latestRoundData: %v", err) + } + return chain.CallResult{Success: true, ReturnData: data} +} + +func oracleDecimalsResult(t *testing.T) chain.CallResult { + t.Helper() + parsed := oracleABI(t) + data, err := parsed.Methods["decimals"].Outputs.Pack(uint8(8)) + if err != nil { + t.Fatalf("pack decimals: %v", err) + } + return chain.CallResult{Success: true, ReturnData: data} +} + +func oracleABI(t *testing.T) abi.ABI { + t.Helper() + parsed, err := abi.JSON(strings.NewReader(aggregator.AggregatorV3MetaData.ABI)) + if err != nil { + t.Fatalf("parse AggregatorV3 ABI: %v", err) + } + return parsed +} diff --git a/internal/liquidlane/gas/routes.go b/internal/liquidlane/gas/routes.go index 3808188e..e8682f9c 100644 --- a/internal/liquidlane/gas/routes.go +++ b/internal/liquidlane/gas/routes.go @@ -24,12 +24,38 @@ type State struct { Acquire map[common.Address]*big.Int } +// AdapterState contains liquidity owned by one adapter. +type AdapterState struct { + Vault common.Address `json:"vault"` + Acquire map[common.Address]*big.Int `json:"acquire"` +} + +// VaultState contains liquidity shared by every adapter backed by the vault. +type VaultState struct { + FreeAssets *big.Int `json:"freeAssets"` + Withdrawable *big.Int `json:"withdrawable"` +} + +// Snapshot separates adapter-local acquire balances from shared vault liquidity. +type Snapshot struct { + Adapters map[common.Address]*AdapterState `json:"adapters"` + Vaults map[common.Address]*VaultState `json:"vaults"` +} + // Demand is one expected loan-token output from a swap through a LiquidLane adapter. type Demand struct { Collateral common.Address AmountOut *big.Int } +// AdapterDemand is one expected swap output scoped to its LiquidLane adapter and shared vault. +type AdapterDemand struct { + Demand + + Adapter common.Address + Vault common.Address +} + // PredictRoutes estimates the adapter route for each demand in order. func PredictRoutes(demands []Demand, st *State) []Route { if len(demands) == 0 { @@ -54,6 +80,93 @@ func PredictRoutes(demands []Demand, st *State) []Route { return routes } +// PredictAdapters predicts swap routes for a multi-adapter transaction. Acquire balances are consumed +// per adapter while free and withdrawable liquidity is consumed once across adapters sharing a vault. +func PredictAdapters(demands []AdapterDemand, snapshot *Snapshot) Prediction { + if len(demands) == 0 { + return Prediction{} + } + adapters, vaults := cloneSnapshot(snapshot) + seen := make(map[common.Address]bool, len(adapters)) + routes := make([]Route, 0, len(demands)) + var units uint64 + for _, demand := range demands { + route := RouteUnknown + adapterState := adapters[demand.Adapter] + vaultState := vaults[demand.Vault] + if adapterState != nil && adapterState.Vault == demand.Vault && vaultState != nil { + route = predictRoute( + demand.AmountOut, + demand.Collateral, + adapterState.Acquire, + vaultState.FreeAssets, + vaultState.Withdrawable, + ) + } + routes = append(routes, route) + first := !seen[demand.Adapter] + seen[demand.Adapter] = true + units = saturatingAddUint64(units, UnitsForRouteAt(route, first)) + } + return Prediction{Units: units, Routes: routes} +} + +// WithReserveBps returns a conservative copy of snapshot with every mutable liquidity budget reduced. +func WithReserveBps(snapshot *Snapshot, reserveBps int) *Snapshot { + adapters, vaults := cloneSnapshot(snapshot) + if reserveBps <= 0 { + return &Snapshot{Adapters: adapters, Vaults: vaults} + } + if reserveBps > 10_000 { + reserveBps = 10_000 + } + remainingBps := int64(10_000 - reserveBps) + for _, state := range adapters { + for token, amount := range state.Acquire { + state.Acquire[token] = applyBpsDown(amount, remainingBps) + } + } + for _, state := range vaults { + state.FreeAssets = applyBpsDown(state.FreeAssets, remainingBps) + state.Withdrawable = applyBpsDown(state.Withdrawable, remainingBps) + } + return &Snapshot{Adapters: adapters, Vaults: vaults} +} + +func cloneSnapshot(snapshot *Snapshot) (map[common.Address]*AdapterState, map[common.Address]*VaultState) { + if snapshot == nil { + return nil, nil + } + adapters := make(map[common.Address]*AdapterState, len(snapshot.Adapters)) + for address, state := range snapshot.Adapters { + if state == nil { + continue + } + acquire := make(map[common.Address]*big.Int, len(state.Acquire)) + for token, amount := range state.Acquire { + acquire[token] = cloneBig(amount) + } + adapters[address] = &AdapterState{Vault: state.Vault, Acquire: acquire} + } + vaults := make(map[common.Address]*VaultState, len(snapshot.Vaults)) + for address, state := range snapshot.Vaults { + if state == nil || state.FreeAssets == nil || state.Withdrawable == nil { + continue + } + vaults[address] = &VaultState{ + FreeAssets: cloneBig(state.FreeAssets), Withdrawable: cloneBig(state.Withdrawable), + } + } + return adapters, vaults +} + +func applyBpsDown(amount *big.Int, bps int64) *big.Int { + if amount == nil || amount.Sign() <= 0 || bps <= 0 { + return new(big.Int) + } + return new(big.Int).Div(new(big.Int).Mul(amount, big.NewInt(bps)), big.NewInt(10_000)) +} + func predictRoute(amountOut *big.Int, collateral common.Address, acquire map[common.Address]*big.Int, free, withdrawable *big.Int) Route { if amountOut == nil || amountOut.Sign() <= 0 || free == nil || withdrawable == nil { return RouteUnknown diff --git a/internal/liquidlane/math.go b/internal/liquidlane/math.go new file mode 100644 index 00000000..b1f4884d --- /dev/null +++ b/internal/liquidlane/math.go @@ -0,0 +1,75 @@ +package liquidlane + +import "math/big" + +var rateScale = new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil) + +// MulDivUp returns ceil(left * right / denominator), or zero for invalid input. +func MulDivUp(left, right, denominator *big.Int) *big.Int { + if left == nil || right == nil || denominator == nil || + left.Sign() <= 0 || right.Sign() <= 0 || denominator.Sign() <= 0 { + return new(big.Int) + } + numerator := new(big.Int).Mul(left, right) + quotient, remainder := new(big.Int).QuoRem(numerator, denominator, new(big.Int)) + if remainder.Sign() != 0 { + quotient.Add(quotient, big.NewInt(1)) + } + return quotient +} + +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, tokenInDecimals, tokenOutDecimals int) *big.Int { + if amountIn == nil || rate == nil || amountIn.Sign() <= 0 || rate.Sign() <= 0 { + return new(big.Int) + } + num := new(big.Int).Mul(amountIn, rate) + num.Mul(num, pow10(tokenOutDecimals)) + den := new(big.Int).Mul(rateScale, pow10(tokenInDecimals)) + return num.Div(num, den) +} + +func MaxAmountInForRate(maxAssets, rate *big.Int, tokenInDecimals, tokenOutDecimals int) *big.Int { + if maxAssets == nil || rate == nil || maxAssets.Sign() <= 0 || rate.Sign() <= 0 { + return new(big.Int) + } + den := new(big.Int).Mul(rate, pow10(tokenOutDecimals)) + num := new(big.Int).Mul(maxAssets, rateScale) + num.Mul(num, pow10(tokenInDecimals)) + return num.Div(num, den) +} + +func MinAmountInForAmountOut(amountOut, rate *big.Int, tokenInDecimals, tokenOutDecimals int) *big.Int { + if amountOut == nil || rate == nil || amountOut.Sign() <= 0 || rate.Sign() <= 0 { + return new(big.Int) + } + den := new(big.Int).Mul(rate, pow10(tokenOutDecimals)) + num := new(big.Int).Mul(amountOut, rateScale) + num.Mul(num, pow10(tokenInDecimals)) + num.Add(num, new(big.Int).Sub(den, big.NewInt(1))) + return num.Div(num, den) +} + +func RateForAmountOut(amountOut, amountIn *big.Int, tokenInDecimals, tokenOutDecimals int) *big.Int { + if amountOut == nil || amountIn == nil || amountOut.Sign() <= 0 || amountIn.Sign() <= 0 { + return new(big.Int) + } + num := new(big.Int).Mul(amountOut, rateScale) + num.Mul(num, pow10(tokenInDecimals)) + den := new(big.Int).Mul(amountIn, pow10(tokenOutDecimals)) + return num.Div(num, den) +} + +// AmountOutAfterDiscount applies a LiquidLane ppm discount, rounding down. +func AmountOutAfterDiscount(grossAmountOut, discount *big.Int) *big.Int { + precision := big.NewInt(DiscountPrecision) + if grossAmountOut == nil || grossAmountOut.Sign() <= 0 || discount == nil || discount.Sign() < 0 || + discount.Cmp(precision) > 0 { + return new(big.Int) + } + multiplier := new(big.Int).Sub(precision, discount) + return new(big.Int).Div(new(big.Int).Mul(grossAmountOut, multiplier), big.NewInt(DiscountPrecision)) +} diff --git a/internal/liquidlane/math_test.go b/internal/liquidlane/math_test.go new file mode 100644 index 00000000..46709734 --- /dev/null +++ b/internal/liquidlane/math_test.go @@ -0,0 +1,89 @@ +package liquidlane + +import ( + "math/big" + "testing" +) + +func mustBig(t *testing.T, raw string) *big.Int { + t.Helper() + n, ok := new(big.Int).SetString(raw, 10) + if !ok { + t.Fatalf("invalid integer %q", raw) + } + return n +} + +func TestRateMathAcrossDecimals(t *testing.T) { + rate := mustBig(t, "1000000000000000000") + amountIn := mustBig(t, "1000000000000000000") + amountOut := AmountOutForRate(amountIn, rate, 18, 6) + if amountOut.String() != "1000000" { + t.Fatalf("amountOut = %s", amountOut) + } + if got := RateForAmountOut(amountOut, amountIn, 18, 6); got.Cmp(rate) != 0 { + t.Fatalf("rate = %s", got) + } + if got := MaxAmountInForRate(amountOut, rate, 18, 6); got.Cmp(amountIn) != 0 { + t.Fatalf("max amountIn = %s", got) + } +} + +func TestMinAmountInForAmountOutRoundsUp(t *testing.T) { + got := MinAmountInForAmountOut( + big.NewInt(1), + mustBig(t, "3000000000000000000"), + 18, + 6, + ) + if got.String() != "333333333334" { + t.Fatalf("min amountIn = %s", got) + } +} + +func TestMulDivUp(t *testing.T) { + tests := map[string]struct { + left, right, denominator *big.Int + want string + }{ + "exact": {left: big.NewInt(6), right: big.NewInt(2), denominator: big.NewInt(3), want: "4"}, + "round up": {left: big.NewInt(5), right: big.NewInt(2), denominator: big.NewInt(3), want: "4"}, + "invalid": {left: nil, right: big.NewInt(1), denominator: big.NewInt(1), want: "0"}, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if got := MulDivUp(tt.left, tt.right, tt.denominator).String(); got != tt.want { + t.Fatalf("MulDivUp() = %s, want %s", got, tt.want) + } + }) + } +} + +func TestRateMathRejectsInvalidInput(t *testing.T) { + if AmountOutForRate(nil, big.NewInt(1), 18, 6).Sign() != 0 { + t.Fatal("nil amount must produce zero") + } + if RateForAmountOut(big.NewInt(1), big.NewInt(0), 18, 6).Sign() != 0 { + t.Fatal("zero input must produce zero") + } +} + +func TestAmountOutAfterDiscount(t *testing.T) { + tests := map[string]struct { + gross *big.Int + discount *big.Int + want string + }{ + "zero": {gross: big.NewInt(1_000), discount: big.NewInt(0), want: "1000"}, + "ten percent": {gross: big.NewInt(1_000), discount: big.NewInt(100_000), want: "900"}, + "full discount": {gross: big.NewInt(1_000), discount: big.NewInt(DiscountPrecision), want: "0"}, + "invalid": {gross: big.NewInt(1_000), discount: big.NewInt(DiscountPrecision + 1), want: "0"}, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if got := AmountOutAfterDiscount(tt.gross, tt.discount).String(); got != tt.want { + t.Fatalf("AmountOutAfterDiscount() = %s, want %s", got, tt.want) + } + }) + } +} diff --git a/internal/liquidlane/reader.go b/internal/liquidlane/reader.go new file mode 100644 index 00000000..0d37e4ca --- /dev/null +++ b/internal/liquidlane/reader.go @@ -0,0 +1,944 @@ +package liquidlane + +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/api/bindings/erc4626" + "github.com/symbioticfi/vault-solver/api/bindings/lens" + "github.com/symbioticfi/vault-solver/api/bindings/liquidlane/adapter" + "github.com/symbioticfi/vault-solver/api/bindings/vaultv2" + "github.com/symbioticfi/vault-solver/internal/chain" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" +) + +const ( + DefaultMaxTokensPerAdapter = 64 + inventoryReadsPerRoute = 4 + fillReadsPerRoute = 4 +) + +var ( + llAdapter = adapter.NewLiquidLaneAdapter() + erc4626b = erc4626.NewIERC4626() + vaultV2b = vaultv2.NewIVaultV2() + lensB = lens.NewFrontendLiquidityLens() +) + +type Reader struct { + chain liquidLaneBackend + log logr.Logger + dec decimalsReader + + chainID int64 + maxTokensPerAdapter int + // lens is the FrontendLiquidityLens address. When non-zero, swappable headroom is read from the lens's + // cross-adapter deallocation-cascade estimate instead of the adapter's own getMaxAssets(tokenToRedeem); + // zero falls back to the adapter getter. + lens common.Address +} + +type gasAdapterState struct { + owner common.Address + marketMaker common.Address + state *liquidlanegas.AdapterState +} + +type liquidLaneBackend interface { + ChainID() *big.Int + Multicall(ctx context.Context, calls []chain.Call) ([]chain.CallResult, error) +} + +type decimalsReader interface { + Get(ctx context.Context, token common.Address) (int, error) +} + +func NewReader(c *chain.Client, log logr.Logger, liquidityLens common.Address) *Reader { + return &Reader{ + chain: c, + log: log, + dec: chain.NewDecimals(c), + chainID: c.ChainID().Int64(), + maxTokensPerAdapter: DefaultMaxTokensPerAdapter, + lens: liquidityLens, + } +} + +// maxAssetsCall builds the getMaxAssets sub-call for a route: via the lens when configured (which models +// the delegator's cross-adapter deallocation cascade the adapter's own getter overstates), else the +// adapter itself. Both return a single uint256, so the result unpacks identically via +// llAdapter.UnpackGetMaxAssets regardless of source. +func (r *Reader) maxAssetsCall(adapterAddr, tokenToRedeem common.Address) chain.Call { + if r.lens != (common.Address{}) { + return chain.Call{Target: r.lens, AllowFailure: true, Data: lensB.PackGetMaxAssets0(adapterAddr, tokenToRedeem)} + } + return chain.Call{Target: adapterAddr, AllowFailure: true, Data: llAdapter.PackGetMaxAssets(tokenToRedeem)} +} + +// TokenDecimals returns the cached ERC-20 decimals used to build typed routes +// when an upstream protocol omits input-token metadata. +func (r *Reader) TokenDecimals(ctx context.Context, token common.Address) (int, error) { + return r.dec.Get(ctx, token) +} + +func (r *Reader) ResolveAdapters(ctx context.Context, adapters []common.Address) ([]Adapter, error) { + adapters = dedupeAddresses(adapters) + if len(adapters) == 0 { + return nil, nil + } + + vaultCalls := make([]chain.Call, len(adapters)) + for i, a := range adapters { + vaultCalls[i] = chain.Call{Target: a, AllowFailure: true, Data: llAdapter.PackVault()} + } + vaultResults, err := r.chain.Multicall(ctx, vaultCalls) + if err != nil { + return nil, err + } + if len(vaultResults) != len(vaultCalls) { + return nil, errors.Errorf( + "liquidlane: vault multicall: got %d results, want %d", + len(vaultResults), + len(vaultCalls), + ) + } + + out := make([]Adapter, len(adapters)) + assetCalls := make([]chain.Call, len(adapters)) + for i := range adapters { + out[i].Adapter = adapters[i] + if !vaultResults[i].Success { + return nil, errors.Errorf("liquidlane: resolve adapter %s vault: call failed", adapters[i].Hex()) + } + vault, unpackErr := llAdapter.UnpackVault(vaultResults[i].ReturnData) + if unpackErr != nil { + return nil, errors.Errorf("liquidlane: resolve adapter %s vault: %w", adapters[i].Hex(), unpackErr) + } + if vault == (common.Address{}) { + return nil, errors.Errorf("liquidlane: resolve adapter %s vault: zero address", adapters[i].Hex()) + } + out[i].Vault = vault + assetCalls[i] = chain.Call{Target: out[i].Vault, AllowFailure: true, Data: erc4626b.PackAsset()} + } + assetResults, err := r.chain.Multicall(ctx, assetCalls) + if err != nil { + return nil, err + } + if len(assetResults) != len(assetCalls) { + return nil, errors.Errorf( + "liquidlane: asset multicall: got %d results, want %d", + len(assetResults), + len(assetCalls), + ) + } + + for i := range out { + if !assetResults[i].Success { + return nil, errors.Errorf( + "liquidlane: resolve adapter %s vault %s asset: call failed", + out[i].Adapter.Hex(), + out[i].Vault.Hex(), + ) + } + asset, unpackErr := erc4626b.UnpackAsset(assetResults[i].ReturnData) + if unpackErr != nil { + return nil, errors.Errorf( + "liquidlane: resolve adapter %s vault %s asset: %w", + out[i].Adapter.Hex(), + out[i].Vault.Hex(), + unpackErr, + ) + } + if asset == (common.Address{}) { + return nil, errors.Errorf( + "liquidlane: resolve adapter %s vault %s asset: zero address", + out[i].Adapter.Hex(), + out[i].Vault.Hex(), + ) + } + out[i].TokenOut = asset + decimals, decimalsErr := r.dec.Get(ctx, asset) + if decimalsErr != nil { + return nil, errors.Errorf( + "liquidlane: resolve adapter %s tokenOut %s decimals: %w", + out[i].Adapter.Hex(), + asset.Hex(), + decimalsErr, + ) + } + out[i].TokenOutDecimals = decimals + } + return out, nil +} + +func (r *Reader) ResolveRoutes(ctx context.Context, adapters []common.Address) ([]Route, error) { + resolved, err := r.ResolveAdapters(ctx, adapters) + if err != nil { + return nil, err + } + lengths, err := r.readTokenCounts(ctx, resolved) + if err != nil { + return nil, err + } + + type tokenReq struct { + adapterIndex int + tokenIndex int + } + var reqs []tokenReq + var tokenCalls []chain.Call + for i, n := range lengths { + for j := range n { + reqs = append(reqs, tokenReq{adapterIndex: i, tokenIndex: j}) + tokenCalls = append(tokenCalls, chain.Call{ + Target: resolved[i].Adapter, + AllowFailure: true, + Data: llAdapter.PackTokensToRedeem(big.NewInt(int64(j))), + }) + } + } + if len(tokenCalls) == 0 { + return nil, nil + } + res, err := r.chain.Multicall(ctx, tokenCalls) + if err != nil { + return nil, err + } + if len(res) != len(tokenCalls) { + return nil, errors.Errorf("liquidlane: tokensToRedeem multicall: got %d results, want %d", len(res), len(tokenCalls)) + } + + routes := make([]Route, 0, len(res)) + for i, call := range res { + req := reqs[i] + resolvedAdapter := resolved[req.adapterIndex] + if !call.Success { + return nil, errors.Errorf( + "liquidlane: resolve adapter %s tokensToRedeem[%d]: call failed", + resolvedAdapter.Adapter.Hex(), + req.tokenIndex, + ) + } + tokenIn, unpackErr := llAdapter.UnpackTokensToRedeem(call.ReturnData) + if unpackErr != nil { + return nil, errors.Errorf( + "liquidlane: resolve adapter %s tokensToRedeem[%d]: %w", + resolvedAdapter.Adapter.Hex(), + req.tokenIndex, + unpackErr, + ) + } + if tokenIn == (common.Address{}) { + return nil, errors.Errorf( + "liquidlane: resolve adapter %s tokensToRedeem[%d]: zero address", + resolvedAdapter.Adapter.Hex(), + req.tokenIndex, + ) + } + route, routeErr := r.resolveRouteForToken(ctx, resolvedAdapter, tokenIn) + if routeErr != nil { + return nil, routeErr + } + routes = append(routes, route) + } + return compactRoutes(routes), nil +} + +func (r *Reader) RoutesForToken(ctx context.Context, adapters []Adapter, tokenIn common.Address) []Route { + out := make([]Route, 0, len(adapters)) + for _, a := range dedupeAdapters(adapters) { + route, err := r.resolveRouteForToken(ctx, a, tokenIn) + if err != nil { + r.log.Error(err, "liquidlane: route unresolved", + "adapter", a.Adapter.Hex(), + "tokenIn", tokenIn.Hex(), + ) + continue + } + out = append(out, route) + } + return compactRoutes(out) +} + +// readPaused returns current pause state for each successfully decoded adapter. +func (r *Reader) readPaused(ctx context.Context, adapters []common.Address) (map[common.Address]bool, error) { + adapters = dedupeAddresses(adapters) + if len(adapters) == 0 { + return nil, nil + } + calls := make([]chain.Call, len(adapters)) + for i, address := range adapters { + calls[i] = chain.Call{Target: address, AllowFailure: true, Data: llAdapter.PackPaused()} + } + results, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if len(results) != len(calls) { + return nil, errors.Errorf("liquidlane: paused multicall: got %d results, want %d", len(results), len(calls)) + } + out := make(map[common.Address]bool, len(adapters)) + for i, result := range results { + if !result.Success { + continue + } + paused, unpackErr := llAdapter.UnpackPaused(result.ReturnData) + if unpackErr == nil { + out[adapters[i]] = paused + } + } + return out, nil +} + +func (r *Reader) ReadInventory(ctx context.Context, routes []Route) ([]Inventory, error) { + return r.readInventory(ctx, routes, false) +} + +func (r *Reader) readInventory(ctx context.Context, routes []Route, keepZero bool) ([]Inventory, error) { + routes = compactRoutes(routes) + if len(routes) == 0 { + return nil, nil + } + calls := make([]chain.Call, 0, len(routes)*inventoryReadsPerRoute) + for _, route := range routes { + calls = append(calls, + chain.Call{Target: route.Adapter, AllowFailure: true, Data: llAdapter.PackPaused()}, + r.maxAssetsCall(route.Adapter, route.TokenIn), + chain.Call{Target: route.Adapter, AllowFailure: true, Data: llAdapter.PackGetMaxRate(route.TokenIn)}, + chain.Call{Target: route.Adapter, AllowFailure: true, Data: llAdapter.PackMinDiscount(route.TokenIn)}, + ) + } + res, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if len(res) != len(calls) { + return nil, errors.Errorf("liquidlane: inventory multicall: got %d results, want %d", len(res), len(calls)) + } + out := make([]Inventory, 0, len(routes)) + for i, route := range routes { + base := i * inventoryReadsPerRoute + paused, maxAssetsRes, maxRateRes, minDiscountRes := res[base], res[base+1], res[base+2], res[base+3] + if !unpaused(paused) { + continue + } + if !maxAssetsRes.Success || !maxRateRes.Success || !minDiscountRes.Success { + continue + } + maxAssets, aerr := llAdapter.UnpackGetMaxAssets(maxAssetsRes.ReturnData) + maxRate, rerr := llAdapter.UnpackGetMaxRate(maxRateRes.ReturnData) + minDiscount, derr := llAdapter.UnpackMinDiscount(minDiscountRes.ReturnData) + if aerr != nil || rerr != nil || derr != nil || maxAssets == nil || maxRate == nil || minDiscount == nil || + minDiscount.Sign() < 0 || minDiscount.Cmp(big.NewInt(DiscountPrecision)) > 0 { + continue + } + if !keepZero && (maxAssets.Sign() <= 0 || maxRate.Sign() <= 0) { + continue + } + inventory := DirectInventory(route, maxAssets, maxRate) + inventory.AdapterMinDiscount = CloneBig(minDiscount) + out = append(out, inventory) + } + return out, nil +} + +// ReadGasSnapshot returns the latest adapter-local acquire balances and shared vault liquidity needed +// to predict LiquidLane swap gas. Partially unread state remains absent and is priced as RouteUnknown. +func (r *Reader) ReadGasSnapshot(ctx context.Context, routes []Route) (*liquidlanegas.Snapshot, error) { + routes = compactRoutes(routes) + if len(routes) == 0 { + return nil, nil + } + type adapterRoutes struct { + adapter common.Address + vault common.Address + routes []Route + } + byAdapter := make(map[common.Address]*adapterRoutes, len(routes)) + ordered := make([]*adapterRoutes, 0, len(routes)) + for _, route := range routes { + entry := byAdapter[route.Adapter] + if entry == nil { + entry = &adapterRoutes{adapter: route.Adapter, vault: route.Vault} + byAdapter[route.Adapter] = entry + ordered = append(ordered, entry) + } + entry.routes = append(entry.routes, route) + } + + headCalls := make([]chain.Call, 0, len(ordered)*2) + for _, entry := range ordered { + headCalls = append(headCalls, + chain.Call{Target: entry.adapter, AllowFailure: true, Data: llAdapter.PackOwner()}, + chain.Call{Target: entry.adapter, AllowFailure: true, Data: llAdapter.PackMarketMaker()}, + ) + } + vaults := make([]common.Address, 0, len(ordered)) + seenVaults := make(map[common.Address]bool, len(ordered)) + for _, entry := range ordered { + if !seenVaults[entry.vault] { + seenVaults[entry.vault] = true + vaults = append(vaults, entry.vault) + } + } + for _, vault := range vaults { + headCalls = append(headCalls, + chain.Call{Target: vault, AllowFailure: true, Data: vaultV2b.PackFreeAssets()}, + chain.Call{Target: vault, AllowFailure: true, Data: vaultV2b.PackWithdrawable()}, + ) + } + headResults, err := r.chain.Multicall(ctx, headCalls) + if err != nil { + return nil, err + } + if len(headResults) != len(headCalls) { + return nil, errors.Errorf("liquidlane: gas state head multicall: got %d results, want %d", len(headResults), len(headCalls)) + } + + states := make(map[common.Address]*gasAdapterState, len(ordered)) + for i, entry := range ordered { + base := i * 2 + ownerRes, makerRes := headResults[base], headResults[base+1] + if !ownerRes.Success || !makerRes.Success { + continue + } + owner, ownerErr := llAdapter.UnpackOwner(ownerRes.ReturnData) + marketMaker, makerErr := llAdapter.UnpackMarketMaker(makerRes.ReturnData) + if ownerErr != nil || makerErr != nil { + continue + } + states[entry.adapter] = &gasAdapterState{ + owner: owner, marketMaker: marketMaker, + state: &liquidlanegas.AdapterState{ + Vault: entry.vault, Acquire: make(map[common.Address]*big.Int, len(entry.routes)), + }, + } + } + vaultStates := make(map[common.Address]*liquidlanegas.VaultState, len(vaults)) + vaultBase := len(ordered) * 2 + for i, vault := range vaults { + base := vaultBase + i*2 + freeRes, withdrawableRes := headResults[base], headResults[base+1] + if !freeRes.Success || !withdrawableRes.Success { + continue + } + freeAssets, freeErr := vaultV2b.UnpackFreeAssets(freeRes.ReturnData) + withdrawable, withdrawableErr := vaultV2b.UnpackWithdrawable(withdrawableRes.ReturnData) + if freeErr != nil || withdrawableErr != nil || freeAssets == nil || withdrawable == nil { + continue + } + vaultStates[vault] = &liquidlanegas.VaultState{ + FreeAssets: new(big.Int).Set(freeAssets), Withdrawable: new(big.Int).Set(withdrawable), + } + } + + type acquireRead struct { + adapter common.Address + token common.Address + holder common.Address + } + acquireCalls := make([]chain.Call, 0, len(routes)*2) + reads := make([]acquireRead, 0, len(routes)*2) + for _, entry := range ordered { + state := states[entry.adapter] + if state == nil { + continue + } + for _, route := range entry.routes { + acquireCalls = append(acquireCalls, chain.Call{ + Target: entry.adapter, AllowFailure: true, Data: llAdapter.PackAcquireBalance(route.TokenIn, state.owner), + }) + reads = append(reads, acquireRead{adapter: entry.adapter, token: route.TokenIn, holder: state.owner}) + if state.marketMaker != state.owner { + acquireCalls = append(acquireCalls, chain.Call{ + Target: entry.adapter, AllowFailure: true, + Data: llAdapter.PackAcquireBalance(route.TokenIn, state.marketMaker), + }) + reads = append(reads, acquireRead{ + adapter: entry.adapter, + token: route.TokenIn, + holder: state.marketMaker, + }) + } + } + } + if len(acquireCalls) == 0 { + return gasSnapshot(states, vaultStates), nil + } + acquireResults, err := r.chain.Multicall(ctx, acquireCalls) + if err != nil { + return nil, err + } + if len(acquireResults) != len(acquireCalls) { + return nil, errors.Errorf("liquidlane: gas state acquire multicall: got %d results, want %d", len(acquireResults), len(acquireCalls)) + } + for i, read := range reads { + result := acquireResults[i] + if !result.Success { + continue + } + amount, unpackErr := llAdapter.UnpackAcquireBalance(result.ReturnData) + if unpackErr != nil || amount == nil || amount.Sign() < 0 { + continue + } + state := states[read.adapter] + if state == nil { + return nil, errors.Errorf("liquidlane: missing gas state for adapter %s", read.adapter.Hex()) + } + if state.state.Acquire[read.token] == nil { + state.state.Acquire[read.token] = new(big.Int) + } + state.state.Acquire[read.token].Add(state.state.Acquire[read.token], amount) + } + return gasSnapshot(states, vaultStates), nil +} + +func gasSnapshot( + in map[common.Address]*gasAdapterState, + vaults map[common.Address]*liquidlanegas.VaultState, +) *liquidlanegas.Snapshot { + adapters := make(map[common.Address]*liquidlanegas.AdapterState, len(in)) + for adapter, state := range in { + adapters[adapter] = state.state + } + return &liquidlanegas.Snapshot{Adapters: adapters, Vaults: vaults} +} + +// ReadAdapterSnapshot reads one complete LiquidLane adapter view for solvers that consume all routes. +func (r *Reader) ReadAdapterSnapshot( + ctx context.Context, + adapterAddress common.Address, + filler common.Address, +) (AdapterSnapshot, error) { + routes, err := r.ResolveRoutes(ctx, []common.Address{adapterAddress}) + if err != nil { + return AdapterSnapshot{}, err + } + if len(routes) == 0 { + return AdapterSnapshot{}, errors.New("liquidlane: adapter has no resolved routes") + } + pausedByAdapter, err := r.readPaused(ctx, []common.Address{adapterAddress}) + if err != nil { + return AdapterSnapshot{}, err + } + paused, pausedResolved := pausedByAdapter[adapterAddress] + if !pausedResolved { + return AdapterSnapshot{}, errors.New("liquidlane: adapter pause state unresolved") + } + auth, err := r.ReadAuth(ctx, []common.Address{adapterAddress}, filler) + if err != nil { + return AdapterSnapshot{}, err + } + if len(auth) != 1 || auth[0].Adapter != adapterAddress { + return AdapterSnapshot{}, errors.New("liquidlane: adapter authorization unresolved") + } + gasState, err := r.ReadGasSnapshot(ctx, routes) + if err != nil { + return AdapterSnapshot{}, err + } + adapterState := gasState.Adapters[adapterAddress] + vaultState := gasState.Vaults[routes[0].Vault] + if adapterState == nil || vaultState == nil { + return AdapterSnapshot{}, errors.New("liquidlane: adapter liquidity state unresolved") + } + + inventoryByRoute := make(map[RouteID]Inventory, len(routes)) + if paused { + for _, route := range routes { + inventoryByRoute[route.ID] = DirectInventory(route, new(big.Int), new(big.Int)) + } + } else { + inventory, inventoryErr := r.readInventory(ctx, routes, true) + if inventoryErr != nil { + return AdapterSnapshot{}, inventoryErr + } + for _, item := range inventory { + inventoryByRoute[item.ID] = item + } + if len(inventoryByRoute) != len(routes) { + return AdapterSnapshot{}, errors.New("liquidlane: adapter inventory unresolved") + } + } + + first := routes[0] + out := AdapterSnapshot{ + Adapter: Adapter{ + Adapter: first.Adapter, Vault: first.Vault, + TokenOut: first.TokenOut, TokenOutDecimals: first.TokenOutDecimals, + }, + Paused: paused, Authorized: auth[0].Authorized, + FreeAssets: CloneBig(vaultState.FreeAssets), Withdrawable: CloneBig(vaultState.Withdrawable), + Routes: make([]RouteSnapshot, 0, len(routes)), + } + for _, route := range routes { + item := inventoryByRoute[route.ID] + out.Routes = append(out.Routes, RouteSnapshot{ + Route: route, + MaxAssets: CloneBig(item.MaxAssets), MaxRate: CloneBig(item.MaxRate), + AcquireBalance: CloneBig(adapterState.Acquire[route.TokenIn]), + }) + } + return out, nil +} + +func (r *Reader) ReadFillQuotes( + ctx context.Context, + routes []Route, + tokenIn common.Address, + amountIn *big.Int, +) ([]FillQuote, error) { + if tokenIn == (common.Address{}) || amountIn == nil || amountIn.Sign() <= 0 { + return nil, nil + } + candidates := make([]Route, 0, len(routes)) + for _, route := range compactRoutes(routes) { + if route.TokenIn == tokenIn { + candidates = append(candidates, route) + } + } + if len(candidates) == 0 { + return nil, nil + } + + calls := make([]chain.Call, 0, len(candidates)*fillReadsPerRoute) + for _, route := range candidates { + calls = append(calls, + chain.Call{Target: route.Adapter, AllowFailure: true, Data: llAdapter.PackPaused()}, + r.maxAssetsCall(route.Adapter, route.TokenIn), + chain.Call{Target: route.Adapter, AllowFailure: true, Data: llAdapter.PackGetAmountOut(route.TokenIn, amountIn)}, + chain.Call{Target: route.Adapter, AllowFailure: true, Data: llAdapter.PackMinDiscount(route.TokenIn)}, + ) + } + res, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if len(res) != len(calls) { + return nil, errors.Errorf("liquidlane: fill multicall: got %d results, want %d", len(res), len(calls)) + } + out := make([]FillQuote, 0, len(candidates)) + for i, route := range candidates { + base := i * fillReadsPerRoute + paused, maxAssetsRes, amountOutRes, discountRes := res[base], res[base+1], res[base+2], res[base+3] + if !unpaused(paused) { + continue + } + if !maxAssetsRes.Success || !amountOutRes.Success || !discountRes.Success { + continue + } + maxAssets, aerr := llAdapter.UnpackGetMaxAssets(maxAssetsRes.ReturnData) + grossAmountOut, oerr := llAdapter.UnpackGetAmountOut(amountOutRes.ReturnData) + discount, derr := llAdapter.UnpackMinDiscount(discountRes.ReturnData) + if aerr != nil || oerr != nil || derr != nil || maxAssets.Sign() <= 0 || grossAmountOut.Sign() <= 0 || + discount.Sign() < 0 || discount.Cmp(big.NewInt(DiscountPrecision)) > 0 { + continue + } + maxAmountOut := AmountOutAfterDiscount(grossAmountOut, discount) + if maxAmountOut.Sign() <= 0 { + continue + } + maxRate := RateForAmountOut(maxAmountOut, amountIn, route.TokenInDecimals, route.TokenOutDecimals) + inventory := DirectInventory(route, maxAssets, maxRate) + inventory.AdapterMinDiscount = CloneBig(discount) + out = append(out, FillQuote{ + Inventory: inventory, + AmountIn: CloneBig(amountIn), + GrossAmountOut: CloneBig(grossAmountOut), + MaxAmountOut: maxAmountOut, + MinDiscount: CloneBig(discount), + }) + } + return out, nil +} + +func (r *Reader) FilterAuthorized(ctx context.Context, inv []Inventory, filler common.Address) ([]Inventory, error) { + inv = compactInventory(inv) + if len(inv) == 0 { + return nil, nil + } + adapters := make([]common.Address, 0, len(inv)) + for _, item := range inv { + adapters = append(adapters, item.Adapter) + } + authorized, err := r.authorizedAdapters(ctx, adapters, filler) + if err != nil { + return nil, err + } + out := make([]Inventory, 0, len(inv)) + for _, item := range inv { + if authorized[item.Adapter] { + out = append(out, item) + } + } + return out, nil +} + +func (r *Reader) FilterAuthorizedRoutes(ctx context.Context, routes []Route, filler common.Address) ([]Route, error) { + routes = compactRoutes(routes) + if len(routes) == 0 { + return nil, nil + } + adapters := make([]common.Address, 0, len(routes)) + for _, route := range routes { + adapters = append(adapters, route.Adapter) + } + authorized, err := r.authorizedAdapters(ctx, adapters, filler) + if err != nil { + return nil, err + } + out := make([]Route, 0, len(routes)) + for _, route := range routes { + if authorized[route.Adapter] { + out = append(out, route) + } + } + return out, nil +} + +func (r *Reader) authorizedAdapters( + ctx context.Context, + adapters []common.Address, + filler common.Address, +) (map[common.Address]bool, error) { + auth, err := r.ReadAuth(ctx, adapters, filler) + if err != nil { + return nil, err + } + authorized := make(map[common.Address]bool, len(auth)) + for _, item := range auth { + authorized[item.Adapter] = item.Authorized + } + return authorized, nil +} + +func (r *Reader) ReadAuth(ctx context.Context, adapters []common.Address, filler common.Address) ([]Auth, error) { + adapters = dedupeAddresses(adapters) + if len(adapters) == 0 || filler == (common.Address{}) { + return nil, nil + } + calls := make([]chain.Call, 0, len(adapters)*2) + for _, adapterAddr := range adapters { + calls = append(calls, + chain.Call{Target: adapterAddr, AllowFailure: true, Data: llAdapter.PackMarketMaker()}, + chain.Call{Target: adapterAddr, AllowFailure: true, Data: llAdapter.PackOwner()}, + ) + } + res, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if len(res) != len(calls) { + return nil, errors.Errorf("liquidlane: authorization multicall: got %d results, want %d", len(res), len(calls)) + } + + auths := make([]Auth, len(adapters)) + resolved := make([]bool, len(adapters)) + var delegatedChecks []int + for i := range adapters { + mm, ow := res[i*2], res[i*2+1] + if !mm.Success || !ow.Success { + continue + } + marketMaker, e1 := llAdapter.UnpackMarketMaker(mm.ReturnData) + owner, e2 := llAdapter.UnpackOwner(ow.ReturnData) + if e1 != nil || e2 != nil { + continue + } + auths[i] = Auth{Adapter: adapters[i], MarketMaker: marketMaker, Owner: owner} + resolved[i] = true + auths[i].Authorized = marketMaker == filler || owner == filler + if !auths[i].Authorized { + delegatedChecks = append(delegatedChecks, i) + } + } + + if len(delegatedChecks) > 0 { + delegationCalls := make([]chain.Call, len(delegatedChecks)) + for j, i := range delegatedChecks { + // Delegation is keyed by the adapter's exact current marketMaker value; zero is valid. + delegationCalls[j] = chain.Call{ + Target: adapters[i], AllowFailure: true, + Data: llAdapter.PackIsFiller(auths[i].MarketMaker, filler), + } + } + delegationResults, err := r.chain.Multicall(ctx, delegationCalls) + if err != nil { + return nil, err + } + if len(delegationResults) != len(delegationCalls) { + return nil, errors.Errorf( + "liquidlane: filler authorization multicall: got %d results, want %d", + len(delegationResults), + len(delegationCalls), + ) + } + for j, i := range delegatedChecks { + if delegationResults[j].Success { + if ok, derr := llAdapter.UnpackIsFiller(delegationResults[j].ReturnData); derr == nil { + auths[i].IsFiller = ok + auths[i].Authorized = ok + } + } + } + } + + out := make([]Auth, 0, len(auths)) + for i, item := range auths { + if resolved[i] { + out = append(out, item) + } + } + return out, nil +} + +func unpaused(result chain.CallResult) bool { + if !result.Success { + return false + } + paused, err := llAdapter.UnpackPaused(result.ReturnData) + return err == nil && !paused +} + +func (r *Reader) readTokenCounts(ctx context.Context, adapters []Adapter) ([]int, error) { + calls := make([]chain.Call, len(adapters)) + for i, a := range adapters { + calls[i] = chain.Call{Target: a.Adapter, AllowFailure: true, Data: llAdapter.PackGetTokensToRedeemLength()} + } + res, err := r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if len(res) != len(calls) { + return nil, errors.Errorf("liquidlane: tokensToRedeem length multicall: got %d results, want %d", len(res), len(calls)) + } + out := make([]int, len(adapters)) + for i, call := range res { + if !call.Success { + return nil, errors.Errorf( + "liquidlane: resolve adapter %s tokensToRedeem length: call failed", + adapters[i].Adapter.Hex(), + ) + } + n, unpackErr := llAdapter.UnpackGetTokensToRedeemLength(call.ReturnData) + if unpackErr != nil { + return nil, errors.Errorf( + "liquidlane: resolve adapter %s tokensToRedeem length: %w", + adapters[i].Adapter.Hex(), + unpackErr, + ) + } + if !n.IsInt64() || n.Sign() < 0 { + return nil, errors.Errorf( + "liquidlane: resolve adapter %s tokensToRedeem length: invalid value %s", + adapters[i].Adapter.Hex(), + n, + ) + } + if n.Sign() == 0 { + return nil, errors.Errorf( + "liquidlane: resolve adapter %s: tokensToRedeem is empty", + adapters[i].Adapter.Hex(), + ) + } + if n.Cmp(big.NewInt(int64(r.maxTokensPerAdapter))) > 0 { + return nil, errors.Errorf( + "liquidlane: resolve adapter %s tokensToRedeem length %s exceeds cap %d", + adapters[i].Adapter.Hex(), + n, + r.maxTokensPerAdapter, + ) + } + out[i] = int(n.Int64()) + } + return out, nil +} + +func (r *Reader) resolveRouteForToken(ctx context.Context, adapter Adapter, tokenIn common.Address) (Route, error) { + if tokenIn == (common.Address{}) { + return Route{}, errors.Errorf("liquidlane: resolve adapter %s tokenIn: zero address", adapter.Adapter.Hex()) + } + tokenInDecimals, err := r.dec.Get(ctx, tokenIn) + if err != nil { + return Route{}, errors.Errorf( + "liquidlane: resolve adapter %s tokenIn %s decimals: %w", + adapter.Adapter.Hex(), + tokenIn.Hex(), + err, + ) + } + return NewRoute( + r.chainID, + adapter.Adapter, + adapter.Vault, + tokenIn, + adapter.TokenOut, + tokenInDecimals, + adapter.TokenOutDecimals, + ), 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 seen[a] { + continue + } + seen[a] = true + out = append(out, a) + } + return out +} + +func dedupeAdapters(in []Adapter) []Adapter { + seen := make(map[common.Address]bool, len(in)) + out := make([]Adapter, 0, len(in)) + for _, a := range in { + if seen[a.Adapter] { + continue + } + seen[a.Adapter] = true + out = append(out, a) + } + return out +} + +func compactRoutes(in []Route) []Route { + seen := make(map[RouteID]bool, len(in)) + out := make([]Route, 0, len(in)) + for _, route := range in { + if route.Adapter == (common.Address{}) || route.TokenIn == (common.Address{}) || route.TokenOut == (common.Address{}) { + continue + } + if seen[route.ID] { + continue + } + seen[route.ID] = true + out = append(out, route) + } + return out +} + +func compactInventory(in []Inventory) []Inventory { + seen := make(map[CandidateID]bool, len(in)) + out := make([]Inventory, 0, len(in)) + for _, item := range in { + if item.Adapter == (common.Address{}) || item.TokenIn == (common.Address{}) || item.TokenOut == (common.Address{}) { + continue + } + if item.MaxAssets == nil || item.MaxAssets.Sign() <= 0 { + continue + } + id := NewCandidateID(item.Route, item.DiscountID) + if seen[id] { + continue + } + seen[id] = true + out = append(out, item) + } + return out +} diff --git a/internal/liquidlane/reader_test.go b/internal/liquidlane/reader_test.go new file mode 100644 index 00000000..194434a3 --- /dev/null +++ b/internal/liquidlane/reader_test.go @@ -0,0 +1,666 @@ +package liquidlane + +import ( + "context" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "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/liquidlane/adapter" + "github.com/symbioticfi/vault-solver/api/bindings/vaultv2" + "github.com/symbioticfi/vault-solver/internal/chain" +) + +type scriptedLiquidLaneBackend struct { + latest [][]chain.CallResult +} + +func (b *scriptedLiquidLaneBackend) ChainID() *big.Int { return big.NewInt(11155111) } + +func (b *scriptedLiquidLaneBackend) Multicall(_ context.Context, _ []chain.Call) ([]chain.CallResult, error) { + result := b.latest[0] + b.latest = b.latest[1:] + return result, nil +} + +type fixedDecimals map[common.Address]int + +func (d fixedDecimals) Get(_ context.Context, token common.Address) (int, error) { + return d[token], nil +} + +type failingDecimals struct { + err error +} + +func (d failingDecimals) Get(_ context.Context, _ common.Address) (int, error) { + return 0, d.err +} + +type selectiveDecimals struct { + values fixedDecimals + token common.Address + err error +} + +func (d selectiveDecimals) Get(_ context.Context, token common.Address) (int, error) { + if token == d.token { + return 0, d.err + } + return d.values[token], nil +} + +func TestReaderResolveAdaptersFailsClosedForConfiguredAdapterMetadata(t *testing.T) { + route := testReaderRoute(1) + decimalsErr := errors.New("temporary decimals failure") + tests := map[string]struct { + results [][]chain.CallResult + dec decimalsReader + want string + }{ + "vault call": { + results: [][]chain.CallResult{{{}}}, + dec: fixedDecimals{}, + want: "vault: call failed", + }, + "vault decode": { + results: [][]chain.CallResult{{{Success: true, ReturnData: []byte{0xff}}}}, + dec: fixedDecimals{}, + want: "vault:", + }, + "zero vault": { + results: [][]chain.CallResult{{successOutput(t, "vault", common.Address{})}}, + dec: fixedDecimals{}, + want: "vault: zero address", + }, + "asset call": { + results: [][]chain.CallResult{ + {successOutput(t, "vault", route.Vault)}, + {{}}, + }, + dec: fixedDecimals{}, + want: "asset: call failed", + }, + "asset decode": { + results: [][]chain.CallResult{ + {successOutput(t, "vault", route.Vault)}, + {{Success: true, ReturnData: []byte{0xff}}}, + }, + dec: fixedDecimals{}, + want: "asset:", + }, + "zero asset": { + results: [][]chain.CallResult{ + {successOutput(t, "vault", route.Vault)}, + {successAssetOutput(t, common.Address{})}, + }, + dec: fixedDecimals{}, + want: "asset: zero address", + }, + "decimals": { + results: [][]chain.CallResult{ + {successOutput(t, "vault", route.Vault)}, + {successAssetOutput(t, route.TokenOut)}, + }, + dec: failingDecimals{err: decimalsErr}, + want: "decimals", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + r := &Reader{ + chain: &scriptedLiquidLaneBackend{latest: test.results}, + log: logr.Discard(), + dec: test.dec, + chainID: 11155111, + maxTokensPerAdapter: DefaultMaxTokensPerAdapter, + } + + _, err := r.ResolveAdapters(context.Background(), []common.Address{route.Adapter}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("ResolveAdapters error = %v, want %q", err, test.want) + } + if name == "decimals" && !errors.Is(err, decimalsErr) { + t.Fatalf("ResolveAdapters error = %v, want wrapped decimals error", err) + } + }) + } +} + +func TestReaderResolveRoutesFailsClosedForConfiguredAdapterRoutes(t *testing.T) { + route := testReaderRoute(1) + decimalsErr := errors.New("temporary token decimals failure") + baseResults := func(extra ...[]chain.CallResult) [][]chain.CallResult { + results := [][]chain.CallResult{ + {successOutput(t, "vault", route.Vault)}, + {successAssetOutput(t, route.TokenOut)}, + } + return append(results, extra...) + } + tests := map[string]struct { + results [][]chain.CallResult + dec decimalsReader + want string + }{ + "length call": { + results: baseResults([]chain.CallResult{{}}), + dec: fixedDecimals{route.TokenOut: route.TokenOutDecimals}, + want: "tokensToRedeem length: call failed", + }, + "length decode": { + results: baseResults([]chain.CallResult{{Success: true, ReturnData: []byte{0xff}}}), + dec: fixedDecimals{route.TokenOut: route.TokenOutDecimals}, + want: "tokensToRedeem length:", + }, + "empty route list": { + results: baseResults([]chain.CallResult{successOutput(t, "getTokensToRedeemLength", new(big.Int))}), + dec: fixedDecimals{route.TokenOut: route.TokenOutDecimals}, + want: "tokensToRedeem is empty", + }, + "route cap": { + results: baseResults([]chain.CallResult{successOutput( + t, + "getTokensToRedeemLength", + big.NewInt(DefaultMaxTokensPerAdapter+1), + )}), + dec: fixedDecimals{route.TokenOut: route.TokenOutDecimals}, + want: "exceeds cap", + }, + "token call": { + results: baseResults( + []chain.CallResult{successOutput(t, "getTokensToRedeemLength", big.NewInt(1))}, + []chain.CallResult{{}}, + ), + dec: fixedDecimals{route.TokenOut: route.TokenOutDecimals}, + want: "tokensToRedeem[0]: call failed", + }, + "token decode": { + results: baseResults( + []chain.CallResult{successOutput(t, "getTokensToRedeemLength", big.NewInt(1))}, + []chain.CallResult{{Success: true, ReturnData: []byte{0xff}}}, + ), + dec: fixedDecimals{route.TokenOut: route.TokenOutDecimals}, + want: "tokensToRedeem[0]:", + }, + "zero token": { + results: baseResults( + []chain.CallResult{successOutput(t, "getTokensToRedeemLength", big.NewInt(1))}, + []chain.CallResult{successOutput(t, "tokensToRedeem", common.Address{})}, + ), + dec: fixedDecimals{route.TokenOut: route.TokenOutDecimals}, + want: "tokensToRedeem[0]: zero address", + }, + "token decimals": { + results: baseResults( + []chain.CallResult{successOutput(t, "getTokensToRedeemLength", big.NewInt(1))}, + []chain.CallResult{successOutput(t, "tokensToRedeem", route.TokenIn)}, + ), + dec: selectiveDecimals{ + values: fixedDecimals{route.TokenOut: route.TokenOutDecimals}, + token: route.TokenIn, + err: decimalsErr, + }, + want: "tokenIn " + route.TokenIn.Hex() + " decimals", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + r := &Reader{ + chain: &scriptedLiquidLaneBackend{latest: test.results}, + log: logr.Discard(), + dec: test.dec, + chainID: 11155111, + maxTokensPerAdapter: DefaultMaxTokensPerAdapter, + } + + _, err := r.ResolveRoutes(t.Context(), []common.Address{route.Adapter}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("ResolveRoutes error = %v, want %q", err, test.want) + } + if name == "token decimals" && !errors.Is(err, decimalsErr) { + t.Fatalf("ResolveRoutes error = %v, want wrapped decimals error", err) + } + }) + } +} + +func TestReaderReadInventoryUsesLatestAndFailsClosedPerRoute(t *testing.T) { + backend := &scriptedLiquidLaneBackend{ + latest: [][]chain.CallResult{{ + successOutput(t, "paused", false), + successOutput(t, "getMaxAssets", big.NewInt(100)), + successOutput(t, "getMaxRate", big.NewInt(1_000_000_000_000_000_000)), + successOutput(t, "minDiscount", big.NewInt(100_000)), + {Success: true, ReturnData: []byte{0xff}}, + successOutput(t, "getMaxAssets", big.NewInt(200)), + successOutput(t, "getMaxRate", big.NewInt(1_000_000_000_000_000_000)), + successOutput(t, "minDiscount", big.NewInt(100_000)), + }}, + } + r := &Reader{ + chain: backend, log: logr.Discard(), dec: fixedDecimals{}, chainID: 11155111, + maxTokensPerAdapter: DefaultMaxTokensPerAdapter, + } + routes := []Route{testReaderRoute(1), testReaderRoute(2)} + + inventory, err := r.ReadInventory(context.Background(), routes) + if err != nil { + t.Fatalf("ReadInventory: %v", err) + } + if len(inventory) != 1 || inventory[0].ID != routes[0].ID { + t.Fatalf("inventory = %+v", inventory) + } + if inventory[0].MaxRate.String() != "1000000000000000000" || + inventory[0].AdapterMinDiscount.String() != "100000" { + t.Fatalf("executable inventory = %+v", inventory[0]) + } +} + +func TestReaderReadFillQuotesFiltersTokenAtLatest(t *testing.T) { + tokenIn := common.HexToAddress("0x0000000000000000000000000000000000000101") + backend := &scriptedLiquidLaneBackend{ + latest: [][]chain.CallResult{{ + successOutput(t, "paused", false), + successOutput(t, "getMaxAssets", big.NewInt(1_000)), + successOutput(t, "getAmountOut", big.NewInt(900)), + successOutput(t, "minDiscount", big.NewInt(100_000)), + }}, + } + r := &Reader{ + chain: backend, log: logr.Discard(), dec: fixedDecimals{}, chainID: 11155111, + maxTokensPerAdapter: DefaultMaxTokensPerAdapter, + } + matching := testReaderRoute(1) + matching.TokenIn = tokenIn + nonMatching := testReaderRoute(2) + amountIn := big.NewInt(500) + + quotes, err := r.ReadFillQuotes(context.Background(), []Route{matching, nonMatching}, tokenIn, amountIn) + if err != nil { + t.Fatalf("ReadFillQuotes: %v", err) + } + if len(quotes) != 1 || quotes[0].GrossAmountOut.String() != "900" || + quotes[0].MaxAmountOut.String() != "810" || quotes[0].MinDiscount.String() != "100000" || + quotes[0].MaxRate.String() != "1620000000000000000000000000000" { + t.Fatalf("quotes = %+v", quotes) + } + amountIn.SetInt64(1) + if quotes[0].AmountIn.String() != "500" { + t.Fatalf("amountIn was not cloned: %s", quotes[0].AmountIn) + } +} + +func TestReaderReadFillQuotesKeepsAmountSpecificFillWhenRateRoundsToZero(t *testing.T) { + tokenIn := common.HexToAddress("0x0000000000000000000000000000000000000101") + backend := &scriptedLiquidLaneBackend{latest: [][]chain.CallResult{{ + successOutput(t, "paused", false), + successOutput(t, "getMaxAssets", big.NewInt(1)), + successOutput(t, "getAmountOut", big.NewInt(1)), + successOutput(t, "minDiscount", big.NewInt(0)), + }}} + r := &Reader{ + chain: backend, log: logr.Discard(), dec: fixedDecimals{}, chainID: 11155111, + maxTokensPerAdapter: DefaultMaxTokensPerAdapter, + } + route := testReaderRoute(1) + route.TokenIn = tokenIn + amountIn := new(big.Int).Exp(big.NewInt(10), big.NewInt(37), nil) + + quotes, err := r.ReadFillQuotes(context.Background(), []Route{route}, tokenIn, amountIn) + if err != nil { + t.Fatalf("ReadFillQuotes: %v", err) + } + if len(quotes) != 1 || quotes[0].MaxAmountOut.String() != "1" || quotes[0].MaxRate.Sign() != 0 { + t.Fatalf("quotes = %+v", quotes) + } +} + +func TestReaderReadGasSnapshotCombinesAcquireAndDeduplicatesVaultState(t *testing.T) { + route := testReaderRoute(1) + secondRoute := testReaderRoute(2) + secondRoute.Vault = route.Vault + secondRoute.CapacityID = route.CapacityID + owner := common.HexToAddress("0x0000000000000000000000000000000000000a11") + marketMaker := common.HexToAddress("0x0000000000000000000000000000000000000b11") + backend := &scriptedLiquidLaneBackend{latest: [][]chain.CallResult{ + { + successOutput(t, "owner", owner), + successOutput(t, "marketMaker", marketMaker), + successOutput(t, "owner", owner), + successOutput(t, "marketMaker", marketMaker), + successVaultOutput(t, "freeAssets", big.NewInt(200)), + successVaultOutput(t, "withdrawable", big.NewInt(150)), + }, + { + successOutput(t, "acquireBalance", big.NewInt(30)), + successOutput(t, "acquireBalance", big.NewInt(70)), + successOutput(t, "acquireBalance", big.NewInt(20)), + successOutput(t, "acquireBalance", big.NewInt(10)), + }, + }} + r := &Reader{chain: backend, log: logr.Discard(), dec: fixedDecimals{}, chainID: 11155111} + snapshot, err := r.ReadGasSnapshot(context.Background(), []Route{route, secondRoute}) + if err != nil { + t.Fatalf("ReadGasSnapshot: %v", err) + } + if len(snapshot.Vaults) != 1 || snapshot.Vaults[route.Vault].FreeAssets.String() != "200" || + snapshot.Vaults[route.Vault].Withdrawable.String() != "150" { + t.Fatalf("gas vault state = %+v", snapshot.Vaults) + } + if snapshot.Adapters[route.Adapter].Acquire[route.TokenIn].String() != "100" || + snapshot.Adapters[secondRoute.Adapter].Acquire[secondRoute.TokenIn].String() != "30" { + t.Fatalf("gas adapter state = %+v", snapshot.Adapters) + } +} + +func TestReaderReadGasSnapshotReadsZeroMarketMakerKey(t *testing.T) { + route := testReaderRoute(1) + owner := common.HexToAddress("0x0000000000000000000000000000000000000a11") + backend := &scriptedLiquidLaneBackend{latest: [][]chain.CallResult{ + { + successOutput(t, "owner", owner), + successOutput(t, "marketMaker", common.Address{}), + successVaultOutput(t, "freeAssets", big.NewInt(200)), + successVaultOutput(t, "withdrawable", big.NewInt(150)), + }, + { + successOutput(t, "acquireBalance", big.NewInt(30)), + successOutput(t, "acquireBalance", new(big.Int)), + }, + }} + r := &Reader{chain: backend, log: logr.Discard(), dec: fixedDecimals{}, chainID: 11155111} + + snapshot, err := r.ReadGasSnapshot(t.Context(), []Route{route}) + if err != nil { + t.Fatalf("ReadGasSnapshot: %v", err) + } + if got := snapshot.Adapters[route.Adapter].Acquire[route.TokenIn]; got == nil || got.String() != "30" { + t.Fatalf("acquire balance = %v, want 30", got) + } +} + +func TestReaderReadGasSnapshotTreatsInvalidAcquireBalanceAsUnavailable(t *testing.T) { + route := testReaderRoute(1) + owner := common.HexToAddress("0x0000000000000000000000000000000000000a11") + tests := map[string]chain.CallResult{ + "failed call": {}, + "malformed result": {Success: true, ReturnData: []byte{0xff}}, + } + for name, acquireResult := range tests { + t.Run(name, func(t *testing.T) { + backend := &scriptedLiquidLaneBackend{latest: [][]chain.CallResult{ + { + successOutput(t, "owner", owner), + successOutput(t, "marketMaker", owner), + successVaultOutput(t, "freeAssets", big.NewInt(200)), + successVaultOutput(t, "withdrawable", big.NewInt(150)), + }, + {acquireResult}, + }} + r := &Reader{chain: backend, log: logr.Discard(), dec: fixedDecimals{}, chainID: 11155111} + + snapshot, err := r.ReadGasSnapshot(context.Background(), []Route{route}) + if err != nil { + t.Fatalf("ReadGasSnapshot: %v", err) + } + if amount := snapshot.Adapters[route.Adapter].Acquire[route.TokenIn]; amount != nil { + t.Fatalf("acquire balance = %v, want unavailable", amount) + } + }) + } +} + +func TestReaderReadAdapterSnapshotCombinesSharedFacts(t *testing.T) { + route := testReaderRoute(1) + owner := common.HexToAddress("0x0000000000000000000000000000000000000a11") + backend := &scriptedLiquidLaneBackend{latest: [][]chain.CallResult{ + {successOutput(t, "vault", route.Vault)}, + {successAssetOutput(t, route.TokenOut)}, + {successOutput(t, "getTokensToRedeemLength", big.NewInt(1))}, + {successOutput(t, "tokensToRedeem", route.TokenIn)}, + {successOutput(t, "paused", false)}, + {successOutput(t, "marketMaker", owner), successOutput(t, "owner", owner)}, + { + successOutput(t, "owner", owner), successOutput(t, "marketMaker", owner), + successVaultOutput(t, "freeAssets", big.NewInt(200)), + successVaultOutput(t, "withdrawable", big.NewInt(150)), + }, + {successOutput(t, "acquireBalance", big.NewInt(30))}, + { + successOutput(t, "paused", false), + successOutput(t, "getMaxAssets", big.NewInt(120)), + successOutput(t, "getMaxRate", big.NewInt(900)), + successOutput(t, "minDiscount", big.NewInt(100_000)), + }, + }} + r := &Reader{ + chain: backend, log: logr.Discard(), chainID: 11155111, + dec: fixedDecimals{route.TokenIn: route.TokenInDecimals, route.TokenOut: route.TokenOutDecimals}, + maxTokensPerAdapter: DefaultMaxTokensPerAdapter, + } + + snapshot, err := r.ReadAdapterSnapshot(context.Background(), route.Adapter, owner) + if err != nil { + t.Fatalf("ReadAdapterSnapshot: %v", err) + } + if !snapshot.Authorized || snapshot.Paused || snapshot.Vault != route.Vault || snapshot.TokenOut != route.TokenOut { + t.Fatalf("adapter snapshot = %+v", snapshot) + } + if snapshot.FreeAssets.String() != "200" || snapshot.Withdrawable.String() != "150" || len(snapshot.Routes) != 1 { + t.Fatalf("adapter liquidity = %+v", snapshot) + } + gotRoute := snapshot.Routes[0] + if gotRoute.MaxAssets.String() != "120" || gotRoute.MaxRate.String() != "900" || + gotRoute.AcquireBalance.String() != "30" { + t.Fatalf("route snapshot = %+v", gotRoute) + } +} + +func TestReaderReadAdapterSnapshotKeepsZeroCapacityRoutes(t *testing.T) { + first := testReaderRoute(1) + second := testReaderRoute(2) + owner := common.HexToAddress("0x0000000000000000000000000000000000000a11") + backend := &scriptedLiquidLaneBackend{latest: [][]chain.CallResult{ + {successOutput(t, "vault", first.Vault)}, + {successAssetOutput(t, first.TokenOut)}, + {successOutput(t, "getTokensToRedeemLength", big.NewInt(2))}, + { + successOutput(t, "tokensToRedeem", first.TokenIn), + successOutput(t, "tokensToRedeem", second.TokenIn), + }, + {successOutput(t, "paused", false)}, + {successOutput(t, "marketMaker", owner), successOutput(t, "owner", owner)}, + { + successOutput(t, "owner", owner), successOutput(t, "marketMaker", owner), + successVaultOutput(t, "freeAssets", big.NewInt(200)), + successVaultOutput(t, "withdrawable", big.NewInt(150)), + }, + { + successOutput(t, "acquireBalance", big.NewInt(0)), + successOutput(t, "acquireBalance", big.NewInt(30)), + }, + { + successOutput(t, "paused", false), + successOutput(t, "getMaxAssets", big.NewInt(0)), + successOutput(t, "getMaxRate", big.NewInt(900)), + successOutput(t, "minDiscount", big.NewInt(100_000)), + successOutput(t, "paused", false), + successOutput(t, "getMaxAssets", big.NewInt(120)), + successOutput(t, "getMaxRate", big.NewInt(800)), + successOutput(t, "minDiscount", big.NewInt(100_000)), + }, + }} + r := &Reader{ + chain: backend, log: logr.Discard(), chainID: 11155111, + dec: fixedDecimals{ + first.TokenIn: first.TokenInDecimals, second.TokenIn: second.TokenInDecimals, + first.TokenOut: first.TokenOutDecimals, + }, + maxTokensPerAdapter: DefaultMaxTokensPerAdapter, + } + + snapshot, err := r.ReadAdapterSnapshot(context.Background(), first.Adapter, owner) + if err != nil { + t.Fatalf("ReadAdapterSnapshot: %v", err) + } + if len(snapshot.Routes) != 2 { + t.Fatalf("routes = %+v", snapshot.Routes) + } + if snapshot.Routes[0].MaxAssets == nil || snapshot.Routes[0].MaxAssets.Sign() != 0 || + snapshot.Routes[0].MaxRate == nil || snapshot.Routes[0].MaxRate.String() != "900" { + t.Fatalf("zero-cap route = %+v", snapshot.Routes[0]) + } + if snapshot.Routes[1].MaxAssets.String() != "120" || snapshot.Routes[1].MaxRate.String() != "800" { + t.Fatalf("healthy route = %+v", snapshot.Routes[1]) + } +} + +func TestReaderReadAuthUsesDirectRolesAndDelegatedFiller(t *testing.T) { + filler := common.HexToAddress("0x0000000000000000000000000000000000000f11") + marketMaker := common.HexToAddress("0x0000000000000000000000000000000000000a11") + owner := common.HexToAddress("0x0000000000000000000000000000000000000b11") + adapters := []common.Address{ + common.HexToAddress("0x0000000000000000000000000000000000000011"), + common.HexToAddress("0x0000000000000000000000000000000000000012"), + common.HexToAddress("0x0000000000000000000000000000000000000013"), + } + backend := &scriptedLiquidLaneBackend{latest: [][]chain.CallResult{ + { + successOutput(t, "marketMaker", filler), successOutput(t, "owner", owner), + successOutput(t, "marketMaker", marketMaker), successOutput(t, "owner", filler), + successOutput(t, "marketMaker", marketMaker), successOutput(t, "owner", owner), + }, + {successOutput(t, "isFiller", true)}, + }} + r := &Reader{chain: backend, log: logr.Discard(), dec: fixedDecimals{}, chainID: 11155111} + + auth, err := r.ReadAuth(context.Background(), adapters, filler) + if err != nil { + t.Fatalf("ReadAuth: %v", err) + } + if len(auth) != 3 || !auth[0].Authorized || !auth[1].Authorized || !auth[2].Authorized || !auth[2].IsFiller { + t.Fatalf("auth = %+v", auth) + } +} + +func TestReaderReadAuthAcceptsDelegatedFillerForZeroMarketMaker(t *testing.T) { + filler := common.HexToAddress("0x0000000000000000000000000000000000000f11") + owner := common.HexToAddress("0x0000000000000000000000000000000000000b11") + adapterAddress := common.HexToAddress("0x0000000000000000000000000000000000000011") + backend := &scriptedLiquidLaneBackend{latest: [][]chain.CallResult{ + {successOutput(t, "marketMaker", common.Address{}), successOutput(t, "owner", owner)}, + {successOutput(t, "isFiller", true)}, + }} + r := &Reader{chain: backend, log: logr.Discard(), dec: fixedDecimals{}, chainID: 11155111} + + auth, err := r.ReadAuth(t.Context(), []common.Address{adapterAddress}, filler) + if err != nil { + t.Fatalf("ReadAuth: %v", err) + } + if len(auth) != 1 || auth[0].MarketMaker != (common.Address{}) || auth[0].Owner != owner || + !auth[0].Authorized || !auth[0].IsFiller { + t.Fatalf("auth = %+v", auth) + } +} + +func TestReaderReadAuthRejectsIncompleteFillerResults(t *testing.T) { + filler := common.HexToAddress("0x0000000000000000000000000000000000000f11") + marketMaker := common.HexToAddress("0x0000000000000000000000000000000000000a11") + owner := common.HexToAddress("0x0000000000000000000000000000000000000b11") + adapterAddress := common.HexToAddress("0x0000000000000000000000000000000000000011") + backend := &scriptedLiquidLaneBackend{latest: [][]chain.CallResult{ + {successOutput(t, "marketMaker", marketMaker), successOutput(t, "owner", owner)}, + {}, + }} + r := &Reader{chain: backend, log: logr.Discard(), dec: fixedDecimals{}, chainID: 11155111} + + if _, err := r.ReadAuth(context.Background(), []common.Address{adapterAddress}, filler); err == nil { + t.Fatal("expected incomplete filler multicall error") + } +} + +func TestReaderFilterAuthorizedRoutesDropsUnauthorizedAdapters(t *testing.T) { + filler := common.HexToAddress("0x0000000000000000000000000000000000000f11") + owner := common.HexToAddress("0x0000000000000000000000000000000000000b11") + marketMaker := common.HexToAddress("0x0000000000000000000000000000000000000a11") + routes := []Route{testReaderRoute(1), testReaderRoute(2)} + backend := &scriptedLiquidLaneBackend{latest: [][]chain.CallResult{ + { + successOutput(t, "marketMaker", filler), successOutput(t, "owner", owner), + successOutput(t, "marketMaker", marketMaker), successOutput(t, "owner", owner), + }, + {successOutput(t, "isFiller", false)}, + }} + r := &Reader{chain: backend, log: logr.Discard(), dec: fixedDecimals{}, chainID: 11155111} + + got, err := r.FilterAuthorizedRoutes(context.Background(), routes, filler) + if err != nil { + t.Fatalf("FilterAuthorizedRoutes: %v", err) + } + if len(got) != 1 || got[0].ID != routes[0].ID { + t.Fatalf("authorized routes = %+v", got) + } +} + +func testReaderRoute(index byte) Route { + return NewRoute( + 11155111, + common.BytesToAddress([]byte{index}), + common.BytesToAddress([]byte{index + 10}), + common.BytesToAddress([]byte{index + 20}), + common.BytesToAddress([]byte{index + 30}), + 18, + 6, + ) +} + +func successOutput(t *testing.T, method string, values ...any) chain.CallResult { + t.Helper() + parsed, err := adapter.LiquidLaneAdapterMetaData.ParseABI() + if err != nil { + t.Fatalf("parse adapter ABI: %v", err) + } + data, err := packMethodOutput(parsed, method, values...) + if err != nil { + t.Fatalf("pack %s output: %v", method, err) + } + return chain.CallResult{Success: true, ReturnData: data} +} + +func successVaultOutput(t *testing.T, method string, values ...any) chain.CallResult { + t.Helper() + parsed, err := vaultv2.IVaultV2MetaData.ParseABI() + if err != nil { + t.Fatalf("parse vault ABI: %v", err) + } + data, err := packMethodOutput(parsed, method, values...) + if err != nil { + t.Fatalf("pack %s output: %v", method, err) + } + return chain.CallResult{Success: true, ReturnData: data} +} + +func successAssetOutput(t *testing.T, asset common.Address) chain.CallResult { + t.Helper() + parsed, err := erc4626.IERC4626MetaData.ParseABI() + if err != nil { + t.Fatalf("parse ERC4626 ABI: %v", err) + } + data, err := packMethodOutput(parsed, "asset", asset) + if err != nil { + t.Fatalf("pack asset output: %v", err) + } + return chain.CallResult{Success: true, ReturnData: data} +} + +func packMethodOutput(parsed *abi.ABI, method string, values ...any) ([]byte, error) { + return parsed.Methods[method].Outputs.Pack(values...) +} diff --git a/internal/liquidlane/reservations.go b/internal/liquidlane/reservations.go new file mode 100644 index 00000000..e5151f2c --- /dev/null +++ b/internal/liquidlane/reservations.go @@ -0,0 +1,91 @@ +package liquidlane + +import ( + "math/big" + "sync" +) + +// CapacityReservations tracks unavailable output by physical vault capacity. +type CapacityReservations map[CapacityID]*big.Int + +// Add accumulates a positive reservation without retaining the caller's big.Int. +func (reservations CapacityReservations) Add(capacityID CapacityID, amount *big.Int) { + if capacityID == "" || amount == nil || amount.Sign() <= 0 { + return + } + if reservations[capacityID] == nil { + reservations[capacityID] = new(big.Int) + } + reservations[capacityID].Add(reservations[capacityID], amount) +} + +// AddAll accumulates another reservation set. +func (reservations CapacityReservations) AddAll(additions CapacityReservations) { + for capacityID, amount := range additions { + reservations.Add(capacityID, amount) + } +} + +// CapacityLedger owns reservations for pending fills. Its zero value is ready to use. +type CapacityLedger struct { + mu sync.RWMutex + byKey map[string]CapacityReservations +} + +// Set stores one pending fill reservation. It reports whether the ledger changed. +func (ledger *CapacityLedger) Set(key string, reservations CapacityReservations) bool { + normalized, ok := cloneValidReservations(reservations) + if key == "" || !ok { + return false + } + ledger.mu.Lock() + defer ledger.mu.Unlock() + if ledger.byKey == nil { + ledger.byKey = make(map[string]CapacityReservations) + } + ledger.byKey[key] = normalized + return true +} + +// Delete releases one pending fill reservation. It reports whether the ledger changed. +func (ledger *CapacityLedger) Delete(key string) bool { + ledger.mu.Lock() + defer ledger.mu.Unlock() + if _, ok := ledger.byKey[key]; !ok { + return false + } + delete(ledger.byKey, key) + return true +} + +// Snapshot returns the aggregate reservation without exposing ledger state. +func (ledger *CapacityLedger) Snapshot() CapacityReservations { + ledger.mu.RLock() + defer ledger.mu.RUnlock() + out := make(CapacityReservations) + for _, reservations := range ledger.byKey { + out.AddAll(reservations) + } + return out +} + +// Len returns the number of pending fills in the ledger. +func (ledger *CapacityLedger) Len() int { + ledger.mu.RLock() + defer ledger.mu.RUnlock() + return len(ledger.byKey) +} + +func cloneValidReservations(reservations CapacityReservations) (CapacityReservations, bool) { + if len(reservations) == 0 { + return nil, false + } + out := make(CapacityReservations, len(reservations)) + for capacityID, amount := range reservations { + if capacityID == "" || amount == nil || amount.Sign() <= 0 { + return nil, false + } + out.Add(capacityID, amount) + } + return out, true +} diff --git a/internal/liquidlane/reservations_test.go b/internal/liquidlane/reservations_test.go new file mode 100644 index 00000000..261f3cd5 --- /dev/null +++ b/internal/liquidlane/reservations_test.go @@ -0,0 +1,24 @@ +package liquidlane + +import ( + "math/big" + "testing" +) + +func TestCapacityLedgerAggregatesAndReleasesClonedReservations(t *testing.T) { + var ledger CapacityLedger + first := CapacityReservations{"shared": big.NewInt(30)} + if !ledger.Set("first", first) || !ledger.Set("second", CapacityReservations{"shared": big.NewInt(20)}) { + t.Fatal("expected reservations to change ledger") + } + first["shared"].SetInt64(1) + if got := ledger.Snapshot()["shared"]; got == nil || got.Int64() != 50 || ledger.Len() != 2 { + t.Fatalf("snapshot = %v, len = %d", ledger.Snapshot(), ledger.Len()) + } + if !ledger.Delete("first") || ledger.Delete("missing") { + t.Fatal("unexpected delete result") + } + if got := ledger.Snapshot()["shared"]; got == nil || got.Int64() != 20 || ledger.Len() != 1 { + t.Fatalf("snapshot after release = %v, len = %d", ledger.Snapshot(), ledger.Len()) + } +} diff --git a/internal/liquidlane/snapshot/reader.go b/internal/liquidlane/snapshot/reader.go new file mode 100644 index 00000000..84601ca0 --- /dev/null +++ b/internal/liquidlane/snapshot/reader.go @@ -0,0 +1,192 @@ +// Package snapshot reads the common LiquidLane inventory, fill quote, and gas state +// consumed by protocol solvers. +package snapshot + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/chain" + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" +) + +// Quote contains direct and physical inventory plus optional gas state from the same decision boundary. +type Quote struct { + Direct []liquidlane.Inventory + Physical []liquidlane.Inventory + GasSnapshot *liquidlanegas.Snapshot + GasPrices *liquidlanegas.PriceSnapshot +} + +// Fill contains amount-specific direct and physical quotes plus optional current gas state. +type Fill struct { + Direct []liquidlane.FillQuote + Physical []liquidlane.FillQuote + GasSnapshot *liquidlanegas.Snapshot + GasPrices *liquidlanegas.PriceSnapshot +} + +type liquidReader interface { + ResolveRoutes(ctx context.Context, adapters []common.Address) ([]liquidlane.Route, error) + ReadInventory(ctx context.Context, routes []liquidlane.Route) ([]liquidlane.Inventory, error) + FilterAuthorized( + ctx context.Context, + inventory []liquidlane.Inventory, + filler common.Address, + ) ([]liquidlane.Inventory, error) + ReadFillQuotes( + ctx context.Context, + routes []liquidlane.Route, + tokenIn common.Address, + amountIn *big.Int, + ) ([]liquidlane.FillQuote, error) + FilterAuthorizedRoutes( + ctx context.Context, + routes []liquidlane.Route, + filler common.Address, + ) ([]liquidlane.Route, error) + ReadGasSnapshot(ctx context.Context, routes []liquidlane.Route) (*liquidlanegas.Snapshot, error) +} + +type gasReader interface { + ValidateTokens(tokens []liquidlanegas.Token) error + Read(ctx context.Context, tokens []liquidlanegas.Token, now time.Time) (*liquidlanegas.PriceSnapshot, error) +} + +// Reader owns the protocol-neutral LiquidLane read path shared by solver integrations. +type Reader struct { + liquid liquidReader + gas gasReader +} + +func New( + c *chain.Client, log logr.Logger, gasCfg *liquidlanegas.OracleConfig, liquidityLens common.Address, +) (*Reader, error) { + var gas gasReader + if gasCfg != nil { + reader, err := liquidlanegas.NewOracleReader(c, *gasCfg) + if err != nil { + return nil, err + } + gas = reader + } + return newReader(liquidlane.NewReader(c, log, liquidityLens), gas), nil +} + +func newReader(liquid liquidReader, gas gasReader) *Reader { + return &Reader{liquid: liquid, gas: gas} +} + +func (r *Reader) ResolveRoutes(ctx context.Context, adapters []common.Address) ([]liquidlane.Route, error) { + return r.liquid.ResolveRoutes(ctx, adapters) +} + +func (r *Reader) ValidateGasTokens(routes []liquidlane.Route) error { + if r.gas == nil { + return nil + } + return r.gas.ValidateTokens(routeTokens(routes)) +} + +func (r *Reader) FilterAuthorizedRoutes( + ctx context.Context, + routes []liquidlane.Route, + executor common.Address, +) ([]liquidlane.Route, error) { + return r.liquid.FilterAuthorizedRoutes(ctx, routes, executor) +} + +// ReadFillQuotes reads amount-specific physical quotes without direct-route authorization or gas state. +func (r *Reader) ReadFillQuotes( + ctx context.Context, + routes []liquidlane.Route, + tokenIn common.Address, + amountIn *big.Int, +) ([]liquidlane.FillQuote, error) { + return r.liquid.ReadFillQuotes(ctx, routes, tokenIn, amountIn) +} + +func (r *Reader) Quote( + ctx context.Context, + routes []liquidlane.Route, + executor common.Address, + now time.Time, +) (Quote, error) { + physical, err := r.liquid.ReadInventory(ctx, routes) + if err != nil { + return Quote{}, err + } + direct, err := r.liquid.FilterAuthorized(ctx, physical, executor) + if err != nil { + return Quote{}, err + } + gasSnapshot, prices, err := r.readGas(ctx, routes, now) + if err != nil { + return Quote{}, err + } + return Quote{Direct: direct, Physical: physical, GasSnapshot: gasSnapshot, GasPrices: prices}, nil +} + +func (r *Reader) Fill( + ctx context.Context, + routes []liquidlane.Route, + executor, tokenIn common.Address, + amountIn *big.Int, + now time.Time, +) (Fill, error) { + physical, err := r.liquid.ReadFillQuotes(ctx, routes, tokenIn, amountIn) + if err != nil { + return Fill{}, err + } + authorized, err := r.liquid.FilterAuthorizedRoutes(ctx, routes, executor) + if err != nil { + return Fill{}, err + } + directRoute := make(map[liquidlane.RouteID]bool, len(authorized)) + for _, route := range authorized { + directRoute[route.ID] = true + } + direct := make([]liquidlane.FillQuote, 0, len(physical)) + for _, quote := range physical { + if directRoute[quote.ID] { + direct = append(direct, quote) + } + } + gasSnapshot, prices, err := r.readGas(ctx, routes, now) + if err != nil { + return Fill{}, err + } + return Fill{Direct: direct, Physical: physical, GasSnapshot: gasSnapshot, GasPrices: prices}, nil +} + +func (r *Reader) readGas( + ctx context.Context, + routes []liquidlane.Route, + now time.Time, +) (*liquidlanegas.Snapshot, *liquidlanegas.PriceSnapshot, error) { + if r.gas == nil { + return nil, nil, nil + } + snapshot, err := r.liquid.ReadGasSnapshot(ctx, routes) + if err != nil { + return nil, nil, err + } + prices, err := r.gas.Read(ctx, routeTokens(routes), now) + if err != nil { + return nil, nil, err + } + return snapshot, prices, nil +} + +func routeTokens(routes []liquidlane.Route) []liquidlanegas.Token { + tokens := make([]liquidlanegas.Token, 0, len(routes)) + for _, route := range routes { + tokens = append(tokens, liquidlanegas.Token{Address: route.TokenOut, Decimals: route.TokenOutDecimals}) + } + return tokens +} diff --git a/internal/liquidlane/snapshot/reader_test.go b/internal/liquidlane/snapshot/reader_test.go new file mode 100644 index 00000000..ca2ccfdf --- /dev/null +++ b/internal/liquidlane/snapshot/reader_test.go @@ -0,0 +1,152 @@ +package snapshot + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" +) + +type fakeLiquidReader struct { + routes []liquidlane.Route + inventory []liquidlane.Inventory + quotes []liquidlane.FillQuote + authorized []liquidlane.Route + gas *liquidlanegas.Snapshot + gasReads int +} + +func (f *fakeLiquidReader) ResolveRoutes(context.Context, []common.Address) ([]liquidlane.Route, error) { + return f.routes, nil +} + +func (f *fakeLiquidReader) ReadInventory(context.Context, []liquidlane.Route) ([]liquidlane.Inventory, error) { + return f.inventory, nil +} + +func (f *fakeLiquidReader) FilterAuthorized( + _ context.Context, + inventory []liquidlane.Inventory, + _ common.Address, +) ([]liquidlane.Inventory, error) { + allowed := make(map[liquidlane.RouteID]bool, len(f.authorized)) + for _, route := range f.authorized { + allowed[route.ID] = true + } + out := make([]liquidlane.Inventory, 0, len(inventory)) + for _, item := range inventory { + if allowed[item.ID] { + out = append(out, item) + } + } + return out, nil +} + +func (f *fakeLiquidReader) ReadFillQuotes( + context.Context, + []liquidlane.Route, + common.Address, + *big.Int, +) ([]liquidlane.FillQuote, error) { + return f.quotes, nil +} + +func (f *fakeLiquidReader) FilterAuthorizedRoutes( + context.Context, + []liquidlane.Route, + common.Address, +) ([]liquidlane.Route, error) { + return f.authorized, nil +} + +func (f *fakeLiquidReader) ReadGasSnapshot(context.Context, []liquidlane.Route) (*liquidlanegas.Snapshot, error) { + f.gasReads++ + return f.gas, nil +} + +type fakeGasReader struct { + tokens []liquidlanegas.Token + prices *liquidlanegas.PriceSnapshot +} + +func (f *fakeGasReader) ValidateTokens(tokens []liquidlanegas.Token) error { + f.tokens = tokens + return nil +} + +func (f *fakeGasReader) Read( + _ context.Context, + tokens []liquidlanegas.Token, + _ time.Time, +) (*liquidlanegas.PriceSnapshot, error) { + f.tokens = tokens + return f.prices, nil +} + +func TestReaderBuildsQuoteAndFillSnapshots(t *testing.T) { + t.Parallel() + routeA := liquidlane.Route{ID: "a", TokenOut: common.HexToAddress("0xa"), TokenOutDecimals: 6} + routeB := liquidlane.Route{ID: "b", TokenOut: common.HexToAddress("0xb"), TokenOutDecimals: 18} + liquid := &fakeLiquidReader{ + routes: []liquidlane.Route{routeA, routeB}, authorized: []liquidlane.Route{routeB}, + inventory: []liquidlane.Inventory{{Route: routeA}, {Route: routeB}}, + quotes: []liquidlane.FillQuote{ + {Inventory: liquidlane.Inventory{Route: routeA}}, + {Inventory: liquidlane.Inventory{Route: routeB}}, + }, + gas: &liquidlanegas.Snapshot{}, + } + gas := &fakeGasReader{prices: &liquidlanegas.PriceSnapshot{}} + reader := newReader(liquid, gas) + + quote, err := reader.Quote(t.Context(), liquid.routes, common.Address{}, time.Now()) + if err != nil { + t.Fatalf("quote: %v", err) + } + if len(quote.Direct) != 1 || quote.Direct[0].ID != routeB.ID || len(quote.Physical) != 2 || + quote.GasSnapshot != liquid.gas || quote.GasPrices != gas.prices { + t.Fatalf("quote snapshot = %#v", quote) + } + + fill, err := reader.Fill(t.Context(), liquid.routes, common.Address{}, common.Address{}, big.NewInt(1), time.Now()) + if err != nil { + t.Fatalf("fill: %v", err) + } + if len(fill.Direct) != 1 || fill.Direct[0].ID != routeB.ID || len(fill.Physical) != 2 { + t.Fatalf("fill snapshot = %#v", fill) + } + if len(gas.tokens) != 2 || gas.tokens[0].Address != routeA.TokenOut || gas.tokens[1].Decimals != 18 { + t.Fatalf("gas tokens = %#v", gas.tokens) + } + + withoutGas := newReader(liquid, nil) + if err := withoutGas.ValidateGasTokens(liquid.routes); err != nil { + t.Fatalf("validate gas tokens: %v", err) + } + quote, err = withoutGas.Quote(t.Context(), liquid.routes, common.Address{}, time.Now()) + if err != nil { + t.Fatalf("quote without gas: %v", err) + } + fill, err = withoutGas.Fill( + t.Context(), + liquid.routes, + common.Address{}, + common.Address{}, + big.NewInt(1), + time.Now(), + ) + if err != nil { + t.Fatalf("fill without gas: %v", err) + } + if quote.GasSnapshot != nil || quote.GasPrices != nil || fill.GasSnapshot != nil || fill.GasPrices != nil { + t.Fatalf("gas data populated while disabled: quote=%#v fill=%#v", quote, fill) + } + if liquid.gasReads != 2 { + t.Fatalf("gas reads = %d, want 2 from enabled reader only", liquid.gasReads) + } +} diff --git a/internal/liquidlane/strategies/fill.go b/internal/liquidlane/strategies/fill.go new file mode 100644 index 00000000..9de6a001 --- /dev/null +++ b/internal/liquidlane/strategies/fill.go @@ -0,0 +1,172 @@ +package strategies + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" +) + +// FillRoute is one canonical LiquidLane execution leg selected by a strategy. +type FillRoute struct { + CandidateID liquidlane.CandidateID `json:"-"` + RouteID liquidlane.RouteID `json:"routeId"` + CapacityID liquidlane.CapacityID `json:"capacityId"` + Adapter common.Address `json:"adapter"` + AmountIn *big.Int `json:"amountIn"` + ExpectedAmountOut *big.Int `json:"expectedAmountOut"` + MinAmountOut *big.Int `json:"minAmountOut"` + ReservedAmountOut *big.Int `json:"reservedAmountOut"` + DiscountID *common.Hash `json:"discountId"` +} + +// FillValidation contains the solver-owned facts used to validate an external fill decision. +type FillValidation struct { + TokenIn common.Address + TokenOut common.Address + AmountIn *big.Int + RequiredAmountOut *big.Int + RequireSingleRoute bool + MaxRoutes int + + Quotes []liquidlane.FillQuote + Reservations liquidlane.CapacityReservations + GasSnapshot *liquidlanegas.Snapshot + GasPrices *liquidlanegas.PriceSnapshot + MaxFeePerGas *big.Int + GasEnvelope GasEnvelope +} + +// ValidateFillRoutes validates and canonicalizes untrusted strategy output. +func ValidateFillRoutes(input FillValidation, routes []FillRoute) ([]FillRoute, error) { + if input.AmountIn == nil || input.AmountIn.Sign() <= 0 { + return nil, errors.New("fill input amount is invalid") + } + if input.RequiredAmountOut == nil || input.RequiredAmountOut.Sign() <= 0 { + return nil, errors.New("fill output amount is invalid") + } + if len(routes) == 0 || len(routes) > input.MaxRoutes { + return nil, errors.Errorf("fill has %d routes, allowed [1,%d]", len(routes), input.MaxRoutes) + } + if input.RequireSingleRoute && len(routes) != 1 { + return nil, errors.New("fill aggregates a permissioned token") + } + + candidates := make(map[liquidlane.CandidateID]liquidlane.FillQuote, len(input.Quotes)) + for _, candidate := range input.Quotes { + candidates[liquidlane.NewCandidateID(candidate.Route, candidate.DiscountID)] = candidate + } + + normalized := make([]FillRoute, len(routes)) + usedRoutes := make(map[liquidlane.RouteID]bool, len(routes)) + capacityLimits := make(liquidlane.CapacityReservations, len(routes)) + capacityUsed := make(liquidlane.CapacityReservations, len(routes)) + totalInput := new(big.Int) + totalMinimumOutput := new(big.Int) + gasLegs := make([]GasLeg, 0, len(routes)) + for index, route := range routes { + id := liquidlane.NewCandidateID(liquidlane.Route{ID: route.RouteID}, route.DiscountID) + candidate, ok := candidates[id] + if !ok { + return nil, errors.Errorf("fill route %d uses unknown candidate %s", index, id) + } + if candidate.TokenIn != input.TokenIn || candidate.TokenOut != input.TokenOut { + return nil, errors.Errorf("fill route %d uses a candidate from another token pair", index) + } + if usedRoutes[candidate.ID] { + return nil, errors.Errorf("fill repeats physical route %s", candidate.ID) + } + usedRoutes[candidate.ID] = true + if !validFillRouteAmounts(route) { + return nil, errors.Errorf("fill route %d has invalid amounts", index) + } + if candidate.MaxAssets == nil || candidate.MaxAssets.Sign() <= 0 { + return nil, errors.Errorf("fill route %d candidate capacity is invalid", index) + } + available := scaledOutput(candidate, route.AmountIn) + if route.ExpectedAmountOut.Cmp(available) > 0 || + route.ReservedAmountOut.Cmp(route.ExpectedAmountOut) < 0 || + route.ReservedAmountOut.Cmp(candidate.MaxAssets) > 0 { + return nil, errors.Errorf("fill route %d exceeds current candidate output or capacity", index) + } + + capacityID := liquidlane.RouteCapacityID(candidate.Route) + normalized[index] = cloneFillRoute(route) + normalized[index].CandidateID = id + normalized[index].RouteID = candidate.ID + normalized[index].CapacityID = capacityID + normalized[index].Adapter = candidate.Adapter + normalized[index].DiscountID = liquidlane.CloneHash(candidate.DiscountID) + totalInput.Add(totalInput, route.AmountIn) + totalMinimumOutput.Add(totalMinimumOutput, route.MinAmountOut) + if limit := capacityLimits[capacityID]; limit == nil || candidate.MaxAssets.Cmp(limit) > 0 { + capacityLimits[capacityID] = liquidlane.CloneBig(candidate.MaxAssets) + } + capacityUsed.Add(capacityID, route.ReservedAmountOut) + gasLegs = append(gasLegs, GasLeg{ + Route: candidate.Route, AmountOut: available, Private: candidate.DiscountID != nil, + }) + } + + if totalInput.Cmp(input.AmountIn) != 0 { + return nil, errors.Errorf("fill input sum %s does not match order %s", totalInput, input.AmountIn) + } + gasCost, err := FillGasCost( + input.MaxFeePerGas, input.TokenOut, input.GasPrices, input.GasSnapshot, input.GasEnvelope, gasLegs, + ) + if err != nil { + return nil, errors.Errorf("fill gas cost: %w", err) + } + requiredOutput := new(big.Int).Add(input.RequiredAmountOut, gasCost) + if totalMinimumOutput.Cmp(requiredOutput) < 0 { + return nil, errors.New("fill minimum output does not cover the order") + } + for capacityID, used := range capacityUsed { + if reserved := input.Reservations[capacityID]; reserved != nil && reserved.Sign() > 0 { + used.Add(used, reserved) + } + if used.Cmp(capacityLimits[capacityID]) > 0 { + return nil, errors.Errorf("fill exceeds shared capacity %s", capacityID) + } + } + return normalized, nil +} + +// FillRouteReservations validates and aggregates the capacity reserved by a fill plan. +func FillRouteReservations(routes []FillRoute) (liquidlane.CapacityReservations, bool) { + reservations := make(liquidlane.CapacityReservations) + for _, route := range routes { + if route.CapacityID == "" || route.ReservedAmountOut == nil || route.ReservedAmountOut.Sign() <= 0 { + return nil, false + } + reservations.Add(route.CapacityID, route.ReservedAmountOut) + } + return reservations, len(reservations) > 0 +} + +func validFillRouteAmounts(route FillRoute) bool { + return route.AmountIn != nil && route.AmountIn.Sign() > 0 && + route.ExpectedAmountOut != nil && route.ExpectedAmountOut.Sign() > 0 && + route.MinAmountOut != nil && route.MinAmountOut.Sign() > 0 && + route.MinAmountOut.Cmp(route.ExpectedAmountOut) <= 0 && + route.ReservedAmountOut != nil && route.ReservedAmountOut.Sign() > 0 +} + +func scaledOutput(candidate liquidlane.FillQuote, amountIn *big.Int) *big.Int { + if candidate.AmountIn == nil || candidate.AmountIn.Sign() <= 0 || candidate.MaxAmountOut == nil { + return new(big.Int) + } + return new(big.Int).Div(new(big.Int).Mul(candidate.MaxAmountOut, amountIn), candidate.AmountIn) +} + +func cloneFillRoute(route FillRoute) FillRoute { + route.AmountIn = liquidlane.CloneBig(route.AmountIn) + route.ExpectedAmountOut = liquidlane.CloneBig(route.ExpectedAmountOut) + route.MinAmountOut = liquidlane.CloneBig(route.MinAmountOut) + route.ReservedAmountOut = liquidlane.CloneBig(route.ReservedAmountOut) + route.DiscountID = liquidlane.CloneHash(route.DiscountID) + return route +} diff --git a/internal/liquidlane/strategies/fill_test.go b/internal/liquidlane/strategies/fill_test.go new file mode 100644 index 00000000..625c4993 --- /dev/null +++ b/internal/liquidlane/strategies/fill_test.go @@ -0,0 +1,159 @@ +package strategies + +import ( + "encoding/json" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" +) + +func TestValidateFillRoutesCanonicalizesAndChecksCapacity(t *testing.T) { + t.Parallel() + tokenIn := common.HexToAddress("0x1000000000000000000000000000000000000001") + tokenOut := common.HexToAddress("0x2000000000000000000000000000000000000002") + adapter := common.HexToAddress("0x3000000000000000000000000000000000000003") + route := liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", Adapter: adapter, + TokenIn: tokenIn, TokenOut: tokenOut, + } + quote := liquidlane.FillQuote{ + Inventory: liquidlane.Inventory{Route: route, MaxAssets: big.NewInt(100)}, + AmountIn: big.NewInt(10), MaxAmountOut: big.NewInt(20), + } + untrusted := []FillRoute{{ + RouteID: route.ID, Adapter: common.HexToAddress("0xdead"), AmountIn: big.NewInt(10), + ExpectedAmountOut: big.NewInt(20), MinAmountOut: big.NewInt(18), ReservedAmountOut: big.NewInt(20), + }} + + normalized, err := ValidateFillRoutes(FillValidation{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(10), RequiredAmountOut: big.NewInt(18), + MaxRoutes: 3, Quotes: []liquidlane.FillQuote{quote}, + }, untrusted) + if err != nil { + t.Fatalf("validate: %v", err) + } + if len(normalized) != 1 || normalized[0].Adapter != adapter || normalized[0].CapacityID != route.CapacityID { + t.Fatalf("normalized route = %#v", normalized) + } + if untrusted[0].Adapter == adapter || untrusted[0].CapacityID != "" { + t.Fatalf("input route was mutated: %#v", untrusted[0]) + } + + _, err = ValidateFillRoutes(FillValidation{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(10), RequiredAmountOut: big.NewInt(18), + MaxRoutes: 3, Quotes: []liquidlane.FillQuote{quote}, + Reservations: liquidlane.CapacityReservations{route.CapacityID: big.NewInt(90)}, + }, untrusted) + if err == nil { + t.Fatal("expected shared capacity error") + } +} + +func TestValidateFillRoutesRejectsUntrustedOutput(t *testing.T) { + t.Parallel() + tokenIn := common.HexToAddress("0x1000000000000000000000000000000000000001") + tokenOut := common.HexToAddress("0x2000000000000000000000000000000000000002") + route := liquidlane.Route{ID: "route-1", CapacityID: "capacity-1", TokenIn: tokenIn, TokenOut: tokenOut} + quote := liquidlane.FillQuote{ + Inventory: liquidlane.Inventory{Route: route, MaxAssets: big.NewInt(100)}, + AmountIn: big.NewInt(10), MaxAmountOut: big.NewInt(20), + } + valid := FillRoute{ + RouteID: route.ID, AmountIn: big.NewInt(10), ExpectedAmountOut: big.NewInt(20), + MinAmountOut: big.NewInt(18), ReservedAmountOut: big.NewInt(20), + } + tests := map[string]func(*FillValidation, *FillRoute){ + "unknown candidate": func(_ *FillValidation, route *FillRoute) { route.RouteID = "missing" }, + "wrong pair": func(input *FillValidation, _ *FillRoute) { input.TokenOut = common.HexToAddress("0xbeef") }, + "input sum": func(_ *FillValidation, route *FillRoute) { route.AmountIn = big.NewInt(9) }, + "output": func(_ *FillValidation, route *FillRoute) { route.ExpectedAmountOut = big.NewInt(21) }, + "minimum": func(_ *FillValidation, route *FillRoute) { route.MinAmountOut = big.NewInt(17) }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + input := FillValidation{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(10), + RequiredAmountOut: big.NewInt(18), MaxRoutes: 3, Quotes: []liquidlane.FillQuote{quote}, + } + candidate := cloneFillRoute(valid) + mutate(&input, &candidate) + if _, err := ValidateFillRoutes(input, []FillRoute{candidate}); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +func TestValidateFillRoutesIncludesSettlementGas(t *testing.T) { + t.Parallel() + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + adapter := common.HexToAddress("0x3333333333333333333333333333333333333333") + vault := common.HexToAddress("0x4444444444444444444444444444444444444444") + route := liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", Adapter: adapter, Vault: vault, + TokenIn: tokenIn, TokenOut: tokenOut, + } + quote := liquidlane.FillQuote{ + Inventory: liquidlane.Inventory{Route: route, MaxAssets: big.NewInt(1_000_000)}, + AmountIn: big.NewInt(1_000_000), MaxAmountOut: big.NewInt(1_000_000), + } + fill := FillRoute{ + RouteID: route.ID, AmountIn: big.NewInt(1_000_000), ExpectedAmountOut: big.NewInt(1_000_000), + MinAmountOut: big.NewInt(900_000), ReservedAmountOut: big.NewInt(1_000_000), + } + input := FillValidation{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(1_000_000), + RequiredAmountOut: big.NewInt(500_000), MaxRoutes: 3, Quotes: []liquidlane.FillQuote{quote}, + MaxFeePerGas: big.NewInt(1), + GasPrices: liquidlanegas.NewPriceSnapshot(map[common.Address]*big.Int{ + tokenOut: big.NewInt(1_000_000_000_000_000_000), + }), + GasSnapshot: &liquidlanegas.Snapshot{ + Adapters: map[common.Address]*liquidlanegas.AdapterState{ + adapter: {Vault: vault, Acquire: map[common.Address]*big.Int{}}, + }, + Vaults: map[common.Address]*liquidlanegas.VaultState{ + vault: {FreeAssets: big.NewInt(1_000_000), Withdrawable: big.NewInt(1_000_000)}, + }, + }, + GasEnvelope: testGasEnvelope(), + } + + if _, err := ValidateFillRoutes(input, []FillRoute{fill}); err == nil { + t.Fatal("expected gas-negative fill to be rejected") + } +} + +func TestFillRouteReservationsAggregatesAndClonesInput(t *testing.T) { + t.Parallel() + amount := big.NewInt(4) + reservations, ok := FillRouteReservations([]FillRoute{ + {CapacityID: "shared", ReservedAmountOut: amount}, + {CapacityID: "shared", ReservedAmountOut: big.NewInt(6)}, + }) + if !ok || reservations["shared"].Cmp(big.NewInt(10)) != 0 { + t.Fatalf("reservations = %#v, ok = %t", reservations, ok) + } + amount.SetInt64(99) + if reservations["shared"].Cmp(big.NewInt(10)) != 0 { + t.Fatalf("reservation retained caller amount: %s", reservations["shared"]) + } +} + +func TestFillRouteWireOmitsInternalCandidateID(t *testing.T) { + t.Parallel() + raw, err := json.Marshal(FillRoute{CandidateID: "internal", RouteID: "route"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(raw), "internal") || !strings.Contains(string(raw), `"routeId":"route"`) { + t.Fatalf("wire route = %s", raw) + } +} diff --git a/internal/liquidlane/strategies/gas.go b/internal/liquidlane/strategies/gas.go new file mode 100644 index 00000000..af0e21b0 --- /dev/null +++ b/internal/liquidlane/strategies/gas.go @@ -0,0 +1,146 @@ +// Package strategies contains neutral inputs and economics shared by +// LiquidLane decision strategies. +package strategies + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" +) + +const ( + nativeUnit = 1_000_000_000_000_000_000 +) + +// GasEnvelope is the protocol-specific fixed gas around LiquidLane route execution. +type GasEnvelope struct { + SettlementUnits uint64 + PrivateRouteUnits uint64 +} + +// GasLeg describes one LiquidLane swap included in a settlement. +type GasLeg struct { + Route liquidlane.Route + AmountOut *big.Int + Private bool +} + +// GasPricing converts predicted settlement gas into tokenOut. +type GasPricing struct { + feePerGas *big.Int + tokenOutPerNative *big.Int + snapshot *liquidlanegas.Snapshot + envelope GasEnvelope +} + +func NewGasPricing( + maxFeePerGas *big.Int, + tokenOut common.Address, + prices *liquidlanegas.PriceSnapshot, + snapshot *liquidlanegas.Snapshot, + reserveBps int, + envelope GasEnvelope, +) (GasPricing, error) { + if maxFeePerGas == nil || maxFeePerGas.Sign() < 0 { + return GasPricing{}, errors.New("max fee per gas must be non-negative") + } + rate := prices.TokenOutPerNative(tokenOut) + if maxFeePerGas.Sign() > 0 && (rate == nil || rate.Sign() <= 0) { + return GasPricing{}, errors.Errorf("gas oracle: missing tokenOut rate for %s", tokenOut.Hex()) + } + if rate == nil { + rate = new(big.Int) + } + return GasPricing{ + feePerGas: new(big.Int).Set(maxFeePerGas), tokenOutPerNative: rate, + snapshot: liquidlanegas.WithReserveBps(snapshot, reserveBps), envelope: envelope, + }, nil +} + +func (p GasPricing) Cost(legs []GasLeg) *big.Int { + return fillGasCostAtRate(p.feePerGas, p.tokenOutPerNative, p.snapshot, p.envelope, legs) +} + +// MaxCost bounds settlement gas without depending on adapter liquidity or route ordering. +func (p GasPricing) MaxCost(routeCount, privateRouteCount int) *big.Int { + if routeCount <= 0 || p.feePerGas == nil || p.feePerGas.Sign() <= 0 || + p.tokenOutPerNative == nil || p.tokenOutPerNative.Sign() <= 0 { + return new(big.Int) + } + privateRouteCount = min(max(privateRouteCount, 0), routeCount) + units := p.envelope.SettlementUnits + for range routeCount { + units = saturatingAdd( + units, + liquidlanegas.UnitsForRouteAt(liquidlanegas.RouteUnknown, true), + ) + } + for range privateRouteCount { + units = saturatingAdd(units, p.envelope.PrivateRouteUnits) + } + nativeCost := new(big.Int).Mul(p.feePerGas, new(big.Int).SetUint64(units)) + return liquidlane.MulDivUp(nativeCost, p.tokenOutPerNative, big.NewInt(nativeUnit)) +} + +// FillGasCost predicts a LiquidLane settlement and converts its native gas cost into tokenOut. +func FillGasCost( + maxFeePerGas *big.Int, + tokenOut common.Address, + prices *liquidlanegas.PriceSnapshot, + snapshot *liquidlanegas.Snapshot, + envelope GasEnvelope, + legs []GasLeg, +) (*big.Int, error) { + if maxFeePerGas == nil || maxFeePerGas.Sign() == 0 || len(legs) == 0 { + return new(big.Int), nil + } + if maxFeePerGas.Sign() < 0 { + return nil, errors.New("max fee per gas must be non-negative") + } + rate := prices.TokenOutPerNative(tokenOut) + if rate == nil || rate.Sign() <= 0 { + return nil, errors.Errorf("gas oracle: missing tokenOut rate for %s", tokenOut.Hex()) + } + return fillGasCostAtRate(maxFeePerGas, rate, snapshot, envelope, legs), nil +} + +func fillGasCostAtRate( + maxFeePerGas, tokenOutPerNative *big.Int, + snapshot *liquidlanegas.Snapshot, + envelope GasEnvelope, + legs []GasLeg, +) *big.Int { + if maxFeePerGas == nil || maxFeePerGas.Sign() <= 0 || tokenOutPerNative == nil || + tokenOutPerNative.Sign() <= 0 || len(legs) == 0 { + return new(big.Int) + } + demands := make([]liquidlanegas.AdapterDemand, 0, len(legs)) + units := envelope.SettlementUnits + for _, leg := range legs { + demands = append(demands, liquidlanegas.AdapterDemand{ + Adapter: leg.Route.Adapter, + Vault: leg.Route.Vault, + Demand: liquidlanegas.Demand{ + Collateral: leg.Route.TokenIn, + AmountOut: liquidlane.CloneBig(leg.AmountOut), + }, + }) + if leg.Private { + units = saturatingAdd(units, envelope.PrivateRouteUnits) + } + } + units = saturatingAdd(units, liquidlanegas.PredictAdapters(demands, snapshot).Units) + nativeCost := new(big.Int).Mul(maxFeePerGas, new(big.Int).SetUint64(units)) + return liquidlane.MulDivUp(nativeCost, tokenOutPerNative, big.NewInt(nativeUnit)) +} + +func saturatingAdd(left, right uint64) uint64 { + if right > ^uint64(0)-left { + return ^uint64(0) + } + return left + right +} diff --git a/internal/liquidlane/strategies/gas_test.go b/internal/liquidlane/strategies/gas_test.go new file mode 100644 index 00000000..aa123f58 --- /dev/null +++ b/internal/liquidlane/strategies/gas_test.go @@ -0,0 +1,115 @@ +package strategies + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" +) + +func testGasEnvelope() GasEnvelope { + return GasEnvelope{SettlementUnits: 250_000, PrivateRouteUnits: 75_000} +} + +func TestFillGasCostIncludesSettlementRouteAndPrivateOverhead(t *testing.T) { + adapter := common.HexToAddress("0x1111111111111111111111111111111111111111") + vault := common.HexToAddress("0x2222222222222222222222222222222222222222") + tokenIn := common.HexToAddress("0x3333333333333333333333333333333333333333") + tokenOut := common.HexToAddress("0x4444444444444444444444444444444444444444") + snapshot := &liquidlanegas.Snapshot{ + Adapters: map[common.Address]*liquidlanegas.AdapterState{ + adapter: {Vault: vault, Acquire: map[common.Address]*big.Int{}}, + }, + Vaults: map[common.Address]*liquidlanegas.VaultState{ + vault: {FreeAssets: big.NewInt(100), Withdrawable: big.NewInt(100)}, + }, + } + prices := liquidlanegas.NewPriceSnapshot(map[common.Address]*big.Int{tokenOut: big.NewInt(nativeUnit)}) + leg := GasLeg{ + Route: liquidlane.Route{Adapter: adapter, Vault: vault, TokenIn: tokenIn}, AmountOut: big.NewInt(10), + } + + direct, err := FillGasCost(big.NewInt(3), tokenOut, prices, snapshot, testGasEnvelope(), []GasLeg{leg}) + if err != nil { + t.Fatalf("direct FillGasCost: %v", err) + } + wantDirect := new(big.Int).SetUint64( + testGasEnvelope().SettlementUnits + liquidlanegas.UnitsForRouteAt(liquidlanegas.RouteAllocate, true), + ) + wantDirect.Mul(wantDirect, big.NewInt(3)) + if direct.Cmp(wantDirect) != 0 { + t.Fatalf("direct gas cost = %s, want %s", direct, wantDirect) + } + + leg.Private = true + private, err := FillGasCost(big.NewInt(3), tokenOut, prices, snapshot, testGasEnvelope(), []GasLeg{leg}) + if err != nil { + t.Fatalf("private FillGasCost: %v", err) + } + wantPrivate := new(big.Int).Add( + wantDirect, + new(big.Int).Mul(new(big.Int).SetUint64(testGasEnvelope().PrivateRouteUnits), big.NewInt(3)), + ) + if private.Cmp(wantPrivate) != 0 { + t.Fatalf("private gas cost = %s, want %s", private, wantPrivate) + } +} + +func TestGasPricingAppliesInventoryReserveBeforeRoutePrediction(t *testing.T) { + adapter := common.HexToAddress("0x1111111111111111111111111111111111111111") + vault := common.HexToAddress("0x1414141414141414141414141414141414141414") + tokenIn := common.HexToAddress("0x1212121212121212121212121212121212121212") + tokenOut := common.HexToAddress("0x3333333333333333333333333333333333333333") + snapshot := &liquidlanegas.Snapshot{ + Adapters: map[common.Address]*liquidlanegas.AdapterState{ + adapter: {Vault: vault, Acquire: map[common.Address]*big.Int{}}, + }, + Vaults: map[common.Address]*liquidlanegas.VaultState{ + vault: {FreeAssets: big.NewInt(100), Withdrawable: big.NewInt(200)}, + }, + } + prices := liquidlanegas.NewPriceSnapshot(map[common.Address]*big.Int{tokenOut: big.NewInt(nativeUnit)}) + pricing, err := NewGasPricing(big.NewInt(1), tokenOut, prices, snapshot, 1_000, testGasEnvelope()) + if err != nil { + t.Fatal(err) + } + cost := pricing.Cost([]GasLeg{{ + Route: liquidlane.Route{Adapter: adapter, Vault: vault, TokenIn: tokenIn}, AmountOut: big.NewInt(95), + }}) + want := new(big.Int).SetUint64( + testGasEnvelope().SettlementUnits + liquidlanegas.UnitsForRouteAt(liquidlanegas.RouteDeallocate, true), + ) + if cost.Cmp(want) != 0 { + t.Fatalf("reserved route cost = %s, want %s", cost, want) + } +} + +func TestGasPricingMaxCostBoundsEveryRouteAsFirstUnknown(t *testing.T) { + tokenOut := common.HexToAddress("0x3333333333333333333333333333333333333333") + prices := liquidlanegas.NewPriceSnapshot(map[common.Address]*big.Int{tokenOut: big.NewInt(nativeUnit)}) + pricing, err := NewGasPricing(big.NewInt(2), tokenOut, prices, nil, 0, testGasEnvelope()) + if err != nil { + t.Fatal(err) + } + cost := pricing.MaxCost(3, 2) + units := testGasEnvelope().SettlementUnits + + 3*liquidlanegas.UnitsForRouteAt(liquidlanegas.RouteUnknown, true) + + 2*testGasEnvelope().PrivateRouteUnits + want := new(big.Int).Mul(new(big.Int).SetUint64(units), big.NewInt(2)) + if cost.Cmp(want) != 0 { + t.Fatalf("max gas cost = %s, want %s", cost, want) + } +} + +func TestFillGasCostRejectsMissingTokenRate(t *testing.T) { + tokenOut := common.HexToAddress("0x4444444444444444444444444444444444444444") + _, err := FillGasCost( + big.NewInt(1), tokenOut, nil, nil, testGasEnvelope(), []GasLeg{{AmountOut: big.NewInt(1)}}, + ) + if err == nil { + t.Fatal("expected missing token rate error") + } +} diff --git a/internal/liquidlane/strategies/greedy/allocation.go b/internal/liquidlane/strategies/greedy/allocation.go new file mode 100644 index 00000000..bb3697c2 --- /dev/null +++ b/internal/liquidlane/strategies/greedy/allocation.go @@ -0,0 +1,278 @@ +// Package greedy contains the greedy LiquidLane quote and fill strategy shared +// by protocol-specific solver strategies. +package greedy + +import ( + "math/big" + "sort" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +// Allocation is the selected amount for one candidate. +type Allocation struct { + Candidate liquidlane.QuoteCandidate + AmountIn *big.Int + AmountOut *big.Int +} + +// allocationResult describes the allocated prefix of an exact-input request. +type allocationResult struct { + Allocations []Allocation + TotalAmountIn *big.Int + TotalAmountOut *big.Int + Remaining *big.Int +} + +// allocator is an immutable, normalized set of priced LiquidLane routes. +type allocator struct { + sources []source +} + +func newAllocator(candidates []liquidlane.QuoteCandidate) allocator { + return allocator{sources: buildSources(candidates)} +} + +// allocateExactInput greedily allocates amountIn to the best route candidates. +// maxRoutes must be positive. Direct and private candidates for the same route +// are alternatives: the best one that can cover the selected leg wins. +func (a allocator) allocateExactInput(amountIn *big.Int, maxRoutes int) allocationResult { + return a.allocateExactInputWithPolicy(amountIn, maxRoutes, false) +} + +func (a allocator) allocateExactInputWithPolicy( + amountIn *big.Int, + maxRoutes int, + requireComplete bool, +) allocationResult { + result := newAllocationResult(amountIn) + if amountIn == nil || amountIn.Sign() <= 0 || maxRoutes <= 0 { + return result + } + + result.Allocations = make([]Allocation, 0, min(maxRoutes, len(a.sources))) + used := make(map[liquidlane.RouteID]bool, min(maxRoutes, len(a.sources))) + for result.Remaining.Sign() > 0 && len(result.Allocations) < maxRoutes { + mustCoverRemaining := requireComplete && len(result.Allocations) == maxRoutes-1 + candidate, amount, ok := bestInputLeg(a.sources, used, result.Remaining, mustCoverRemaining) + if !ok { + break + } + amountOut := output(candidate, amount) + if amountOut.Sign() <= 0 { + used[candidate.Route.ID] = true + continue + } + result.Allocations = append(result.Allocations, Allocation{ + Candidate: candidate, + AmountIn: liquidlane.CloneBig(amount), + AmountOut: amountOut, + }) + result.TotalAmountIn.Add(result.TotalAmountIn, amount) + result.TotalAmountOut.Add(result.TotalAmountOut, amountOut) + result.Remaining.Sub(result.Remaining, amount) + used[candidate.Route.ID] = true + } + return result +} + +// allocateExactOutput greedily buys amountOut from the best physical routes. +// Output rounding may intentionally produce a surplus. +func (a allocator) allocateExactOutput(targetOutput *big.Int, maxRoutes int) allocationResult { + result := newAllocationResult(targetOutput) + if targetOutput == nil || targetOutput.Sign() <= 0 || maxRoutes <= 0 { + return result + } + + result.Allocations = make([]Allocation, 0, min(maxRoutes, len(a.sources))) + used := make(map[liquidlane.RouteID]bool, min(maxRoutes, len(a.sources))) + for result.Remaining.Sign() > 0 && len(result.Allocations) < maxRoutes { + candidate, wanted, ok := bestOutputLeg(a.sources, used, result.Remaining) + if !ok { + break + } + amountIn := liquidlane.MinAmountInForAmountOut( + wanted, + candidate.Rate, + candidate.Route.TokenInDecimals, + candidate.Route.TokenOutDecimals, + ) + amountOut := output(candidate, amountIn) + if amountIn.Sign() <= 0 || amountIn.Cmp(candidate.MaxAmountIn) > 0 || amountOut.Sign() <= 0 { + used[candidate.Route.ID] = true + continue + } + result.Allocations = append(result.Allocations, Allocation{ + Candidate: candidate, + AmountIn: amountIn, + AmountOut: amountOut, + }) + result.TotalAmountIn.Add(result.TotalAmountIn, amountIn) + result.TotalAmountOut.Add(result.TotalAmountOut, amountOut) + result.Remaining.Sub(result.Remaining, minBig(result.Remaining, amountOut)) + used[candidate.Route.ID] = true + } + return result +} + +func newAllocationResult(remaining *big.Int) allocationResult { + result := allocationResult{ + TotalAmountIn: new(big.Int), TotalAmountOut: new(big.Int), Remaining: new(big.Int), + } + if remaining != nil { + result.Remaining.Set(remaining) + } + return result +} + +type source struct { + id liquidlane.RouteID + alternatives []liquidlane.QuoteCandidate + maxInput *big.Int + maxOutput *big.Int + bestRate *big.Int +} + +func buildSources(candidates []liquidlane.QuoteCandidate) []source { + bySource := make(map[liquidlane.RouteID][]liquidlane.QuoteCandidate) + for _, candidate := range candidates { + if !validCandidate(candidate) { + continue + } + bySource[candidate.Route.ID] = append(bySource[candidate.Route.ID], candidate) + } + + sources := make([]source, 0, len(bySource)) + for sourceID, group := range bySource { + item := source{ + id: sourceID, alternatives: group, + maxInput: new(big.Int), maxOutput: new(big.Int), bestRate: new(big.Int), + } + for _, candidate := range group { + if candidate.MaxAmountIn.Cmp(item.maxInput) > 0 { + item.maxInput.Set(candidate.MaxAmountIn) + } + if candidate.Rate.Cmp(item.bestRate) > 0 { + item.bestRate.Set(candidate.Rate) + } + if candidateOutput := output(candidate, candidate.MaxAmountIn); candidateOutput.Cmp(item.maxOutput) > 0 { + item.maxOutput.Set(candidateOutput) + } + } + if len(group) > 0 { + sources = append(sources, item) + } + } + sort.Slice(sources, func(i, j int) bool { + if cmp := sources[i].bestRate.Cmp(sources[j].bestRate); cmp != 0 { + return cmp > 0 + } + if cmp := sources[i].maxInput.Cmp(sources[j].maxInput); cmp != 0 { + return cmp > 0 + } + return sources[i].id < sources[j].id + }) + return sources +} + +func validCandidate(candidate liquidlane.QuoteCandidate) bool { + return candidate.ID != "" && candidate.Route.ID != "" && + candidate.Rate != nil && candidate.Rate.Sign() > 0 && + candidate.MaxAmountIn != nil && candidate.MaxAmountIn.Sign() > 0 && + candidate.MaxAmountOut != nil && candidate.MaxAmountOut.Sign() > 0 +} + +func bestInputLeg( + sources []source, + used map[liquidlane.RouteID]bool, + remaining *big.Int, + mustCoverRemaining bool, +) (liquidlane.QuoteCandidate, *big.Int, bool) { + var best liquidlane.QuoteCandidate + var bestAmount *big.Int + found := false + for _, item := range sources { + if used[item.id] { + continue + } + if mustCoverRemaining && item.maxInput.Cmp(remaining) < 0 { + continue + } + amount := minAmount(remaining, item.maxInput) + for _, candidate := range item.alternatives { + if candidate.MaxAmountIn.Cmp(amount) < 0 { + continue + } + if !found || better(candidate, best) { + best, bestAmount, found = candidate, amount, true + } + } + } + return best, bestAmount, found +} + +func bestOutputLeg( + sources []source, + used map[liquidlane.RouteID]bool, + remaining *big.Int, +) (liquidlane.QuoteCandidate, *big.Int, bool) { + var best liquidlane.QuoteCandidate + var bestAmount *big.Int + found := false + for _, item := range sources { + if used[item.id] { + continue + } + amount := minBig(remaining, item.maxOutput) + for _, candidate := range item.alternatives { + if output(candidate, candidate.MaxAmountIn).Cmp(amount) < 0 { + continue + } + if !found || better(candidate, best) { + best, bestAmount, found = candidate, amount, true + } + } + } + return best, bestAmount, found +} + +func better(left, right liquidlane.QuoteCandidate) bool { + if cmp := left.Rate.Cmp(right.Rate); cmp != 0 { + return cmp > 0 + } + if cmp := left.MaxAmountIn.Cmp(right.MaxAmountIn); cmp != 0 { + return cmp > 0 + } + leftDirect := left.DiscountID == nil + rightDirect := right.DiscountID == nil + if leftDirect != rightDirect { + return leftDirect + } + if left.ValidUntil.IsZero() != right.ValidUntil.IsZero() { + return left.ValidUntil.IsZero() + } + if !left.ValidUntil.Equal(right.ValidUntil) { + return left.ValidUntil.After(right.ValidUntil) + } + return left.ID < right.ID +} + +func output(candidate liquidlane.QuoteCandidate, amountIn *big.Int) *big.Int { + amountOut := liquidlane.AmountOutForRate( + amountIn, + candidate.Rate, + candidate.Route.TokenInDecimals, + candidate.Route.TokenOutDecimals, + ) + if amountOut.Cmp(candidate.MaxAmountOut) > 0 { + return liquidlane.CloneBig(candidate.MaxAmountOut) + } + return amountOut +} + +func minAmount(left, right *big.Int) *big.Int { + if left.Cmp(right) <= 0 { + return new(big.Int).Set(left) + } + return new(big.Int).Set(right) +} diff --git a/internal/liquidlane/strategies/greedy/allocation_test.go b/internal/liquidlane/strategies/greedy/allocation_test.go new file mode 100644 index 00000000..c294ceb9 --- /dev/null +++ b/internal/liquidlane/strategies/greedy/allocation_test.go @@ -0,0 +1,150 @@ +package greedy + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +type Candidate = liquidlane.QuoteCandidate + +func TestAllocateExactInputUsesBestRatesAcrossSources(t *testing.T) { + result := newAllocator([]Candidate{ + candidate("worse", "route-2", 90, 100), + candidate("better", "route-1", 100, 60), + }).allocateExactInput(big.NewInt(100), 2) + + if result.Remaining.Sign() != 0 || result.TotalAmountOut.Int64() != 96 { + t.Fatalf("result = %+v, want complete allocation with output 96", result) + } + if len(result.Allocations) != 2 || result.Allocations[0].Candidate.ID != "better" || + result.Allocations[0].AmountIn.Int64() != 60 || result.Allocations[1].Candidate.ID != "worse" { + t.Fatalf("allocations = %+v, want better route first", result.Allocations) + } +} + +func TestAllocateExactInputUsesCoveringAlternative(t *testing.T) { + discountID := common.HexToHash("0x01") + private := candidate("private", "route-1", 120, 40) + private.DiscountID = &discountID + direct := candidate("direct", "route-1", 100, 100) + + result := newAllocator([]Candidate{private, direct}).allocateExactInput(big.NewInt(80), 1) + + if result.Remaining.Sign() != 0 || len(result.Allocations) != 1 || + result.Allocations[0].Candidate.ID != "direct" || result.TotalAmountOut.Int64() != 80 { + t.Fatalf("result = %+v, want direct alternative covering full input", result) + } +} + +func TestAllocateExactInputDoesNotReusePhysicalRoute(t *testing.T) { + discountID := common.HexToHash("0x01") + private := candidate("private", "route-1", 120, 40) + private.DiscountID = &discountID + direct := candidate("direct", "route-1", 100, 100) + + result := newAllocator([]Candidate{private, direct}).allocateExactInput(big.NewInt(120), 2) + + if len(result.Allocations) != 1 || result.Remaining.Int64() != 20 { + t.Fatalf("result = %+v, want one physical route and 20 input remaining", result) + } +} + +func TestAllocateExactInputPrefersDirectCandidateOnTie(t *testing.T) { + discountID := common.HexToHash("0x01") + private := candidate("a-private", "route-1", 100, 100) + private.DiscountID = &discountID + private.ValidUntil = time.Unix(100, 0) + direct := candidate("z-direct", "route-1", 100, 100) + + result := newAllocator([]Candidate{private, direct}).allocateExactInput(big.NewInt(50), 1) + + if len(result.Allocations) != 1 || result.Allocations[0].Candidate.ID != "z-direct" { + t.Fatalf("allocations = %+v, want direct candidate", result.Allocations) + } +} + +func TestAllocateExactInputChoosesBestCoveringRouteBeforeApplyingLimit(t *testing.T) { + discountID := common.HexToHash("0x01") + narrowPrivate := candidate("private", "route-1", 120, 40) + narrowPrivate.DiscountID = &discountID + wideDirect := candidate("direct", "route-1", 50, 1_000) + betterCovering := candidate("covering", "route-2", 100, 1_000) + + result := newAllocator([]Candidate{narrowPrivate, wideDirect, betterCovering}). + allocateExactInput(big.NewInt(100), 1) + + if result.Remaining.Sign() != 0 || len(result.Allocations) != 1 || + result.Allocations[0].Candidate.ID != "covering" || result.TotalAmountOut.Int64() != 100 { + t.Fatalf("result = %+v, want the best route that covers the requested leg", result) + } +} + +func TestAllocateExactInputRejectsInvalidRequestWithoutNilAmounts(t *testing.T) { + result := newAllocator(nil).allocateExactInput(nil, 0) + if result.TotalAmountOut == nil || result.Remaining == nil || + result.TotalAmountOut.Sign() != 0 || result.Remaining.Sign() != 0 { + t.Fatalf("result = %+v, want non-nil zero amounts", result) + } +} + +func FuzzAllocateExactInputInvariants(f *testing.F) { + f.Add(uint64(100), uint8(3), uint64(80), uint64(120)) + f.Add(uint64(1_000_000), uint8(1), uint64(1), uint64(250)) + f.Fuzz(func(t *testing.T, rawAmount uint64, rawRoutes uint8, rawCapacity uint64, rawRate uint64) { + amount := new(big.Int).SetUint64(rawAmount%1_000_000 + 1) + maxRoutes := int(rawRoutes%4) + 1 + capacity := int64(rawCapacity%1_000 + 1) + rate := int64(rawRate%200 + 1) + candidates := []Candidate{ + candidate("route-1", "route-1", rate, capacity), + candidate("route-2", "route-2", rate+1, capacity+1), + candidate("route-3", "route-3", rate+2, capacity+2), + candidate("route-4", "route-4", rate+3, capacity+3), + } + + result := newAllocator(candidates).allocateExactInput(amount, maxRoutes) + if len(result.Allocations) > maxRoutes { + t.Fatalf("allocations = %d, maxRoutes = %d", len(result.Allocations), maxRoutes) + } + sumIn := new(big.Int).Set(result.Remaining) + sumOut := new(big.Int) + seen := make(map[liquidlane.RouteID]bool, len(result.Allocations)) + for _, allocation := range result.Allocations { + if seen[allocation.Candidate.Route.ID] { + t.Fatalf("route %q allocated twice", allocation.Candidate.Route.ID) + } + seen[allocation.Candidate.Route.ID] = true + if allocation.AmountIn.Sign() <= 0 || allocation.AmountIn.Cmp(allocation.Candidate.MaxAmountIn) > 0 { + t.Fatalf("amountIn %s is outside candidate capacity", allocation.AmountIn) + } + if allocation.AmountOut.Sign() <= 0 || allocation.AmountOut.Cmp(allocation.Candidate.MaxAmountOut) > 0 { + t.Fatalf("amountOut %s is outside candidate capacity", allocation.AmountOut) + } + sumIn.Add(sumIn, allocation.AmountIn) + sumOut.Add(sumOut, allocation.AmountOut) + } + if sumIn.Cmp(amount) != 0 { + t.Fatalf("allocated + remaining input = %s, want %s", sumIn, amount) + } + if sumOut.Cmp(result.TotalAmountOut) != 0 { + t.Fatalf("allocation output = %s, result total = %s", sumOut, result.TotalAmountOut) + } + }) +} + +func candidate(id, routeID string, ratePercent, maxInput int64) Candidate { + rateScale := new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil) + rate := new(big.Int).Mul(rateScale, big.NewInt(ratePercent)) + rate.Div(rate, big.NewInt(100)) + return Candidate{ + ID: liquidlane.CandidateID(id), + Route: liquidlane.Route{ + ID: liquidlane.RouteID(routeID), TokenInDecimals: 0, TokenOutDecimals: 0, + }, + Rate: rate, MaxAmountIn: big.NewInt(maxInput), MaxAmountOut: big.NewInt(maxInput * 2), + } +} diff --git a/internal/liquidlane/strategies/greedy/fill.go b/internal/liquidlane/strategies/greedy/fill.go new file mode 100644 index 00000000..43243005 --- /dev/null +++ b/internal/liquidlane/strategies/greedy/fill.go @@ -0,0 +1,500 @@ +package greedy + +import ( + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" +) + +// FillTask contains the protocol-neutral facts needed to route an exact-input fill. +type FillTask struct { + TokenIn common.Address + TokenOut common.Address + AmountIn *big.Int + + Quotes []liquidlane.FillQuote + Reservations liquidlane.CapacityReservations + ValidAfter time.Time + + MaxRoutes int + PriceBufferBps int + InventoryReserveBps int + InputPolicy UncoveredInputPolicy + GasPricing *liquidstrategies.GasPricing + Trace liquidstrategies.DecisionTrace +} + +// FillSolution is a routed fill before protocol-specific output requirements are applied. +type FillSolution struct { + routes []fillAllocation + gasAmount *big.Int + maxAmountOut *big.Int +} + +// MaxAmountOut is the largest protocol output requirement this allocation can satisfy. +func (a *FillSolution) MaxAmountOut() *big.Int { + if a == nil { + return new(big.Int) + } + return liquidlane.CloneBig(a.maxAmountOut) +} + +// Finalize distributes the required protocol output and gas across the selected routes. +func (a *FillSolution) Finalize(requiredAmountOut *big.Int) []liquidstrategies.FillRoute { + if a == nil || requiredAmountOut == nil || requiredAmountOut.Sign() <= 0 || + requiredAmountOut.Cmp(a.maxAmountOut) > 0 { + return nil + } + minimumTotal := new(big.Int).Add(requiredAmountOut, a.gasAmount) + targets := make([]*big.Int, len(a.routes)) + for index := range a.routes { + targets[index] = a.routes[index].targetOutput + } + minimums := distributeMinimums(targets, minimumTotal) + if minimums == nil { + return nil + } + routes := make([]liquidstrategies.FillRoute, len(a.routes)) + for index, leg := range a.routes { + routes[index] = liquidstrategies.FillRoute{ + CandidateID: leg.candidate.id(), + RouteID: leg.candidate.quote.ID, + CapacityID: liquidlane.RouteCapacityID(leg.candidate.quote.Route), + Adapter: leg.candidate.quote.Adapter, + AmountIn: liquidlane.CloneBig(leg.amountIn), + ExpectedAmountOut: liquidlane.CloneBig(leg.targetOutput), + MinAmountOut: minimums[index], + ReservedAmountOut: liquidlane.CloneBig(leg.reservedOutput), + DiscountID: liquidlane.CloneHash(leg.candidate.quote.DiscountID), + } + } + return routes +} + +// SolveFill selects current LiquidLane quotes, enforces shared capacity, and optionally prices execution gas. +func SolveFill(task FillTask) (*FillSolution, error) { + if task.AmountIn == nil || task.AmountIn.Sign() <= 0 { + return nil, errors.New("amountIn: must be positive") + } + if task.MaxRoutes <= 0 || len(task.Quotes) == 0 { + task.Trace.Decline( + "fill", "no-quotes", + "quotes", len(task.Quotes), + "maxRoutes", task.MaxRoutes, + ) + return nil, nil + } + if task.InputPolicy != RejectUncoveredInput && task.InputPolicy != AbsorbUncoveredInput { + return nil, errors.New("invalid uncovered input policy") + } + if task.PriceBufferBps < 0 || task.PriceBufferBps >= bpsDenominator { + return nil, errors.Errorf("priceBufferBps: must be in [0,%d)", bpsDenominator) + } + if task.InventoryReserveBps < 0 || task.InventoryReserveBps >= bpsDenominator { + return nil, errors.Errorf("inventoryReserveBps: must be in [0,%d)", bpsDenominator) + } + candidates, err := buildFillCandidates(task) + if err != nil || len(candidates) == 0 { + if err == nil { + task.Trace.Decline( + "fill", "no-candidates", + "quotes", len(task.Quotes), + "reservations", len(task.Reservations), + "validAfter", task.ValidAfter, + ) + } + return nil, err + } + allocation := greedyFillAllocation( + candidates, + task.AmountIn, + min(task.MaxRoutes, len(candidates)), + task.PriceBufferBps, + task.InputPolicy, + ) + if len(allocation) == 0 { + task.Trace.Decline( + "fill", insufficientCapacityReason, + "amountIn", task.AmountIn.String(), + "candidates", len(candidates), + "maxRoutes", task.MaxRoutes, + ) + return nil, nil + } + targetTotal := new(big.Int) + legs := make([]liquidstrategies.GasLeg, 0, len(allocation)) + for index, leg := range allocation { + allocation[index].targetOutput = new(big.Int).Sub( + leg.executableOutput, + applyBpsUp(leg.executableOutput, task.PriceBufferBps), + ) + if allocation[index].targetOutput.Sign() <= 0 { + task.Trace.Decline( + "fill", "buffer-exceeds-output", + "leg", index, + "routeId", leg.candidate.quote.ID, + "executableAmountOut", leg.executableOutput.String(), + "targetAmountOut", allocation[index].targetOutput.String(), + ) + return nil, nil + } + targetTotal.Add(targetTotal, allocation[index].targetOutput) + legs = append(legs, liquidstrategies.GasLeg{ + Route: leg.candidate.quote.Route, + AmountOut: leg.executableOutput, + Private: leg.candidate.quote.DiscountID != nil, + }) + } + gasAmount := new(big.Int) + if task.GasPricing != nil { + gasAmount = task.GasPricing.Cost(legs) + } + maxAmountOut := new(big.Int).Sub(targetTotal, gasAmount) + if maxAmountOut.Sign() <= 0 { + task.Trace.Decline( + "fill", "gas-exceeds-output", + "targetAmountOut", targetTotal.String(), + "gasCost", gasAmount.String(), + "maxAmountOut", maxAmountOut.String(), + ) + return nil, nil + } + task.Trace.Log( + "liquidlane fill selected", + "amountIn", task.AmountIn.String(), + "targetAmountOut", targetTotal.String(), + "gasCost", gasAmount.String(), + "maxAmountOut", maxAmountOut.String(), + "routes", len(allocation), + ) + return &FillSolution{ + routes: allocation, gasAmount: gasAmount, maxAmountOut: maxAmountOut, + }, nil +} + +type fillCandidate struct { + quote liquidlane.FillQuote + capacity *big.Int + maxInput *big.Int +} + +type fillRoute struct { + id liquidlane.RouteID + alternatives []fillCandidate +} + +type fillAllocation struct { + candidate fillCandidate + amountIn *big.Int + executableOutput *big.Int + reservedOutput *big.Int + targetOutput *big.Int +} + +func (candidate fillCandidate) id() liquidlane.CandidateID { + return liquidlane.NewCandidateID(candidate.quote.Route, candidate.quote.DiscountID) +} + +func buildFillCandidates(task FillTask) ([]fillCandidate, error) { + seen := make(map[liquidlane.CandidateID]bool, len(task.Quotes)) + candidates := make([]fillCandidate, 0, len(task.Quotes)) + for _, quote := range task.Quotes { + if quote.TokenIn != task.TokenIn || quote.TokenOut != task.TokenOut { + continue + } + if !quote.ValidUntil.IsZero() && !quote.ValidUntil.After(task.ValidAfter) { + continue + } + if quote.AmountIn == nil || quote.AmountIn.Cmp(task.AmountIn) != 0 { + return nil, errors.Errorf("fill quote %s amountIn does not match order", quote.ID) + } + if quote.MaxAssets == nil || quote.MaxAssets.Sign() <= 0 || + quote.MaxAmountOut == nil || quote.MaxAmountOut.Sign() <= 0 { + continue + } + capacityID := liquidlane.RouteCapacityID(quote.Route) + capacity := AvailableCapacity(quote.MaxAssets, task.InventoryReserveBps) + if reserved := task.Reservations[capacityID]; reserved != nil && reserved.Sign() > 0 { + capacity.Sub(capacity, reserved) + } + if capacity.Sign() <= 0 { + continue + } + candidate := fillCandidate{quote: quote, capacity: capacity} + candidate.maxInput = maxInputWithinCapacity( + candidate, task.AmountIn, capacity, task.PriceBufferBps, + ) + candidateID := candidate.id() + if candidate.maxInput.Sign() <= 0 || seen[candidateID] { + continue + } + seen[candidateID] = true + candidates = append(candidates, candidate) + } + return candidates, nil +} + +func greedyFillAllocation( + candidates []fillCandidate, + amountIn *big.Int, + maxRoutes int, + priceBufferBps int, + inputPolicy UncoveredInputPolicy, +) []fillAllocation { + routes := buildFillRoutes(candidates) + capacityLimits := fillCapacityLimits(candidates) + capacityUsed := make(map[liquidlane.CapacityID]*big.Int, len(capacityLimits)) + usedRoutes := make(map[liquidlane.RouteID]bool, maxRoutes) + remaining := liquidlane.CloneBig(amountIn) + allocation := make([]fillAllocation, 0, maxRoutes) + + for remaining.Sign() > 0 && len(allocation) < maxRoutes { + var best *fillAllocation + lastRoute := inputPolicy == RejectUncoveredInput && len(allocation) == maxRoutes-1 + for _, route := range routes { + if usedRoutes[route.id] { + continue + } + choice := fillRouteChoice( + route, remaining, capacityLimits, capacityUsed, priceBufferBps, + ) + if choice != nil && lastRoute && choice.amountIn.Cmp(remaining) < 0 { + continue + } + if choice != nil && (best == nil || fillAllocationBetter(*choice, *best)) { + best = choice + } + } + if best == nil { + break + } + allocation = append(allocation, *best) + usedRoutes[best.candidate.quote.ID] = true + capacityID := liquidlane.RouteCapacityID(best.candidate.quote.Route) + if capacityUsed[capacityID] == nil { + capacityUsed[capacityID] = new(big.Int) + } + capacityUsed[capacityID].Add(capacityUsed[capacityID], best.reservedOutput) + remaining.Sub(remaining, best.amountIn) + } + if remaining.Sign() > 0 { + if inputPolicy == RejectUncoveredInput || len(allocation) == 0 { + return nil + } + allocation[len(allocation)-1].amountIn.Add(allocation[len(allocation)-1].amountIn, remaining) + } + return allocation +} + +func buildFillRoutes(candidates []fillCandidate) []fillRoute { + byRoute := make(map[liquidlane.RouteID][]fillCandidate) + for _, candidate := range candidates { + byRoute[candidate.quote.ID] = append(byRoute[candidate.quote.ID], candidate) + } + routes := make([]fillRoute, 0, len(byRoute)) + for routeID, alternatives := range byRoute { + routes = append(routes, fillRoute{id: routeID, alternatives: alternatives}) + } + return routes +} + +func fillCandidateBetter(left, right fillCandidate) bool { + if comparison := compareFillRate(left.quote, right.quote); comparison != 0 { + return comparison > 0 + } + if comparison := left.maxInput.Cmp(right.maxInput); comparison != 0 { + return comparison > 0 + } + leftDirect := left.quote.DiscountID == nil + rightDirect := right.quote.DiscountID == nil + if leftDirect != rightDirect { + return leftDirect + } + return left.id() < right.id() +} + +func fillAllocationBetter(left, right fillAllocation) bool { + if comparison := compareFillRate(left.candidate.quote, right.candidate.quote); comparison != 0 { + return comparison > 0 + } + if comparison := left.amountIn.Cmp(right.amountIn); comparison != 0 { + return comparison > 0 + } + leftDirect := left.candidate.quote.DiscountID == nil + rightDirect := right.candidate.quote.DiscountID == nil + if leftDirect != rightDirect { + return leftDirect + } + return left.candidate.id() < right.candidate.id() +} + +func compareFillRate(left, right liquidlane.FillQuote) int { + leftRate := new(big.Int).Mul(left.MaxAmountOut, right.AmountIn) + rightRate := new(big.Int).Mul(right.MaxAmountOut, left.AmountIn) + return leftRate.Cmp(rightRate) +} + +func fillCapacityLimits(candidates []fillCandidate) map[liquidlane.CapacityID]*big.Int { + limits := make(map[liquidlane.CapacityID]*big.Int) + for _, candidate := range candidates { + capacityID := liquidlane.RouteCapacityID(candidate.quote.Route) + if limit := limits[capacityID]; limit == nil || candidate.capacity.Cmp(limit) > 0 { + limits[capacityID] = liquidlane.CloneBig(candidate.capacity) + } + } + return limits +} + +func fillRouteChoice( + route fillRoute, + remaining *big.Int, + capacityLimits map[liquidlane.CapacityID]*big.Int, + capacityUsed map[liquidlane.CapacityID]*big.Int, + priceBufferBps int, +) *fillAllocation { + capacityID := liquidlane.RouteCapacityID(route.alternatives[0].quote.Route) + capacityLeft := liquidlane.CloneBig(capacityLimits[capacityID]) + if used := capacityUsed[capacityID]; used != nil { + capacityLeft.Sub(capacityLeft, used) + } + if capacityLeft.Sign() <= 0 { + return nil + } + + available := make([]*big.Int, len(route.alternatives)) + legAmount := new(big.Int) + for index, candidate := range route.alternatives { + candidateCapacity := minBig(capacityLeft, candidate.capacity) + amount := maxInputWithinCapacity(candidate, remaining, candidateCapacity, priceBufferBps) + if amount.Cmp(candidate.maxInput) > 0 { + amount.Set(candidate.maxInput) + } + available[index] = amount + if amount.Cmp(legAmount) > 0 { + legAmount.Set(amount) + } + } + if legAmount.Sign() <= 0 { + return nil + } + + var best *fillCandidate + for index, candidate := range route.alternatives { + if available[index].Cmp(legAmount) < 0 { + continue + } + if best == nil || fillCandidateBetter(candidate, *best) { + selected := candidate + best = &selected + } + } + if best == nil { + return nil + } + return &fillAllocation{ + candidate: *best, + amountIn: legAmount, + executableOutput: scaledFillOutput(best.quote, legAmount), + reservedOutput: reservedCapacityOutput(*best, legAmount, priceBufferBps), + } +} + +func maxInputWithinCapacity( + candidate fillCandidate, + inputLimit *big.Int, + capacity *big.Int, + priceBufferBps int, +) *big.Int { + quote := candidate.quote + if inputLimit == nil || inputLimit.Sign() <= 0 || capacity == nil || capacity.Sign() <= 0 || + quote.AmountIn == nil || quote.AmountIn.Sign() <= 0 || + quote.MaxAmountOut == nil || quote.MaxAmountOut.Sign() <= 0 { + return new(big.Int) + } + precision := big.NewInt(bpsDenominator) + buffer := big.NewInt(int64(priceBufferBps)) + maxOutput := new(big.Int) + if quote.DiscountID != nil { + maxOutput.Mul(capacity, precision) + maxOutput.Div(maxOutput, new(big.Int).Add(precision, buffer)) + } else { + maxOutput.Add(capacity, big.NewInt(1)) + maxOutput.Mul(maxOutput, precision) + maxOutput.Sub(maxOutput, big.NewInt(1)) + maxOutput.Div(maxOutput, new(big.Int).Sub(precision, buffer)) + } + maxInput := new(big.Int).Add(maxOutput, big.NewInt(1)) + maxInput.Mul(maxInput, quote.AmountIn) + maxInput.Sub(maxInput, big.NewInt(1)) + maxInput.Div(maxInput, quote.MaxAmountOut) + if maxInput.Cmp(inputLimit) > 0 { + maxInput.Set(inputLimit) + } + return maxInput +} + +func reservedCapacityOutput( + candidate fillCandidate, + amountIn *big.Int, + priceBufferBps int, +) *big.Int { + amountOut := scaledFillOutput(candidate.quote, amountIn) + buffer := applyBpsUp(amountOut, priceBufferBps) + if candidate.quote.DiscountID != nil { + return amountOut.Add(amountOut, buffer) + } + return amountOut.Sub(amountOut, buffer) +} + +func distributeMinimums(targets []*big.Int, total *big.Int) []*big.Int { + if len(targets) == 0 || total == nil || total.Sign() <= 0 || + total.Cmp(big.NewInt(int64(len(targets)))) < 0 { + return nil + } + capacity := new(big.Int) + for _, target := range targets { + if target == nil || target.Sign() <= 0 { + return nil + } + capacity.Add(capacity, target) + } + if total.Cmp(capacity) > 0 { + return nil + } + remaining := new(big.Int).Sub(total, big.NewInt(int64(len(targets)))) + remainingCapacity := new(big.Int).Sub(capacity, big.NewInt(int64(len(targets)))) + minimums := make([]*big.Int, len(targets)) + for index, target := range targets { + available := new(big.Int).Sub(target, big.NewInt(1)) + allocation := new(big.Int) + if index == len(targets)-1 { + allocation.Set(remaining) + } else if remainingCapacity.Sign() > 0 { + allocation.Mul(remaining, available) + allocation.Div(allocation, remainingCapacity) + } + if allocation.Cmp(available) > 0 { + return nil + } + minimums[index] = allocation.Add(allocation, big.NewInt(1)) + remaining.Sub(remaining, new(big.Int).Sub(minimums[index], big.NewInt(1))) + remainingCapacity.Sub(remainingCapacity, available) + } + if remaining.Sign() != 0 { + return nil + } + return minimums +} + +func scaledFillOutput(quote liquidlane.FillQuote, amountIn *big.Int) *big.Int { + if quote.AmountIn == nil || quote.AmountIn.Sign() <= 0 || + quote.MaxAmountOut == nil || amountIn == nil { + return new(big.Int) + } + return new(big.Int).Div(new(big.Int).Mul(quote.MaxAmountOut, amountIn), quote.AmountIn) +} diff --git a/internal/liquidlane/strategies/greedy/fill_test.go b/internal/liquidlane/strategies/greedy/fill_test.go new file mode 100644 index 00000000..343ef3c7 --- /dev/null +++ b/internal/liquidlane/strategies/greedy/fill_test.go @@ -0,0 +1,284 @@ +package greedy + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" +) + +func TestSolveFillChoosesBestCompleteRoutes(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + quotes := make([]liquidlane.FillQuote, 3) + for index := range quotes { + quotes[index] = testFillQuote( + liquidlane.RouteID(string(rune('a'+index))), + liquidlane.CapacityID(string(rune('A'+index))), + tokenIn, + tokenOut, + 2, + int64(6+2*index), + int64(3+index), + nil, + ) + } + allocation, err := SolveFill(FillTask{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(2), Quotes: quotes, MaxRoutes: 3, + }) + if err != nil || allocation == nil { + t.Fatalf("SolveFill = %v, %v", allocation, err) + } + routes := allocation.Finalize(allocation.MaxAmountOut()) + if len(routes) != 2 || routes[0].RouteID != "c" || routes[1].RouteID != "b" { + t.Fatalf("routes = %+v, want c,b", routes) + } +} + +func TestSolveFillUsesDirectWhenPrivateCannotCoverLeg(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + discountID := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + direct := testFillQuote("route", "capacity", tokenIn, tokenOut, 100, 100, 100, nil) + private := testFillQuote("route", "capacity", tokenIn, tokenOut, 100, 200, 100, &discountID) + allocation, err := SolveFill(FillTask{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(100), + Quotes: []liquidlane.FillQuote{private, direct}, MaxRoutes: 1, + }) + if err != nil || allocation == nil { + t.Fatalf("SolveFill = %v, %v", allocation, err) + } + routes := allocation.Finalize(big.NewInt(100)) + if len(routes) != 1 || routes[0].DiscountID != nil { + t.Fatalf("routes = %+v, want direct fallback", routes) + } +} + +func TestSolveFillUsesWiderPrivateAlternative(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + narrowDiscountID := common.HexToHash("0x01") + wideDiscountID := common.HexToHash("0x02") + narrow := testFillQuote("route", "capacity", tokenIn, tokenOut, 100, 200, 50, &narrowDiscountID) + wide := testFillQuote("route", "capacity", tokenIn, tokenOut, 100, 100, 100, &wideDiscountID) + + solution, err := SolveFill(FillTask{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(100), + Quotes: []liquidlane.FillQuote{narrow, wide}, MaxRoutes: 1, + }) + if err != nil || solution == nil { + t.Fatalf("SolveFill = %v, %v; want the wider private alternative", solution, err) + } + routes := solution.Finalize(big.NewInt(100)) + if len(routes) != 1 || routes[0].DiscountID == nil || + *routes[0].DiscountID != wideDiscountID { + t.Fatalf("routes = %+v, want wider private discount %s", routes, wideDiscountID) + } +} + +func TestSolveFillDoesNotOverbookSharedCapacity(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + high := testFillQuote("high", "shared", tokenIn, tokenOut, 75, 150, 100, nil) + wide := testFillQuote("wide", "shared", tokenIn, tokenOut, 75, 75, 60, nil) + var declineReason string + allocation, err := SolveFill(FillTask{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(75), + Quotes: []liquidlane.FillQuote{high, wide}, MaxRoutes: 2, + Trace: func(_ string, fields ...any) { + declineReason, _ = fields[1].(string) + }, + }) + if err != nil { + t.Fatal(err) + } + if allocation != nil { + t.Fatalf("allocation = %+v, want shared-capacity rejection", allocation) + } + if declineReason != insufficientCapacityReason { + t.Fatalf("decline reason = %q", declineReason) + } +} + +func TestSolveFillAbsorbsUncoveredInputAsPriceImpact(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + quote := testFillQuote("route", "capacity", tokenIn, tokenOut, 100, 100, 60, nil) + solution, err := SolveFill(FillTask{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(100), + Quotes: []liquidlane.FillQuote{quote}, MaxRoutes: 1, + InputPolicy: AbsorbUncoveredInput, + }) + if err != nil || solution == nil || solution.MaxAmountOut().Int64() != 60 { + t.Fatalf("solution = %+v, err %v", solution, err) + } + routes := solution.Finalize(solution.MaxAmountOut()) + if len(routes) != 1 || routes[0].AmountIn.Int64() != 100 || + routes[0].ExpectedAmountOut.Int64() != 60 { + t.Fatalf("routes = %+v", routes) + } +} + +func TestSolveFillGasPricingIsOptional(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + amount := big.NewInt(10_000_000) + quote := testFillQuote("route", "capacity", tokenIn, tokenOut, amount.Int64(), amount.Int64(), amount.Int64(), nil) + task := FillTask{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: amount, + Quotes: []liquidlane.FillQuote{quote}, MaxRoutes: 1, + } + + withoutGas, err := SolveFill(task) + if err != nil || withoutGas == nil || withoutGas.MaxAmountOut().Cmp(amount) != 0 { + t.Fatalf("SolveFill without gas = %v, %v", withoutGas, err) + } + gasPricing, err := liquidstrategies.NewGasPricing( + big.NewInt(1), + tokenOut, + liquidlanegas.NewPriceSnapshot(map[common.Address]*big.Int{ + tokenOut: big.NewInt(1_000_000_000_000_000_000), + }), + nil, + 0, + liquidstrategies.GasEnvelope{SettlementUnits: 250_000, PrivateRouteUnits: 75_000}, + ) + if err != nil { + t.Fatal(err) + } + task.GasPricing = &gasPricing + withGas, err := SolveFill(task) + if err != nil || withGas == nil { + t.Fatalf("SolveFill with gas = %v, %v", withGas, err) + } + if withGas.MaxAmountOut().Cmp(withoutGas.MaxAmountOut()) >= 0 { + t.Fatalf( + "gas-aware output = %s, without gas = %s", + withGas.MaxAmountOut(), + withoutGas.MaxAmountOut(), + ) + } +} + +func TestSolveFillRejectsInvalidBps(t *testing.T) { + _, err := SolveFill(FillTask{AmountIn: big.NewInt(1), Quotes: []liquidlane.FillQuote{{}}, + MaxRoutes: 1, PriceBufferBps: bpsDenominator}) + if err == nil { + t.Fatal("expected invalid bps error") + } +} + +func TestDistributeMinimumsPreservesTotalAndBounds(t *testing.T) { + targets := []*big.Int{big.NewInt(200), big.NewInt(800)} + minimums := distributeMinimums(targets, big.NewInt(503)) + if len(minimums) != 2 || minimums[0].String() != "100" || minimums[1].String() != "403" { + t.Fatalf("minimums = %v", minimums) + } +} + +func TestMaxInputWithinCapacityIsExact(t *testing.T) { + discountID := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + for _, discount := range []*common.Hash{nil, &discountID} { + candidate := fillCandidate{quote: liquidlane.FillQuote{ + Inventory: liquidlane.Inventory{DiscountID: discount}, + AmountIn: big.NewInt(137), MaxAmountOut: big.NewInt(233), + }} + for capacity := int64(1); capacity <= 233; capacity++ { + limit := big.NewInt(137) + got := maxInputWithinCapacity(candidate, limit, big.NewInt(capacity), 1234) + if reservedCapacityOutput(candidate, got, 1234).Cmp(big.NewInt(capacity)) > 0 { + t.Fatalf("discount=%v capacity=%d input=%s exceeds capacity", discount != nil, capacity, got) + } + if got.Cmp(limit) < 0 { + next := new(big.Int).Add(got, big.NewInt(1)) + if reservedCapacityOutput(candidate, next, 1234).Cmp(big.NewInt(capacity)) <= 0 { + t.Fatalf("discount=%v capacity=%d input=%s is not maximal", discount != nil, capacity, got) + } + } + } + } +} + +func FuzzSolveFillPreservesExactInputAndOutputFloor(f *testing.F) { + f.Add(uint16(100), uint16(60), uint16(40), uint16(30), uint16(120), uint16(110), uint16(90)) + f.Fuzz(func( + t *testing.T, + rawAmount, rawCapA, rawCapB, rawCapC, rawOutA, rawOutB, rawOutC uint16, + ) { + amount := int64(rawAmount%1_000 + 1) + caps := []int64{ + int64(rawCapA%2_000 + 1), int64(rawCapB%2_000 + 1), int64(rawCapC%2_000 + 1), + } + outputs := []int64{ + int64(rawOutA%2_000 + 1), int64(rawOutB%2_000 + 1), int64(rawOutC%2_000 + 1), + } + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + quotes := make([]liquidlane.FillQuote, 3) + for index := range quotes { + quotes[index] = testFillQuote( + liquidlane.RouteID(string(rune('a'+index))), + liquidlane.CapacityID(string(rune('A'+index))), + tokenIn, + tokenOut, + amount, + outputs[index], + caps[index], + nil, + ) + } + allocation, err := SolveFill(FillTask{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(amount), + Quotes: quotes, MaxRoutes: 3, + }) + if err != nil { + t.Fatal(err) + } + if allocation == nil { + return + } + required := allocation.MaxAmountOut() + routes := allocation.Finalize(required) + if len(routes) == 0 { + t.Fatal("complete allocation did not finalize") + } + totalInput := new(big.Int) + totalMinimum := new(big.Int) + for _, route := range routes { + if route.AmountIn.Sign() <= 0 || route.ExpectedAmountOut.Sign() <= 0 || + route.MinAmountOut.Sign() <= 0 || route.MinAmountOut.Cmp(route.ExpectedAmountOut) > 0 { + t.Fatalf("invalid route amounts: %+v", route) + } + totalInput.Add(totalInput, route.AmountIn) + totalMinimum.Add(totalMinimum, route.MinAmountOut) + } + if totalInput.Cmp(big.NewInt(amount)) != 0 { + t.Fatalf("input sum = %s, want %d", totalInput, amount) + } + if totalMinimum.Cmp(required) != 0 { + t.Fatalf("minimum sum = %s, want %s", totalMinimum, required) + } + }) +} + +func testFillQuote( + routeID liquidlane.RouteID, + capacityID liquidlane.CapacityID, + tokenIn, tokenOut common.Address, + amountIn, amountOut, maxAssets int64, + discountID *common.Hash, +) liquidlane.FillQuote { + return liquidlane.FillQuote{ + Inventory: liquidlane.Inventory{ + Route: liquidlane.Route{ + ID: routeID, CapacityID: capacityID, TokenIn: tokenIn, TokenOut: tokenOut, + }, + MaxAssets: big.NewInt(maxAssets), DiscountID: discountID, + }, + AmountIn: big.NewInt(amountIn), MaxAmountOut: big.NewInt(amountOut), + } +} diff --git a/internal/liquidlane/strategies/greedy/inventory.go b/internal/liquidlane/strategies/greedy/inventory.go new file mode 100644 index 00000000..f7db1a98 --- /dev/null +++ b/internal/liquidlane/strategies/greedy/inventory.go @@ -0,0 +1,121 @@ +package greedy + +import ( + "math/big" + "slices" + "time" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +// FilterLiveInventory removes expired and duplicate route alternatives. +func FilterLiveInventory(inventory []liquidlane.Inventory, validAfter time.Time) []liquidlane.Inventory { + seen := make(map[liquidlane.CandidateID]bool, len(inventory)) + out := make([]liquidlane.Inventory, 0, len(inventory)) + for _, candidate := range inventory { + id := liquidlane.NewCandidateID(candidate.Route, candidate.DiscountID) + if (!candidate.ValidUntil.IsZero() && !candidate.ValidUntil.After(validAfter)) || seen[id] { + continue + } + seen[id] = true + out = append(out, candidate) + } + return out +} + +// AllocateInventoryCapacity divides shared vault capacity between physical routes. +func AllocateInventoryCapacity( + inventory []liquidlane.Inventory, + reservations liquidlane.CapacityReservations, + reserveBps int, +) []liquidlane.Inventory { + groups := make(map[liquidlane.CapacityID]map[liquidlane.RouteID][]liquidlane.Inventory) + for _, item := range inventory { + if item.MaxAssets == nil || item.MaxAssets.Sign() <= 0 { + continue + } + capacityID := liquidlane.RouteCapacityID(item.Route) + if groups[capacityID] == nil { + groups[capacityID] = make(map[liquidlane.RouteID][]liquidlane.Inventory) + } + groups[capacityID][item.ID] = append(groups[capacityID][item.ID], item) + } + + capacityIDs := make([]liquidlane.CapacityID, 0, len(groups)) + for capacityID := range groups { + capacityIDs = append(capacityIDs, capacityID) + } + slices.Sort(capacityIDs) + + out := make([]liquidlane.Inventory, 0, len(inventory)) + for _, capacityID := range capacityIDs { + routes := groups[capacityID] + routeIDs := make([]liquidlane.RouteID, 0, len(routes)) + for routeID := range routes { + routeIDs = append(routeIDs, routeID) + } + slices.Sort(routeIDs) + domainMax := new(big.Int) + for _, items := range routes { + for _, item := range items { + if item.MaxAssets.Cmp(domainMax) > 0 { + domainMax.Set(item.MaxAssets) + } + } + } + remaining := AvailableCapacity(domainMax, reserveBps) + if reserved := reservations[capacityID]; reserved != nil && reserved.Sign() > 0 { + remaining.Sub(remaining, reserved) + } + if remaining.Sign() <= 0 { + continue + } + + for index, routeID := range routeIDs { + items := routes[routeID] + share := liquidlane.MulDivUp(remaining, big.NewInt(1), big.NewInt(int64(len(routeIDs)-index))) + routeCap := new(big.Int) + for _, item := range items { + itemCap := AvailableCapacity(item.MaxAssets, reserveBps) + if itemCap.Cmp(routeCap) > 0 { + routeCap.Set(itemCap) + } + } + if share.Cmp(routeCap) > 0 { + share.Set(routeCap) + } + if share.Sign() <= 0 { + continue + } + for _, item := range items { + itemCap := AvailableCapacity(item.MaxAssets, reserveBps) + if itemCap.Cmp(share) > 0 { + itemCap.Set(share) + } + if itemCap.Sign() <= 0 { + continue + } + item.MaxAssets = itemCap + item.MaxRate = liquidlane.CloneBig(item.MaxRate) + item.DiscountID = liquidlane.CloneHash(item.DiscountID) + out = append(out, item) + } + remaining.Sub(remaining, share) + if remaining.Sign() <= 0 { + break + } + } + } + return out +} + +func AvailableCapacity(maxAssets *big.Int, reserveBps int) *big.Int { + return applyBpsDown(maxAssets, bpsDenominator-reserveBps) +} + +func QuoteCapacity(route liquidlane.Inventory, priceBufferBps int) *big.Int { + if route.DiscountID == nil { + return liquidlane.CloneBig(route.MaxAssets) + } + return applyBpsDown(route.MaxAssets, bpsDenominator-priceBufferBps) +} diff --git a/internal/liquidlane/strategies/greedy/inventory_test.go b/internal/liquidlane/strategies/greedy/inventory_test.go new file mode 100644 index 00000000..37887194 --- /dev/null +++ b/internal/liquidlane/strategies/greedy/inventory_test.go @@ -0,0 +1,31 @@ +package greedy + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +func TestFilterLiveInventoryRemovesExpiredAndDuplicateCandidates(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + direct := liquidlane.DirectInventory( + liquidlane.Route{ID: "direct"}, big.NewInt(100), big.NewInt(1), + ) + discountID := common.HexToHash("0x01") + private := liquidlane.DiscountInventory( + liquidlane.Route{ID: "private"}, big.NewInt(100), big.NewInt(1), discountID, now.Add(time.Minute), + ) + expired := liquidlane.DirectInventory( + liquidlane.Route{ID: "expired"}, big.NewInt(100), big.NewInt(1), + ) + expired.ValidUntil = now + + got := FilterLiveInventory([]liquidlane.Inventory{direct, direct, private, expired}, now) + if len(got) != 2 || got[0].ID != direct.ID || got[1].ID != private.ID { + t.Fatalf("filtered inventory = %+v", got) + } +} diff --git a/internal/liquidlane/strategies/greedy/math.go b/internal/liquidlane/strategies/greedy/math.go new file mode 100644 index 00000000..77b68132 --- /dev/null +++ b/internal/liquidlane/strategies/greedy/math.go @@ -0,0 +1,33 @@ +package greedy + +import ( + "math/big" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +const bpsDenominator = 10_000 + +func applyBpsDown(amount *big.Int, bps int) *big.Int { + if amount == nil || amount.Sign() <= 0 || bps <= 0 { + return new(big.Int) + } + return new(big.Int).Div( + new(big.Int).Mul(amount, big.NewInt(int64(bps))), + big.NewInt(bpsDenominator), + ) +} + +func applyBpsUp(amount *big.Int, bps int) *big.Int { + if amount == nil || amount.Sign() <= 0 || bps <= 0 { + return new(big.Int) + } + return liquidlane.MulDivUp(amount, big.NewInt(int64(bps)), big.NewInt(bpsDenominator)) +} + +func minBig(left, right *big.Int) *big.Int { + if left.Cmp(right) < 0 { + return new(big.Int).Set(left) + } + return new(big.Int).Set(right) +} diff --git a/internal/liquidlane/strategies/greedy/normalization.go b/internal/liquidlane/strategies/greedy/normalization.go new file mode 100644 index 00000000..5c23faef --- /dev/null +++ b/internal/liquidlane/strategies/greedy/normalization.go @@ -0,0 +1,98 @@ +package greedy + +import ( + "math/big" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +// NewQuoteCandidate converts a fixed-rate inventory alternative and its +// already-buffered output capacity into the canonical greedy quote shape. +func NewQuoteCandidate( + item liquidlane.Inventory, + maxAmountOut *big.Int, +) *liquidlane.QuoteCandidate { + if item.MaxRate == nil || item.MaxRate.Sign() <= 0 || + maxAmountOut == nil || maxAmountOut.Sign() <= 0 { + return nil + } + maxAmountIn := liquidlane.MaxAmountInForRate( + maxAmountOut, + item.MaxRate, + item.TokenInDecimals, + item.TokenOutDecimals, + ) + if maxAmountIn.Sign() <= 0 || liquidlane.AmountOutForRate( + maxAmountIn, + item.MaxRate, + item.TokenInDecimals, + item.TokenOutDecimals, + ).Sign() <= 0 { + return nil + } + return &liquidlane.QuoteCandidate{ + ID: liquidlane.NewCandidateID(item.Route, item.DiscountID), + Route: item.Route, + Rate: liquidlane.CloneBig(item.MaxRate), + MaxAmountIn: maxAmountIn, + MaxAmountOut: liquidlane.CloneBig(maxAmountOut), + DiscountID: liquidlane.CloneHash(item.DiscountID), + ValidUntil: item.ValidUntil, + } +} + +// NormalizeOracleInventory turns amount-independent RFQ inventory into exact-input +// greedy candidates using current, per-physical-route adapter quotes. +func NormalizeOracleInventory( + amountIn *big.Int, + sources []liquidlane.Inventory, + physical []liquidlane.FillQuote, +) []liquidlane.QuoteCandidate { + if amountIn == nil || amountIn.Sign() <= 0 { + return nil + } + quotes := make(map[liquidlane.RouteID]liquidlane.FillQuote, len(physical)) + for _, quote := range physical { + if quote.ID == "" || quote.AmountIn == nil || quote.AmountIn.Cmp(amountIn) != 0 || + quote.MaxAmountOut == nil || quote.MaxAmountOut.Sign() <= 0 { + continue + } + quotes[quote.ID] = quote + } + seen := make(map[liquidlane.CandidateID]bool, len(sources)) + out := make([]liquidlane.QuoteCandidate, 0, len(sources)) + for _, source := range sources { + quote, ok := quotes[source.ID] + if !ok || source.MaxAssets == nil || source.MaxAssets.Sign() <= 0 || + quote.MaxAssets == nil || quote.MaxAssets.Sign() <= 0 { + continue + } + capacity := liquidlane.CloneBig(source.MaxAssets) + if quote.MaxAssets.Cmp(capacity) < 0 { + capacity.Set(quote.MaxAssets) + } + rate := source.MaxRate + if source.DiscountID == nil { + rate = liquidlane.RateForAmountOut( + quote.MaxAmountOut, + amountIn, + source.TokenInDecimals, + source.TokenOutDecimals, + ) + if source.MaxRate == nil || source.MaxRate.Cmp(rate) < 0 { + continue + } + } + if rate == nil || rate.Sign() <= 0 { + continue + } + source.MaxRate = rate + candidate := NewQuoteCandidate(source, capacity) + if candidate == nil || seen[candidate.ID] { + continue + } + seen[candidate.ID] = true + out = append(out, *candidate) + } + return out +} diff --git a/internal/liquidlane/strategies/greedy/normalization_test.go b/internal/liquidlane/strategies/greedy/normalization_test.go new file mode 100644 index 00000000..2adce0fa --- /dev/null +++ b/internal/liquidlane/strategies/greedy/normalization_test.go @@ -0,0 +1,61 @@ +package greedy + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +func TestNormalizeOracleInventoryPricesEachPhysicalRoute(t *testing.T) { + t.Parallel() + tokenIn := common.HexToAddress("0x1") + tokenOut := common.HexToAddress("0x2") + first := liquidlane.NewRoute(1, common.HexToAddress("0xa"), common.HexToAddress("0x10"), tokenIn, tokenOut, 18, 6) + second := liquidlane.NewRoute(1, common.HexToAddress("0xb"), common.HexToAddress("0x20"), tokenIn, tokenOut, 18, 6) + amountIn := new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil) + sources := []liquidlane.Inventory{ + liquidlane.DirectInventory(first, big.NewInt(2_000_000), big.NewInt(900_000_000_000_000_000)), + liquidlane.DirectInventory(second, big.NewInt(2_000_000), big.NewInt(800_000_000_000_000_000)), + } + physical := []liquidlane.FillQuote{ + {Inventory: liquidlane.DirectInventory(first, big.NewInt(1_500_000), nil), AmountIn: amountIn, MaxAmountOut: big.NewInt(900_000)}, + {Inventory: liquidlane.DirectInventory(second, big.NewInt(2_000_000), nil), AmountIn: amountIn, MaxAmountOut: big.NewInt(800_000)}, + } + + got := NormalizeOracleInventory(amountIn, sources, physical) + if len(got) != 2 || got[0].Rate.String() != "900000000000000000" || got[0].MaxAmountOut.String() != "1500000" || + got[1].Rate.String() != "800000000000000000" { + t.Fatalf("normalized = %#v", got) + } +} + +func TestNormalizeOracleInventoryUsesSignedRateForPrivateAlternative(t *testing.T) { + t.Parallel() + tokenIn := common.HexToAddress("0x1") + tokenOut := common.HexToAddress("0x2") + route := liquidlane.NewRoute(1, common.HexToAddress("0xa"), common.HexToAddress("0x10"), tokenIn, tokenOut, 18, 6) + discountID := common.HexToHash("0xd") + amountIn := new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil) + sources := []liquidlane.Inventory{ + liquidlane.DiscountInventory( + route, + big.NewInt(1_000_000), + big.NewInt(750_000_000_000_000_000), + discountID, + time.Time{}, + ), + } + physical := []liquidlane.FillQuote{{ + Inventory: liquidlane.DirectInventory(route, big.NewInt(1_000_000), nil), + AmountIn: amountIn, MaxAmountOut: big.NewInt(800_000), + }} + + got := NormalizeOracleInventory(amountIn, sources, physical) + if len(got) != 1 || got[0].Rate.String() != "750000000000000000" || got[0].DiscountID == nil { + t.Fatalf("normalized = %#v", got) + } +} diff --git a/internal/liquidlane/strategies/greedy/quote.go b/internal/liquidlane/strategies/greedy/quote.go new file mode 100644 index 00000000..90e34c38 --- /dev/null +++ b/internal/liquidlane/strategies/greedy/quote.go @@ -0,0 +1,302 @@ +package greedy + +import ( + "math/big" + + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" +) + +// UncoveredInputPolicy describes whether a LiquidLane quote must source output +// for every input unit or may absorb excess input as price impact. +type UncoveredInputPolicy uint8 + +const ( + RejectUncoveredInput UncoveredInputPolicy = iota + AbsorbUncoveredInput +) + +const insufficientCapacityReason = "insufficient-capacity" + +// QuoteTask is a protocol-neutral LiquidLane pricing problem. Exactly one of +// ExactInput and ExactOutput must be set. +type QuoteTask struct { + ExactInput *big.Int + ExactOutput *big.Int + + Candidates []liquidlane.QuoteCandidate + MaxRoutes int + MinInput *big.Int + + OutputBufferBps int + InputPolicy UncoveredInputPolicy + GasPricing *liquidstrategies.GasPricing + Trace liquidstrategies.DecisionTrace +} + +// QuoteSolution is the priced amount pair and the LiquidLane allocation that +// produced it. Protocol adapters may omit the allocation from their wire reply. +type QuoteSolution struct { + AmountIn *big.Int + GrossAmountOut *big.Int + GasCost *big.Int + AmountOut *big.Int + Allocations []Allocation +} + +// SolveQuote prices one exact-input or exact-output LiquidLane task. +func SolveQuote(task QuoteTask) (*QuoteSolution, error) { + mode := "exact-output" + if task.ExactInput != nil { + mode = "exact-input" + } + if (task.ExactInput == nil) == (task.ExactOutput == nil) { + return nil, errors.New("exactly one quote amount must be set") + } + if task.MaxRoutes <= 0 || len(task.Candidates) == 0 { + task.Trace.Decline( + "quote", "no-candidates", + "candidates", len(task.Candidates), + "maxRoutes", task.MaxRoutes, + ) + return nil, nil + } + if task.OutputBufferBps < 0 || task.OutputBufferBps >= bpsDenominator { + return nil, errors.Errorf("outputBufferBps: must be in [0,%d)", bpsDenominator) + } + if task.InputPolicy != RejectUncoveredInput && task.InputPolicy != AbsorbUncoveredInput { + return nil, errors.New("invalid uncovered input policy") + } + if task.ExactOutput != nil && task.InputPolicy == AbsorbUncoveredInput { + return nil, errors.New("exact-output quote cannot absorb uncovered input") + } + if task.MinInput != nil && task.MinInput.Sign() < 0 { + return nil, errors.New("minInput: must be non-negative") + } + routes := newAllocator(task.Candidates) + var solution *QuoteSolution + if task.ExactInput != nil { + solution = solveExactInputQuote(task, routes, task.ExactInput, task.MaxRoutes) + } else { + solution = solveExactOutputQuote(task, routes) + } + traceQuoteSolution(task.Trace, mode, solution) + return solution, nil +} + +func solveExactInputQuote( + task QuoteTask, + allocator allocator, + amountIn *big.Int, + maxRoutes int, +) *QuoteSolution { + if amountIn == nil || amountIn.Sign() <= 0 || + (task.MinInput != nil && amountIn.Cmp(task.MinInput) < 0) { + task.Trace.Decline( + "quote", "amount-below-minimum", + "amountIn", bigString(amountIn), + "minInput", bigString(task.MinInput), + ) + return nil + } + allocation := allocator.allocateExactInputWithPolicy( + amountIn, + maxRoutes, + task.InputPolicy == RejectUncoveredInput, + ) + if len(allocation.Allocations) == 0 { + reason := "no-allocation" + if len(allocator.sources) > 0 { + reason = insufficientCapacityReason + } + task.Trace.Decline( + "quote", reason, + "amountIn", amountIn.String(), + "routes", len(allocator.sources), + ) + return nil + } + if allocation.Remaining.Sign() != 0 { + if task.InputPolicy == RejectUncoveredInput { + task.Trace.Decline( + "quote", insufficientCapacityReason, + "amountIn", amountIn.String(), + "allocatedAmountIn", allocation.TotalAmountIn.String(), + "remainingAmountIn", allocation.Remaining.String(), + "routes", len(allocation.Allocations), + ) + return nil + } + last := &allocation.Allocations[len(allocation.Allocations)-1] + last.AmountIn.Add(last.AmountIn, allocation.Remaining) + allocation.Remaining.SetInt64(0) + } + + grossAmountOut := liquidlane.CloneBig(allocation.TotalAmountOut) + gasCost := new(big.Int) + amountOut := applyBpsDown(grossAmountOut, bpsDenominator-task.OutputBufferBps) + if task.GasPricing != nil { + gasCost = task.GasPricing.Cost(quoteGasLegs(allocation.Allocations)) + amountOut.Sub(amountOut, gasCost) + } + if amountOut.Sign() <= 0 { + task.Trace.Decline( + "quote", "gas-exceeds-output", + "amountIn", amountIn.String(), + "grossAmountOut", grossAmountOut.String(), + "bufferedAmountOut", applyBpsDown(grossAmountOut, bpsDenominator-task.OutputBufferBps).String(), + "gasCost", gasCost.String(), + "netAmountOut", amountOut.String(), + ) + return nil + } + return &QuoteSolution{ + AmountIn: liquidlane.CloneBig(amountIn), GrossAmountOut: grossAmountOut, + GasCost: gasCost, AmountOut: amountOut, + Allocations: cloneAllocations(allocation.Allocations), + } +} + +func solveExactOutputQuote(task QuoteTask, allocator allocator) *QuoteSolution { + if task.ExactOutput == nil || task.ExactOutput.Sign() <= 0 { + task.Trace.Decline("quote", "invalid-exact-output") + return nil + } + solution := solveExactOutputQuoteGreedy(task, allocator) + if solution == nil { + return nil + } + if task.MinInput != nil && solution.AmountIn.Cmp(task.MinInput) < 0 { + task.Trace.Log( + "liquidlane exact-output minimum input applied", + "calculatedAmountIn", solution.AmountIn.String(), + "minInput", task.MinInput.String(), + ) + minimumQuote := solveExactInputQuote(task, allocator, task.MinInput, task.MaxRoutes) + if minimumQuote == nil || minimumQuote.AmountOut.Cmp(task.ExactOutput) < 0 { + task.Trace.Decline( + "quote", "minimum-input-cannot-cover-output", + "exactOutput", task.ExactOutput.String(), + "minInput", task.MinInput.String(), + ) + return nil + } + minimumQuote.AmountOut = liquidlane.CloneBig(task.ExactOutput) + return minimumQuote + } + return solution +} + +func solveExactOutputQuoteGreedy(task QuoteTask, allocator allocator) *QuoteSolution { + targetGross := grossOutputForNet(task.ExactOutput, new(big.Int), task.OutputBufferBps) + for targetGross.Sign() > 0 { + allocation := allocator.allocateExactOutput(targetGross, task.MaxRoutes) + if len(allocation.Allocations) == 0 || allocation.Remaining.Sign() != 0 { + task.Trace.Decline( + "quote", insufficientCapacityReason, + "mode", "exact-output", + "targetGrossAmountOut", targetGross.String(), + "allocatedAmountOut", allocation.TotalAmountOut.String(), + "remainingAmountOut", allocation.Remaining.String(), + "routes", len(allocation.Allocations), + ) + return nil + } + gasCost := new(big.Int) + if task.GasPricing != nil { + gasCost = task.GasPricing.Cost(quoteGasLegs(allocation.Allocations)) + } + netOutput := applyBpsDown(allocation.TotalAmountOut, bpsDenominator-task.OutputBufferBps) + netOutput.Sub(netOutput, gasCost) + if netOutput.Cmp(task.ExactOutput) >= 0 { + return &QuoteSolution{ + AmountIn: allocation.TotalAmountIn, GrossAmountOut: liquidlane.CloneBig(allocation.TotalAmountOut), + GasCost: gasCost, AmountOut: liquidlane.CloneBig(task.ExactOutput), + Allocations: cloneAllocations(allocation.Allocations), + } + } + requiredGross := grossOutputForNet(task.ExactOutput, gasCost, task.OutputBufferBps) + if requiredGross.Cmp(targetGross) <= 0 { + task.Trace.Decline( + "quote", "gas-exceeds-output", + "mode", "exact-output", + "exactOutput", task.ExactOutput.String(), + "grossAmountOut", allocation.TotalAmountOut.String(), + "gasCost", gasCost.String(), + "netAmountOut", netOutput.String(), + ) + return nil + } + targetGross = requiredGross + } + return nil +} + +func grossOutputForNet(netOutput, gasCost *big.Int, outputBufferBps int) *big.Int { + target := new(big.Int).Add(netOutput, gasCost) + return liquidlane.MulDivUp( + target, + big.NewInt(bpsDenominator), + big.NewInt(int64(bpsDenominator-outputBufferBps)), + ) +} + +func quoteGasLegs(allocations []Allocation) []liquidstrategies.GasLeg { + legs := make([]liquidstrategies.GasLeg, len(allocations)) + for index, allocation := range allocations { + legs[index] = liquidstrategies.GasLeg{ + Route: allocation.Candidate.Route, AmountOut: allocation.AmountOut, + Private: allocation.Candidate.DiscountID != nil, + } + } + return legs +} + +func cloneAllocations(allocations []Allocation) []Allocation { + out := make([]Allocation, len(allocations)) + for index, allocation := range allocations { + out[index] = Allocation{ + Candidate: allocation.Candidate, + AmountIn: liquidlane.CloneBig(allocation.AmountIn), + AmountOut: liquidlane.CloneBig(allocation.AmountOut), + } + } + return out +} + +func traceQuoteSolution(trace liquidstrategies.DecisionTrace, mode string, solution *QuoteSolution) { + if trace == nil || solution == nil { + return + } + trace.Log( + "liquidlane quote selected", + "mode", mode, + "amountIn", solution.AmountIn.String(), + "grossAmountOut", solution.GrossAmountOut.String(), + "gasCost", solution.GasCost.String(), + "amountOut", solution.AmountOut.String(), + "routes", len(solution.Allocations), + ) + for index, allocation := range solution.Allocations { + trace.Log( + "liquidlane quote leg selected", + "leg", index, + "candidateId", allocation.Candidate.ID, + "routeId", allocation.Candidate.Route.ID, + "capacityId", liquidlane.RouteCapacityID(allocation.Candidate.Route), + "adapter", allocation.Candidate.Route.Adapter.Hex(), + "amountIn", bigString(allocation.AmountIn), + "amountOut", bigString(allocation.AmountOut), + "private", allocation.Candidate.DiscountID != nil, + ) + } +} + +func bigString(value *big.Int) string { + if value == nil { + return "0" + } + return value.String() +} diff --git a/internal/liquidlane/strategies/greedy/quote_test.go b/internal/liquidlane/strategies/greedy/quote_test.go new file mode 100644 index 00000000..62562863 --- /dev/null +++ b/internal/liquidlane/strategies/greedy/quote_test.go @@ -0,0 +1,197 @@ +package greedy + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestSolveQuoteRejectsOrAbsorbsUncoveredInput(t *testing.T) { + candidates := []Candidate{candidate("only", "route", 100, 60)} + var declineReason string + strict, err := SolveQuote(QuoteTask{ + ExactInput: big.NewInt(100), Candidates: candidates, MaxRoutes: 1, + Trace: func(_ string, fields ...any) { + declineReason, _ = fields[1].(string) + }, + }) + if err != nil || strict != nil { + t.Fatalf("strict = %+v, err %v", strict, err) + } + if declineReason != insufficientCapacityReason { + t.Fatalf("decline reason = %q", declineReason) + } + absorbed, err := SolveQuote(QuoteTask{ + ExactInput: big.NewInt(100), Candidates: candidates, MaxRoutes: 1, + InputPolicy: AbsorbUncoveredInput, + }) + if err != nil || absorbed == nil || absorbed.AmountOut.Int64() != 60 || + len(absorbed.Allocations) != 1 || absorbed.Allocations[0].AmountIn.Int64() != 100 { + t.Fatalf("absorbed = %+v, err %v", absorbed, err) + } +} + +func TestSolveQuoteStrictUsesRouteThatCanCoverLastSlot(t *testing.T) { + narrow := candidate("narrow", "route-1", 200, 60) + wide := candidate("wide", "route-2", 100, 100) + quote, err := SolveQuote(QuoteTask{ + ExactInput: big.NewInt(100), Candidates: []Candidate{narrow, wide}, MaxRoutes: 1, + InputPolicy: RejectUncoveredInput, + }) + if err != nil || quote == nil || len(quote.Allocations) != 1 || + quote.Allocations[0].Candidate.ID != "wide" { + t.Fatalf("quote = %+v, err %v; want the route that covers the full input", quote, err) + } +} + +func TestSolveQuoteAppliesBufferAndFindsExactOutputInput(t *testing.T) { + candidates := []Candidate{candidate("only", "route", 100, 1_000)} + exactInput, err := SolveQuote(QuoteTask{ + ExactInput: big.NewInt(1_000), Candidates: candidates, MaxRoutes: 1, + OutputBufferBps: 200, + }) + if err != nil || exactInput == nil || exactInput.GrossAmountOut.Int64() != 1_000 || + exactInput.GasCost.Sign() != 0 || exactInput.AmountOut.Int64() != 980 { + t.Fatalf("exact input = %+v, err %v", exactInput, err) + } + exactOutput, err := SolveQuote(QuoteTask{ + ExactOutput: big.NewInt(980), Candidates: candidates, MaxRoutes: 1, + OutputBufferBps: 200, + }) + if err != nil || exactOutput == nil || exactOutput.AmountIn.Int64() != 1_000 || + exactOutput.AmountOut.Int64() != 980 { + t.Fatalf("exact output = %+v, err %v", exactOutput, err) + } +} + +func TestSolveQuoteExactOutputKeepsRoundingSurplus(t *testing.T) { + discountID := common.HexToHash("0x01") + private := candidate("private", "route", 120, 100) + private.DiscountID = &discountID + direct := candidate("direct", "route", 100, 1_000) + + quote, err := SolveQuote(QuoteTask{ + ExactOutput: big.NewInt(119), Candidates: []Candidate{private, direct}, MaxRoutes: 1, + }) + if err != nil || quote == nil || quote.AmountIn.Int64() != 100 || quote.AmountOut.Int64() != 119 || + len(quote.Allocations) != 1 || quote.Allocations[0].AmountOut.Int64() != 120 { + t.Fatalf("quote = %+v, err %v; want input 100, user output 119, gross output 120", quote, err) + } +} + +func TestSolveQuoteExactOutputDoesNotDeclineAtWiderWorseAlternative(t *testing.T) { + discountID := common.HexToHash("0x01") + private := candidate("private", "route", 200, 100) + private.DiscountID = &discountID + direct := candidate("direct", "route", 10, 1_000) + + quote, err := SolveQuote(QuoteTask{ + ExactOutput: big.NewInt(150), Candidates: []Candidate{private, direct}, MaxRoutes: 1, + }) + if err != nil || quote == nil || quote.AmountIn.Int64() != 75 || quote.AmountOut.Int64() != 150 { + t.Fatalf("quote = %+v, err %v; want the narrow private alternative", quote, err) + } +} + +func TestSolveQuoteExactOutputUsesWiderAlternativeWhenPrivateCannotCover(t *testing.T) { + discountID := common.HexToHash("0x01") + private := candidate("private", "route", 200, 100) + private.DiscountID = &discountID + direct := candidate("direct", "route", 100, 1_000) + + quote, err := SolveQuote(QuoteTask{ + ExactOutput: big.NewInt(250), Candidates: []Candidate{private, direct}, MaxRoutes: 1, + }) + if err != nil || quote == nil || quote.AmountIn.Int64() != 250 || + len(quote.Allocations) != 1 || quote.Allocations[0].Candidate.ID != "direct" { + t.Fatalf("quote = %+v, err %v; want wider direct alternative", quote, err) + } +} + +func TestSolveQuoteExactInputUsesWiderPrivateAlternative(t *testing.T) { + narrowDiscountID := common.HexToHash("0x01") + wideDiscountID := common.HexToHash("0x02") + narrow := candidate("narrow-private", "route", 200, 40) + narrow.DiscountID = &narrowDiscountID + wide := candidate("wide-private", "route", 100, 100) + wide.DiscountID = &wideDiscountID + + quote, err := SolveQuote(QuoteTask{ + ExactInput: big.NewInt(100), Candidates: []Candidate{narrow, wide}, MaxRoutes: 1, + InputPolicy: RejectUncoveredInput, + }) + if err != nil || quote == nil || len(quote.Allocations) != 1 || + quote.Allocations[0].Candidate.ID != "wide-private" || + quote.AmountOut.Int64() != 100 { + t.Fatalf("quote = %+v, err %v; want the wider private alternative", quote, err) + } +} + +func TestSolveQuoteExactOutputUsesNarrowPrivateAfterAnotherRoute(t *testing.T) { + discountID := common.HexToHash("0x01") + private := candidate("private", "route-1", 200, 100) + private.DiscountID = &discountID + direct := candidate("direct", "route-1", 100, 1_000) + second := candidate("second", "route-2", 150, 100) + + quote, err := SolveQuote(QuoteTask{ + ExactOutput: big.NewInt(250), Candidates: []Candidate{private, direct, second}, MaxRoutes: 2, + }) + if err != nil || quote == nil || quote.AmountIn.Int64() != 150 || len(quote.Allocations) != 2 || + quote.Allocations[0].Candidate.ID != "second" || quote.Allocations[1].Candidate.ID != "private" { + t.Fatalf("quote = %+v, err %v; want second route then narrow private alternative", quote, err) + } +} + +func TestSolveQuoteExactOutputCanUseMinimumInputAsSurplus(t *testing.T) { + quote, err := SolveQuote(QuoteTask{ + ExactOutput: big.NewInt(100), MinInput: big.NewInt(80), + Candidates: []Candidate{candidate("only", "route", 200, 100)}, MaxRoutes: 1, + }) + if err != nil || quote == nil || quote.AmountIn.Int64() != 80 || quote.AmountOut.Int64() != 100 || + len(quote.Allocations) != 1 || quote.Allocations[0].AmountOut.Int64() != 160 { + t.Fatalf("quote = %+v, err %v; want minimum input with gross surplus", quote, err) + } +} + +func TestSolveQuoteRejectsAmbiguousExactOutputPolicy(t *testing.T) { + _, err := SolveQuote(QuoteTask{ + ExactOutput: big.NewInt(1), Candidates: []Candidate{candidate("only", "route", 100, 1)}, + MaxRoutes: 1, InputPolicy: AbsorbUncoveredInput, + }) + if err == nil { + t.Fatal("expected exact-output policy error") + } +} + +func FuzzSolveQuoteExactOutputFindsNoMoreInputThanExactInput(f *testing.F) { + f.Add(uint32(1_000), uint16(125), uint16(200)) + f.Fuzz(func(t *testing.T, rawAmount uint32, rawRate, rawBuffer uint16) { + amount := int64(rawAmount%1_000_000 + 1) + rate := int64(rawRate%200 + 1) + buffer := int(rawBuffer % 1_000) + candidates := []Candidate{candidate("only", "route", rate, amount)} + exactInput, err := SolveQuote(QuoteTask{ + ExactInput: big.NewInt(amount), Candidates: candidates, MaxRoutes: 1, + OutputBufferBps: buffer, + }) + if err != nil { + t.Fatal(err) + } + if exactInput == nil { + return + } + exactOutput, err := SolveQuote(QuoteTask{ + ExactOutput: exactInput.AmountOut, Candidates: candidates, MaxRoutes: 1, + OutputBufferBps: buffer, + }) + if err != nil || exactOutput == nil { + t.Fatalf("exact output = %+v, err %v", exactOutput, err) + } + if exactOutput.AmountIn.Cmp(exactInput.AmountIn) > 0 || + exactOutput.AmountOut.Cmp(exactInput.AmountOut) != 0 { + t.Fatalf("exact input = %+v, exact output = %+v", exactInput, exactOutput) + } + }) +} diff --git a/internal/liquidlane/strategies/greedy/ranges.go b/internal/liquidlane/strategies/greedy/ranges.go new file mode 100644 index 00000000..9e909529 --- /dev/null +++ b/internal/liquidlane/strategies/greedy/ranges.go @@ -0,0 +1,61 @@ +package greedy + +import ( + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +// BestRouteCandidates keeps the highest-ranked physical routes and every +// alternative that can outperform or outlive another alternative for the same +// route. +func BestRouteCandidates(candidates []liquidlane.QuoteCandidate, maxRoutes int) []liquidlane.QuoteCandidate { + if maxRoutes <= 0 { + return nil + } + sources := buildSources(candidates) + if len(sources) > maxRoutes { + sources = sources[:maxRoutes] + } + out := make([]liquidlane.QuoteCandidate, 0, len(candidates)) + for _, source := range sources { + out = append(out, nonDominatedAlternatives(source.alternatives)...) + } + return out +} + +func nonDominatedAlternatives(candidates []liquidlane.QuoteCandidate) []liquidlane.QuoteCandidate { + out := make([]liquidlane.QuoteCandidate, 0, len(candidates)) + for index, candidate := range candidates { + dominated := false + for otherIndex, other := range candidates { + if index != otherIndex && dominates(other, candidate) { + dominated = true + break + } + } + if !dominated { + out = append(out, candidate) + } + } + return out +} + +func dominates(left, right liquidlane.QuoteCandidate) bool { + // A private alternative costs more gas than a direct one, so raw rate and + // capacity alone cannot prove that it dominates the direct path. + if left.DiscountID != nil && right.DiscountID == nil { + return false + } + if left.Rate.Cmp(right.Rate) < 0 || + left.MaxAmountIn.Cmp(right.MaxAmountIn) < 0 || + left.MaxAmountOut.Cmp(right.MaxAmountOut) < 0 { + return false + } + if right.ValidUntil.IsZero() { + if !left.ValidUntil.IsZero() { + return false + } + } else if !left.ValidUntil.IsZero() && left.ValidUntil.Before(right.ValidUntil) { + return false + } + return better(left, right) +} diff --git a/internal/liquidlane/strategies/greedy/ranges_test.go b/internal/liquidlane/strategies/greedy/ranges_test.go new file mode 100644 index 00000000..077f6c18 --- /dev/null +++ b/internal/liquidlane/strategies/greedy/ranges_test.go @@ -0,0 +1,38 @@ +package greedy + +import ( + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +func TestBestRouteCandidatesKeepsUsefulAlternatives(t *testing.T) { + bestDiscount := common.HexToHash("0x01") + worseDiscount := common.HexToHash("0x02") + direct := candidate("direct", "route-1", 90, 100) + bestPrivate := candidate("best-private", "route-1", 100, 100) + bestPrivate.DiscountID = &bestDiscount + worsePrivate := candidate("worse-private", "route-1", 95, 1_000) + worsePrivate.DiscountID = &worseDiscount + dominatedPrivate := candidate("dominated-private", "route-1", 80, 100) + dominatedPrivate.DiscountID = &worseDiscount + dominatedPrivate.ValidUntil = time.Unix(100, 0) + + got := BestRouteCandidates([]Candidate{direct, bestPrivate, worsePrivate, dominatedPrivate}, 1) + if len(got) != 3 { + t.Fatalf("candidates = %+v, want three useful alternatives", got) + } + seen := make(map[string]bool, len(got)) + for _, item := range got { + seen[string(item.ID)] = true + } + for _, want := range []string{"direct", "best-private", "worse-private"} { + if !seen[want] { + t.Fatalf("candidates = %+v, missing %s", got, want) + } + } + if seen["dominated-private"] { + t.Fatalf("candidates = %+v, contains dominated private alternative", got) + } +} diff --git a/internal/liquidlane/strategies/trace.go b/internal/liquidlane/strategies/trace.go new file mode 100644 index 00000000..10713e45 --- /dev/null +++ b/internal/liquidlane/strategies/trace.go @@ -0,0 +1,19 @@ +package strategies + +// DecisionTrace emits optional debug-only decision details. Callers decide +// whether tracing is enabled and attach protocol correlation fields. +type DecisionTrace func(message string, keyValues ...any) + +func (trace DecisionTrace) Log(message string, keyValues ...any) { + if trace != nil { + trace(message, keyValues...) + } +} + +func (trace DecisionTrace) Decline(decision, reason string, keyValues ...any) { + if trace == nil { + return + } + fields := append([]any{"reason", reason}, keyValues...) + trace.Log("liquidlane "+decision+" declined", fields...) +} diff --git a/internal/liquidlane/types.go b/internal/liquidlane/types.go new file mode 100644 index 00000000..b17c61c6 --- /dev/null +++ b/internal/liquidlane/types.go @@ -0,0 +1,195 @@ +package liquidlane + +import ( + "math/big" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +type RouteID string +type CandidateID string +type CapacityID string + +// DiscountPrecision is the LiquidLane parts-per-million denominator. +const DiscountPrecision int64 = 1_000_000 + +// Adapter is adapter-level LiquidLane metadata that is stable after startup. +type Adapter struct { + Adapter common.Address `json:"adapter"` + Vault common.Address `json:"vault"` + + TokenOut common.Address `json:"tokenOut"` + TokenOutDecimals int `json:"tokenOutDecimals"` +} + +// Route is one LiquidLane adapter path: tokenIn -> adapter -> tokenOut. +type Route struct { + ID RouteID `json:"id"` + CapacityID CapacityID `json:"capacityId"` + + Adapter common.Address `json:"adapter"` + Vault common.Address `json:"vault"` + + TokenIn common.Address `json:"tokenIn"` + TokenOut common.Address `json:"tokenOut"` + + TokenInDecimals int `json:"tokenInDecimals"` + TokenOutDecimals int `json:"tokenOutDecimals"` +} + +// Inventory is the current read-side liquidity/cap snapshot for one route. +type Inventory struct { + Route + + MaxAssets *big.Int `json:"maxAssets"` + MaxRate *big.Int `json:"maxRate"` + // AdapterMinDiscount is the adapter's current minimum accepted discount in parts per million. + // It is a physical validation fact, not part of the strategy wire shape. + AdapterMinDiscount *big.Int `json:"-"` + + DiscountID *common.Hash `json:"discountId"` + + ValidUntil time.Time `json:"validUntil"` +} + +// QuoteCandidate is one amount-normalized route alternative ready for a +// LiquidLane quoting strategy. Candidates sharing Route.ID are mutually +// exclusive direct/private alternatives for the same physical route. +type QuoteCandidate struct { + ID CandidateID `json:"id"` + Route Route `json:"route"` + + Rate *big.Int `json:"rate"` + MaxAmountIn *big.Int `json:"maxAmountIn"` + MaxAmountOut *big.Int `json:"maxAmountOut"` + + DiscountID *common.Hash `json:"discountId"` + ValidUntil time.Time `json:"validUntil"` +} + +// FillQuote is a current adapter quote for one concrete amountIn. +type FillQuote struct { + Inventory + + AmountIn *big.Int `json:"amountIn"` + GrossAmountOut *big.Int `json:"grossAmountOut"` + MaxAmountOut *big.Int `json:"maxAmountOut"` + MinDiscount *big.Int `json:"minDiscount"` +} + +type Auth struct { + Adapter common.Address + MarketMaker common.Address + Owner common.Address + IsFiller bool + Authorized bool +} + +// AdapterSnapshot is a current, solver-neutral view of one LiquidLane adapter and its routes. +type AdapterSnapshot struct { + Adapter + + Paused bool + Authorized bool + FreeAssets *big.Int + Withdrawable *big.Int + Routes []RouteSnapshot +} + +// RouteSnapshot combines route metadata with current inventory and adapter-local acquire liquidity. +type RouteSnapshot struct { + Route + + MaxAssets *big.Int + MaxRate *big.Int + AcquireBalance *big.Int +} + +func NewRoute( + chainID int64, + adapter common.Address, + vault common.Address, + tokenIn common.Address, + tokenOut common.Address, + tokenInDecimals int, + tokenOutDecimals int, +) Route { + return Route{ + ID: NewRouteID(chainID, adapter, tokenIn, tokenOut), + CapacityID: NewCapacityID(chainID, vault, tokenOut), + Adapter: adapter, + Vault: vault, + TokenIn: tokenIn, + TokenOut: tokenOut, + TokenInDecimals: tokenInDecimals, + TokenOutDecimals: tokenOutDecimals, + } +} + +func NewCapacityID(chainID int64, vault, tokenOut common.Address) CapacityID { + return CapacityID(strings.ToLower( + "capacity:" + strconv.FormatInt(chainID, 10) + ":" + vault.Hex() + ":" + tokenOut.Hex(), + )) +} + +func RouteCapacityID(route Route) CapacityID { + if route.CapacityID != "" { + return route.CapacityID + } + return CapacityID(route.ID) +} + +func NewRouteID(chainID int64, adapter, tokenIn, tokenOut common.Address) RouteID { + return RouteID(strings.ToLower( + "route:" + strconv.FormatInt(chainID, 10) + ":" + adapter.Hex() + ":" + tokenIn.Hex() + ":" + tokenOut.Hex(), + )) +} + +func NewCandidateID(route Route, discountID *common.Hash) CandidateID { + id := "candidate:" + string(route.ID) + if discountID != nil { + id += ":discount:" + discountID.Hex() + } + return CandidateID(strings.ToLower(id)) +} + +func DirectInventory(route Route, maxAssets, maxRate *big.Int) Inventory { + return Inventory{ + Route: route, + MaxAssets: CloneBig(maxAssets), + MaxRate: CloneBig(maxRate), + } +} + +func DiscountInventory( + route Route, + maxAssets, maxRate *big.Int, + discountID common.Hash, + validUntil time.Time, +) Inventory { + return Inventory{ + Route: route, + MaxAssets: CloneBig(maxAssets), + MaxRate: CloneBig(maxRate), + DiscountID: CloneHash(&discountID), + ValidUntil: validUntil, + } +} + +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/liquidlane/types_test.go b/internal/liquidlane/types_test.go new file mode 100644 index 00000000..539c5b06 --- /dev/null +++ b/internal/liquidlane/types_test.go @@ -0,0 +1,46 @@ +package liquidlane + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +func TestIDsAreStableLowercase(t *testing.T) { + adapter := common.HexToAddress("0x00000000000000000000000000000000000000AA") + tokenIn := common.HexToAddress("0x00000000000000000000000000000000000000BB") + tokenOut := common.HexToAddress("0x00000000000000000000000000000000000000CC") + discount := common.HexToHash("0xABCDEF0000000000000000000000000000000000000000000000000000000000") + + route := NewRoute(11155111, adapter, common.Address{}, tokenIn, tokenOut, 18, 6) + if got, want := string(route.ID), "route:11155111:0x00000000000000000000000000000000000000aa:0x00000000000000000000000000000000000000bb:0x00000000000000000000000000000000000000cc"; got != want { + t.Fatalf("route id = %q, want %q", got, want) + } + if got, want := string(NewCandidateID(route, &discount)), "candidate:"+string(route.ID)+":discount:"+discount.Hex(); got != want { + t.Fatalf("candidate id = %q, want %q", got, want) + } +} + +func TestInventoryConstructorsCloneMutableValues(t *testing.T) { + route := NewRoute(1, common.HexToAddress("0x1"), common.Address{}, common.HexToAddress("0x2"), common.HexToAddress("0x3"), 18, 6) + maxAssets := big.NewInt(100) + maxRate := big.NewInt(200) + discount := common.HexToHash("0x42") + + validUntil := time.Unix(2, 0) + inv := DiscountInventory(route, maxAssets, maxRate, discount, validUntil) + maxAssets.SetInt64(1) + maxRate.SetInt64(2) + + if inv.MaxAssets.String() != "100" || inv.MaxRate.String() != "200" { + t.Fatalf("inventory did not clone big.Int values: maxAssets=%s maxRate=%s", inv.MaxAssets, inv.MaxRate) + } + if inv.DiscountID == nil || *inv.DiscountID == (common.Hash{}) { + t.Fatalf("inventory did not clone discount id: %v", inv.DiscountID) + } + if !inv.ValidUntil.Equal(validUntil) { + t.Fatalf("valid until = %s", inv.ValidUntil) + } +} diff --git a/internal/liquidlanemath/math.go b/internal/liquidlanemath/math.go deleted file mode 100644 index 321b5002..00000000 --- a/internal/liquidlanemath/math.go +++ /dev/null @@ -1,51 +0,0 @@ -// 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 deleted file mode 100644 index 88273d76..00000000 --- a/internal/liquidlanemath/math_test.go +++ /dev/null @@ -1,63 +0,0 @@ -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/solvers/bridgefacilitator/apiclient_test.go b/internal/solvers/bridgefacilitator/apiclient_test.go index cc77764c..26274f72 100644 --- a/internal/solvers/bridgefacilitator/apiclient_test.go +++ b/internal/solvers/bridgefacilitator/apiclient_test.go @@ -16,9 +16,9 @@ import ( ) // fakeSigner is a minimal signer.Signer test double that signs nothing meaningful (65 zero bytes). -type fakeSigner struct{} +type fakeSigner struct{ addr common.Address } -func (fakeSigner) Address() common.Address { return common.Address{} } +func (s fakeSigner) Address() common.Address { return s.addr } func (fakeSigner) SignHash(_ common.Hash) ([]byte, error) { return make([]byte, 65), nil } diff --git a/internal/solvers/bridgefacilitator/chainreader.go b/internal/solvers/bridgefacilitator/chainreader.go index 6f6917de..086e271c 100644 --- a/internal/solvers/bridgefacilitator/chainreader.go +++ b/internal/solvers/bridgefacilitator/chainreader.go @@ -7,11 +7,15 @@ import ( "github.com/go-errors/errors" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" "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/adapterfactory" "github.com/symbioticfi/vault-solver/api/bindings/erc4626" + "github.com/symbioticfi/vault-solver/api/bindings/lens" "github.com/symbioticfi/vault-solver/internal/chain" + "github.com/symbioticfi/vault-solver/internal/signer" ) // Contract bindings (abigen --v2): typed Pack/Unpack helpers for the Multicall3 sub-calls below, so an @@ -19,12 +23,14 @@ import ( // // 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. +// no longer reads the delegator/vault directly for sizing. The collateral token is read during each +// adapter refresh via IERC4626(vault).asset() to match auctions. var ( bfAdapter = adapter.NewThreeFAdapter() + factoryB = adapterfactory.NewIAdapterFactory() vc = vaultcontroller.NewIVaultController() erc4626b = erc4626.NewIERC4626() + lensB = lens.NewFrontendLiquidityLens() ) // maxRequests mirrors MAX_REQUESTS in IThreeFAdapter — the adapter rejects a new request once it tracks @@ -33,28 +39,132 @@ var ( // here rather than read.) const maxRequests = 50 +// maxFactoryEntities bounds the configured factory snapshot before allocating one call per entity. +// A real deployment is orders of magnitude smaller; larger reported counts are rejected so corrupt +// or malicious data cannot exhaust RAM. +const maxFactoryEntities = 2_000 + +// erc1271MagicValue is the ERC-1271 return value of isValidSignature(bytes32,bytes) for a valid +// signature (`bytes4(keccak256("isValidSignature(bytes32,bytes)"))`). +var erc1271MagicValue = [4]byte{0x16, 0x26, 0xba, 0x7e} + +// eligibilityProbeMessage is signed once at startup to build the signerProbe. Its hash is arbitrary and +// deliberately distinct from any EIP-712 offer digest, so the resulting signature cannot be replayed as +// an offer; the adapter's isValidSignature validates the raw hash against its offerSigner regardless. +const eligibilityProbeMessage = "vault-solver:3f:offer-signer-eligibility:v1" + +// signerProbe is a fixed (hash, signature) pair produced once from the solver's key. It is fed to each +// adapter's ERC-1271 isValidSignature to test whether this solver is an authorized offer signer for that +// adapter — matching the exact on-chain check 3F uses to accept offers, so it works whether the adapter's +// offerSigner is this solver's EOA (ecrecover) or an EIP-1271 contract that authorizes this key. The pair +// is reusable across every adapter and across periodic re-checks (see resolveAdapters). +type signerProbe struct { + hash [32]byte + sig []byte +} + +// newSignerProbe signs the fixed eligibility message with the solver's key once. +func newSignerProbe(s signer.Signer) (signerProbe, error) { + hash := crypto.Keccak256Hash([]byte(eligibilityProbeMessage)) + sig, err := s.SignHash(hash) + if err != nil { + return signerProbe{}, errors.Errorf("sign offer-signer eligibility probe: %w", err) + } + return signerProbe{hash: hash, sig: sig}, nil +} + // 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 + // lens is the FrontendLiquidityLens address. When non-zero, funding headroom is read from the lens's + // cross-adapter deallocation-cascade estimate instead of the adapter's own getMaxAssets(); zero falls + // back to the adapter getter. + lens common.Address } -func newReader(c *chain.Client) *reader { - return &reader{chain: c} +func newReader(c *chain.Client, lens common.Address) *reader { + return &reader{chain: c, lens: lens} +} + +// factoryAdapters returns a bounded factory entity snapshot in registry order. The registry is +// append-only, so totalEntities followed by a batched entity(i) read is a consistent enumeration. +func (r *reader) factoryAdapters(ctx context.Context, factory common.Address) ([]common.Address, error) { + res, err := r.chain.Multicall(ctx, []chain.Call{{Target: factory, Data: factoryB.PackTotalEntities()}}) + if err != nil { + return nil, err + } + if len(res) != 1 || !res[0].Success { + return nil, errors.New("adapter factory totalEntities() reverted") + } + total, err := factoryB.UnpackTotalEntities(res[0].ReturnData) + if err != nil { + return nil, errors.Errorf("adapter factory totalEntities(): %w", err) + } + if total.Cmp(big.NewInt(maxFactoryEntities)) > 0 { + return nil, errors.Errorf("adapter factory entity count %s exceeds safety limit %d", total.String(), maxFactoryEntities) + } + count := int(total.Int64()) + if count == 0 { + return nil, nil + } + + calls := make([]chain.Call, count) + for i := range calls { + calls[i] = chain.Call{Target: factory, Data: factoryB.PackEntity(big.NewInt(int64(i)))} + } + res, err = r.chain.Multicall(ctx, calls) + if err != nil { + return nil, err + } + if len(res) != count { + return nil, errors.Errorf("adapter factory returned %d entities, want %d", len(res), count) + } + + adapters := make([]common.Address, count) + for i := range res { + if !res[i].Success { + return nil, errors.Errorf("adapter factory entity(%d) reverted", i) + } + adapterAddr, unpackErr := factoryB.UnpackEntity(res[i].ReturnData) + if unpackErr != nil { + return nil, errors.Errorf("adapter factory entity(%d): %w", i, unpackErr) + } + if adapterAddr == (common.Address{}) { + return nil, errors.Errorf("adapter factory entity(%d) is zero", i) + } + adapters[i] = adapterAddr + } + return adapters, nil } -// 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. +// resolvedAdapter is one adapter's refresh resolution: its vault, that vault's collateral (the +// ERC-4626 asset, used to match auctions), its offer-signer (diagnostic only), and whether this solver +// is an authorized offer signer for it (adapter.isValidSignature accepted the probe). err is set (other +// fields zero) if a required read reverted, so the caller can drop just that adapter. type resolvedAdapter struct { vault common.Address collateral common.Address signer common.Address + authorized bool err error } -// decodeAddr returns the address a Multicall sub-call returned, or an error tagged with `what` if it -// reverted or failed to decode. +// authorizedByProbe reports whether the adapter's ERC-1271 isValidSignature accepted the probe +// signature. A revert or any non-magic return means not authorized (drop the adapter), not a hard error. +func authorizedByProbe(res chain.CallResult) bool { + if !res.Success { + return false + } + magic, err := bfAdapter.UnpackIsValidSignature(res.ReturnData) + if err != nil { + return false + } + return magic == erc1271MagicValue +} + +// decodeAddr returns the non-zero address a Multicall sub-call returned, or an error tagged with +// `what` if it reverted, failed to decode, or returned zero. 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) @@ -63,43 +173,57 @@ func decodeAddr(res chain.CallResult, unpack func([]byte) (common.Address, error if err != nil { return common.Address{}, errors.Errorf("decode %s: %w", what, err) } + if addr == (common.Address{}) { + return common.Address{}, errors.Errorf("%s returned zero address", what) + } return addr, nil } -// 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) { +// resolveAdapters resolves every adapter's vault, collateral, and offer-signer, and validates offer-signer +// authorization via the adapter's ERC-1271 isValidSignature(probe), in two Multicalls regardless of adapter +// count: round 1 batches each adapter's vault()+offerSigner()+isValidSignature(probe); round 2 batches +// asset() on the vaults of adapters that resolved and are authorized. Per-call AllowFailure isolates a bad +// adapter to its own err; a returned error is a whole-batch RPC failure. The probe is reusable — the same +// call drives startup validation and periodic re-validation. +func (r *reader) resolveAdapters(ctx context.Context, adapters []common.Address, probe signerProbe) ([]resolvedAdapter, error) { out := make([]resolvedAdapter, len(adapters)) - calls := make([]chain.Call, 0, 2*len(adapters)) + calls := make([]chain.Call, 0, 3*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}, + chain.Call{Target: a, Data: bfAdapter.PackIsValidSignature(probe.hash, probe.sig), AllowFailure: true}, ) } res, err := r.chain.Multicall(ctx, calls) if err != nil { return nil, err } + if len(res) != len(calls) { + return nil, errors.Errorf("adapter resolution returned %d results, want %d", len(res), len(calls)) + } - // Decode round 1; queue an asset() call for each adapter whose vault and signer both resolved. + // Decode round 1; queue an asset() call for each adapter that resolved and is an authorized signer. 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()") + base := 3 * i + vault, derr := decodeAddr(res[base], bfAdapter.UnpackVault, "adapter.vault()") if derr != nil { out[i].err = derr continue } - signer, derr := decodeAddr(res[2*i+1], bfAdapter.UnpackOfferSigner, "adapter.offerSigner()") + offerSigner, derr := decodeAddr(res[base+1], bfAdapter.UnpackOfferSigner, "adapter.offerSigner()") if derr != nil { out[i].err = derr continue } - out[i].vault, out[i].signer = vault, signer + out[i].vault, out[i].signer = vault, offerSigner + out[i].authorized = authorizedByProbe(res[base+2]) + if !out[i].authorized { + continue // not an authorized offer signer; the caller drops it (no collateral read needed) + } assetCalls = append(assetCalls, chain.Call{Target: vault, Data: erc4626b.PackAsset(), AllowFailure: true}) assetIdx = append(assetIdx, i) } @@ -111,6 +235,9 @@ func (r *reader) resolveAdapters(ctx context.Context, adapters []common.Address) if err != nil { return nil, err } + if len(ares) != len(assetCalls) { + return nil, errors.Errorf("asset resolution returned %d results, want %d", len(ares), len(assetCalls)) + } for k, idx := range assetIdx { collateral, derr := decodeAddr(ares[k], erc4626b.UnpackAsset, "vault.asset()") if derr != nil { @@ -129,7 +256,7 @@ type exposureState struct { 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) + minYieldPpm *big.Int // minYieldPerRequest (ppm) — exact on-chain floor (0 = no floor) } // liquidityAndExposure reads the adapter's JIT-funding headroom (getMaxAssets), its per-request caps, and @@ -138,8 +265,14 @@ type exposureState struct { // 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) { + // getMaxAssets headroom comes from the lens when configured (it models the delegator's cross-adapter + // deallocation cascade, which the adapter's own getter overstates); otherwise from the adapter itself. + maxAssetsCall := chain.Call{Target: adapterAddr, Data: bfAdapter.PackGetMaxAssets()} + if r.lens != (common.Address{}) { + maxAssetsCall = chain.Call{Target: r.lens, Data: lensB.PackGetMaxAssets(adapterAddr)} + } calls := []chain.Call{ - {Target: adapterAddr, Data: bfAdapter.PackGetMaxAssets()}, + maxAssetsCall, {Target: adapterAddr, Data: bfAdapter.PackMinYieldPerRequest()}, {Target: adapterAddr, Data: bfAdapter.PackMinAssetsPerRequest()}, {Target: adapterAddr, Data: bfAdapter.PackMaxAssetsPerRequest()}, @@ -184,7 +317,7 @@ func (r *reader) liquidityAndExposure(ctx context.Context, adapterAddr common.Ad openCount: clampCount(openCount), maxAssets: maxAssets, minAssets: minAssets, - minYieldBps: ppmToBps(minYield), + minYieldPpm: minYield, }, nil } @@ -199,12 +332,6 @@ func clampCount(n *big.Int) int { return maxRequests } -// 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. diff --git a/internal/solvers/bridgefacilitator/chainreader_test.go b/internal/solvers/bridgefacilitator/chainreader_test.go index a8bc2c6e..76aedfed 100644 --- a/internal/solvers/bridgefacilitator/chainreader_test.go +++ b/internal/solvers/bridgefacilitator/chainreader_test.go @@ -60,20 +60,16 @@ func TestCollectRequests(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) { +func TestDecodeAddr_RejectsZeroAddress(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) - } + _, err := decodeAddr( + chain.CallResult{Success: true, ReturnData: abiEncodeAddress(t, common.Address{})}, + bfAdapter.UnpackVault, + "adapter.vault()", + ) + if err == nil { + t.Fatal("expected a zero address to fail validation") } } @@ -173,9 +169,138 @@ func abiEncodeAddress(t *testing.T, addr common.Address) []byte { return enc } +func abiEncodeUint256(t *testing.T, value int64) []byte { + t.Helper() + uintType, err := abi.NewType("uint256", "", nil) + if err != nil { + t.Fatalf("abi.NewType uint256: %v", err) + } + enc, err := abi.Arguments{{Type: uintType}}.Pack(big.NewInt(value)) + if err != nil { + t.Fatalf("abi uint256 Pack: %v", err) + } + return enc +} + +// abiEncodeBytes4 ABI-encodes a bytes4 return value (the raw returnData for a Solidity function +// returning bytes4, e.g. ERC-1271 isValidSignature). +func abiEncodeBytes4(t *testing.T, b [4]byte) []byte { + t.Helper() + ty, err := abi.NewType("bytes4", "", nil) + if err != nil { + t.Fatalf("abi.NewType bytes4: %v", err) + } + enc, err := abi.Arguments{{Type: ty}}.Pack(b) + if err != nil { + t.Fatalf("abi bytes4 Pack: %v", err) + } + return enc +} + +func TestFactoryAdapters_EmptyRegistry(t *testing.T) { + t.Parallel() + + round := abiEncodeAggregate3Results(t, abiEncodeUint256(t, 0)) + c, stop := newMulticallFakeClient(t, round) + defer stop() + + got, err := newReader(c, common.Address{}).factoryAdapters(t.Context(), common.HexToAddress("0x00000000000000000000000000000000000000F0")) + if err != nil { + t.Fatalf("factoryAdapters: %v", err) + } + if len(got) != 0 { + t.Fatalf("factory adapters = %v, want empty", got) + } +} + +func TestFactoryAdapters_EnumeratesEntitiesInRegistryOrder(t *testing.T) { + t.Parallel() + + want := []common.Address{ + common.HexToAddress("0x00000000000000000000000000000000000000A0"), + common.HexToAddress("0x00000000000000000000000000000000000000A1"), + common.HexToAddress("0x00000000000000000000000000000000000000A2"), + } + countRound := abiEncodeAggregate3Results(t, abiEncodeUint256(t, int64(len(want)))) + entitiesRound := abiEncodeAggregate3Results(t, + abiEncodeAddress(t, want[0]), abiEncodeAddress(t, want[1]), abiEncodeAddress(t, want[2]), + ) + c, stop := newMulticallFakeClient(t, countRound, entitiesRound) + defer stop() + + got, err := newReader(c, common.Address{}).factoryAdapters(t.Context(), common.HexToAddress("0x00000000000000000000000000000000000000F0")) + if err != nil { + t.Fatalf("factoryAdapters: %v", err) + } + if len(got) != len(want) { + t.Fatalf("factory adapters = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("factory adapter %d = %s, want %s", i, got[i].Hex(), want[i].Hex()) + } + } +} + +func TestFactoryAdapterLimitIsTwoThousand(t *testing.T) { + t.Parallel() + + if maxFactoryEntities != 2_000 { + t.Fatalf("maxFactoryEntities = %d, want 2000", maxFactoryEntities) + } +} + +func TestFactoryAdapters_AcceptsEntityCountAtLimit(t *testing.T) { + t.Parallel() + + want := make([]common.Address, maxFactoryEntities) + encoded := make([][]byte, maxFactoryEntities) + for i := range want { + want[i] = common.BigToAddress(big.NewInt(int64(i + 1))) + encoded[i] = abiEncodeAddress(t, want[i]) + } + countRound := abiEncodeAggregate3Results(t, abiEncodeUint256(t, maxFactoryEntities)) + entitiesRound := abiEncodeAggregate3Results(t, encoded...) + c, stop := newMulticallFakeClient(t, countRound, entitiesRound) + defer stop() + + got, err := newReader(c, common.Address{}).factoryAdapters(t.Context(), common.HexToAddress("0x00000000000000000000000000000000000000F0")) + if err != nil { + t.Fatalf("factoryAdapters: %v", err) + } + if len(got) != len(want) { + t.Fatalf("factory adapters length = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("factory adapter %d = %s, want %s", i, got[i].Hex(), want[i].Hex()) + } + } +} + +func TestFactoryAdapters_RejectsEntityCountAboveLimit(t *testing.T) { + t.Parallel() + + const totalEntities = 2_001 + countRound := abiEncodeAggregate3Results(t, abiEncodeUint256(t, totalEntities)) + c, stop := newMulticallFakeClient(t, countRound) + defer stop() + + _, err := newReader(c, common.Address{}).factoryAdapters(t.Context(), common.HexToAddress("0x00000000000000000000000000000000000000F0")) + want := "adapter factory entity count 2001 exceeds safety limit 2000" + if err == nil || err.Error() != want { + t.Fatalf("factoryAdapters error = %v, want %q", err, want) + } +} + +// testProbe is any non-empty (hash, sig) pair; the fake client returns canned replies regardless of +// calldata, so its contents don't matter — only the isValidSignature return slots do. +var testProbe = signerProbe{hash: [32]byte{0x01}, sig: []byte{0x02}} + // 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. +// collateral and marks it authorized when isValidSignature returns the ERC-1271 magic value. Round 1 +// returns [vault0, signer0, magic0, vault1, signer1, magic1] and round 2 returns [asset0, asset1], so a +// layout off-by-one would cross adapters' fields. func TestResolveAdapters(t *testing.T) { t.Parallel() @@ -191,30 +316,104 @@ func TestResolveAdapters(t *testing.T) { asset1 := common.HexToAddress("0x00000000000000000000000000000000000000D1") round1 := abiEncodeAggregate3Results(t, - abiEncodeAddress(t, vault0), abiEncodeAddress(t, signer0), - abiEncodeAddress(t, vault1), abiEncodeAddress(t, signer1), + abiEncodeAddress(t, vault0), abiEncodeAddress(t, signer0), abiEncodeBytes4(t, erc1271MagicValue), + abiEncodeAddress(t, vault1), abiEncodeAddress(t, signer1), abiEncodeBytes4(t, erc1271MagicValue), ) round2 := abiEncodeAggregate3Results(t, abiEncodeAddress(t, asset0), abiEncodeAddress(t, asset1)) c, stop := newMulticallFakeClient(t, round1, round2) defer stop() - got, err := newReader(c).resolveAdapters(context.Background(), adapters) + got, err := newReader(c, common.Address{}).resolveAdapters(context.Background(), adapters, testProbe) if err != nil { t.Fatalf("resolveAdapters: %v", err) } want := []resolvedAdapter{ - {vault: vault0, signer: signer0, collateral: asset0}, - {vault: vault1, signer: signer1, collateral: asset1}, + {vault: vault0, signer: signer0, collateral: asset0, authorized: true}, + {vault: vault1, signer: signer1, collateral: asset1, authorized: true}, } 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()) + if got[i].vault != w.vault || got[i].signer != w.signer || + got[i].collateral != w.collateral || got[i].authorized != w.authorized { + t.Errorf("adapter %d = {vault:%s signer:%s collateral:%s authorized:%v}, want {vault:%s signer:%s collateral:%s authorized:%v}", + i, got[i].vault.Hex(), got[i].signer.Hex(), got[i].collateral.Hex(), got[i].authorized, + w.vault.Hex(), w.signer.Hex(), w.collateral.Hex(), w.authorized) + } + } +} + +func TestResolveAdapters_RejectsUnexpectedMulticallResultCounts(t *testing.T) { + t.Parallel() + + adapterAddr := common.HexToAddress("0x00000000000000000000000000000000000000A0") + vault := common.HexToAddress("0x00000000000000000000000000000000000000B0") + signer := common.HexToAddress("0x00000000000000000000000000000000000000C0") + + t.Run("adapter fields", func(t *testing.T) { + t.Parallel() + shortRound := abiEncodeAggregate3Results(t, abiEncodeAddress(t, vault)) + c, stop := newMulticallFakeClient(t, shortRound) + defer stop() + + if _, err := newReader(c, common.Address{}).resolveAdapters(t.Context(), []common.Address{adapterAddr}, testProbe); err == nil { + t.Fatal("expected an error for an incomplete adapter-field response") + } + }) + + t.Run("assets", func(t *testing.T) { + t.Parallel() + fieldsRound := abiEncodeAggregate3Results(t, abiEncodeAddress(t, vault), abiEncodeAddress(t, signer), abiEncodeBytes4(t, erc1271MagicValue)) + emptyAssetRound := abiEncodeAggregate3Results(t) + c, stop := newMulticallFakeClient(t, fieldsRound, emptyAssetRound) + defer stop() + + if _, err := newReader(c, common.Address{}).resolveAdapters(t.Context(), []common.Address{adapterAddr}, testProbe); err == nil { + t.Fatal("expected an error for an incomplete asset response") } + }) +} + +// TestResolveAdaptersDropsUnauthorized verifies an adapter whose isValidSignature returns a non-magic +// value is marked unauthorized and has no collateral read (round 2 only queries the authorized vault). +func TestResolveAdaptersDropsUnauthorized(t *testing.T) { + t.Parallel() + + 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") + + round1 := abiEncodeAggregate3Results(t, + abiEncodeAddress(t, vault0), abiEncodeAddress(t, signer0), abiEncodeBytes4(t, erc1271MagicValue), + abiEncodeAddress(t, vault1), abiEncodeAddress(t, signer1), abiEncodeBytes4(t, [4]byte{0xff, 0xff, 0xff, 0xff}), + ) + // Only the authorized adapter's vault gets an asset() call in round 2. + round2 := abiEncodeAggregate3Results(t, abiEncodeAddress(t, asset0)) + + c, stop := newMulticallFakeClient(t, round1, round2) + defer stop() + + got, err := newReader(c, common.Address{}).resolveAdapters(context.Background(), adapters, testProbe) + if err != nil { + t.Fatalf("resolveAdapters: %v", err) + } + if got[0].err != nil || !got[0].authorized || got[0].collateral != asset0 { + t.Errorf("adapter 0 = {authorized:%v collateral:%s err:%v}, want authorized with collateral %s", + got[0].authorized, got[0].collateral.Hex(), got[0].err, asset0.Hex()) + } + if got[1].authorized || got[1].collateral != (common.Address{}) { + t.Errorf("adapter 1 = {authorized:%v collateral:%s}, want unauthorized with no collateral", + got[1].authorized, got[1].collateral.Hex()) + } + if got[1].signer != signer1 { + t.Errorf("adapter 1 signer = %s, want %s (kept for diagnostics)", got[1].signer.Hex(), signer1.Hex()) } } diff --git a/internal/solvers/bridgefacilitator/config.go b/internal/solvers/bridgefacilitator/config.go index 3c8ad891..22d7813d 100644 --- a/internal/solvers/bridgefacilitator/config.go +++ b/internal/solvers/bridgefacilitator/config.go @@ -15,12 +15,15 @@ import ( // rawConfig mirrors the YAML shape; strings are parsed into typed values in parse(). type rawConfig struct { - APIBaseURL string `yaml:"apiBaseUrl"` - RedeemBatchSize int `yaml:"redeemBatchSize"` - Adapters []string `yaml:"adapters"` - HTTPTimeout string `yaml:"httpTimeout"` - Intervals rawIntervals `yaml:"intervals"` - Strategy rawStrategyConfig `yaml:"strategy"` + APIBaseURL string `yaml:"apiBaseUrl"` + RedeemBatchSize int `yaml:"redeemBatchSize"` + Adapters *[]string `yaml:"adapters"` + AdapterFactory string `yaml:"adapterFactory"` + LiquidityLens string `yaml:"liquidityLens"` + HTTPTimeout string `yaml:"httpTimeout"` + OfferExpiryBuffer string `yaml:"offerExpiryBuffer"` + Intervals rawIntervals `yaml:"intervals"` + Strategy rawStrategyConfig `yaml:"strategy"` } type rawStrategyConfig struct { @@ -42,10 +45,19 @@ type Config struct { // 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 - // Targets is the list of vault+adapter pairs this facilitator serves. - Targets []Target - Intervals Intervals - Strategy StrategyConfig + // OfferExpiryBuffer is added to an auction's solve_start_time to set a signed offer's expiration, so + // the offer stays valid through the whole solve window regardless of when it is signed. + OfferExpiryBuffer time.Duration + // Targets is the configured static adapter set. Nil means adapters was omitted and the factory + // should be discovered; a non-nil slice is authoritative. + Targets []Target + AdapterFactory common.Address + // LiquidityLens is the optional FrontendLiquidityLens address. When set, adapter funding headroom is + // read from the lens's cross-adapter deallocation-cascade estimate instead of each adapter's own + // getMaxAssets(); zero-value falls back to the adapter getter. + LiquidityLens common.Address + Intervals Intervals + Strategy StrategyConfig } type StrategyConfig struct { @@ -53,9 +65,9 @@ type StrategyConfig struct { Config yaml.Node } -// 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. +// Target is one adapter the bot facilitates. Only static adapter addresses are config: Vault +// (adapter.vault()) and Collateral (vault.asset()) are resolved on-chain on every adapter refresh; +// 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. @@ -72,7 +84,7 @@ type Intervals struct { // Default loop cadences (used when a field is unset). const ( - defaultDiscover = time.Hour + defaultDiscover = 5 * time.Minute defaultRedeemPoll = 5 * time.Minute defaultReconcile = 15 * time.Minute ) @@ -83,6 +95,10 @@ const defaultRedeemBatchSize = 10 // defaultHTTPTimeout bounds each 3F API call when httpTimeout is unset. const defaultHTTPTimeout = 30 * time.Second +// defaultOfferExpiryBuffer is the solve_start_time margin applied to a signed offer's expiration when +// offerExpiryBuffer is unset — long enough to cover a full auction solve window plus slack. +const defaultOfferExpiryBuffer = 2 * time.Hour + const defaultStrategyName = "default" // parseConfig decodes and validates the opaque solver config block. @@ -104,6 +120,23 @@ func parseConfig(node yaml.Node) (*Config, error) { if err != nil { return nil, err } + var adapterFactory common.Address + if raw.AdapterFactory != "" { + adapterFactory, err = cfgparse.NonZeroAddress(raw.AdapterFactory, "adapterFactory") + if err != nil { + return nil, err + } + } + if len(targets) == 0 && adapterFactory == (common.Address{}) { + return nil, errors.New("at least one adapters entry or adapterFactory is required") + } + var liquidityLens common.Address + if raw.LiquidityLens != "" { + liquidityLens, err = cfgparse.NonZeroAddress(raw.LiquidityLens, "liquidityLens") + if err != nil { + return nil, err + } + } discover, err := cfgparse.Duration(raw.Intervals.Discover, defaultDiscover, "intervals.discover") if err != nil { @@ -123,27 +156,35 @@ func parseConfig(node yaml.Node) (*Config, error) { return nil, err } + offerExpiryBuffer, err := cfgparse.Duration(raw.OfferExpiryBuffer, defaultOfferExpiryBuffer, "offerExpiryBuffer") + 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, - RedeemBatchSize: redeemBatch, - HTTPTimeout: httpTimeout, - Targets: targets, - Intervals: Intervals{Discover: discover, RedeemPoll: redeemPoll, Reconcile: reconcile}, - Strategy: strategy, + APIBaseURL: raw.APIBaseURL, + RedeemBatchSize: redeemBatch, + HTTPTimeout: httpTimeout, + OfferExpiryBuffer: offerExpiryBuffer, + Targets: targets, + AdapterFactory: adapterFactory, + LiquidityLens: liquidityLens, + Intervals: Intervals{Discover: discover, RedeemPoll: redeemPoll, Reconcile: reconcile}, + Strategy: strategy, }, nil } func parseTargets(raw rawConfig) ([]Target, error) { - if len(raw.Adapters) == 0 { - return nil, errors.New("at least one adapters entry is required") + if raw.Adapters == nil { + return nil, nil } - targets := make([]Target, 0, len(raw.Adapters)) - for i, a := range raw.Adapters { + 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 diff --git a/internal/solvers/bridgefacilitator/config_test.go b/internal/solvers/bridgefacilitator/config_test.go index 54377b04..a7ae3020 100644 --- a/internal/solvers/bridgefacilitator/config_test.go +++ b/internal/solvers/bridgefacilitator/config_test.go @@ -2,6 +2,7 @@ package bridgefacilitator import ( "testing" + "time" "github.com/ethereum/go-ethereum/common" "gopkg.in/yaml.v3" @@ -35,6 +36,11 @@ adapters: - "0x0000000000000000000000000000000000000002" ` +const oneFactory = ` +apiBaseUrl: https://bf.example +adapterFactory: "0x0000000000000000000000000000000000000003" +` + func TestParseConfig_RedeemBatchSizeDefaults(t *testing.T) { cfg := mustParse(t, oneTarget) if cfg.RedeemBatchSize != defaultRedeemBatchSize { @@ -52,6 +58,20 @@ func TestParseConfig_RedeemBatchSizeOverride(t *testing.T) { } } +func TestParseConfig_OfferExpiryBufferDefaults(t *testing.T) { + cfg := mustParse(t, oneTarget) + if cfg.OfferExpiryBuffer != defaultOfferExpiryBuffer { + t.Fatalf("offerExpiryBuffer = %s, want default %s", cfg.OfferExpiryBuffer, defaultOfferExpiryBuffer) + } +} + +func TestParseConfig_OfferExpiryBufferOverride(t *testing.T) { + cfg := mustParse(t, oneTarget+"offerExpiryBuffer: 6h\n") + if cfg.OfferExpiryBuffer != 6*time.Hour { + t.Fatalf("offerExpiryBuffer = %s, want 6h", cfg.OfferExpiryBuffer) + } +} + func TestParseConfig_UnknownKeyRejected(t *testing.T) { var doc yaml.Node if err := yaml.Unmarshal([]byte(oneTarget+"redeemBatchSiez: 3\n"), &doc); err != nil { @@ -99,6 +119,35 @@ func TestParseConfig_AdaptersList(t *testing.T) { } } +func TestParseConfig_AdapterFactory(t *testing.T) { + cfg := mustParse(t, oneFactory) + want := common.HexToAddress("0x0000000000000000000000000000000000000003") + if cfg.AdapterFactory != want { + t.Fatalf("adapter factory = %s, want %s", cfg.AdapterFactory.Hex(), want.Hex()) + } + if cfg.Targets != nil { + t.Fatalf("static targets = %+v, want nil when adapters is omitted", cfg.Targets) + } +} + +func TestParseConfig_ExplicitEmptyAdaptersRemainAuthoritative(t *testing.T) { + cfg := mustParse(t, oneFactory+"adapters: []\n") + if cfg.Targets == nil || len(cfg.Targets) != 0 { + t.Fatalf("static targets = %+v, want a present empty list", cfg.Targets) + } +} + +func TestParseConfig_StaticAndFactorySources(t *testing.T) { + cfg := mustParse(t, oneTarget+`adapterFactory: "0x0000000000000000000000000000000000000003" +`) + if len(cfg.Targets) != 1 { + t.Fatalf("static targets = %+v, want one", cfg.Targets) + } + if cfg.AdapterFactory != common.HexToAddress("0x0000000000000000000000000000000000000003") { + t.Fatalf("adapter factory = %s", cfg.AdapterFactory.Hex()) + } +} + func TestParseConfig_Strategy(t *testing.T) { cfg, err := parse(t, oneTarget+` strategy: @@ -123,11 +172,14 @@ strategy: } } -func TestParseConfig_RejectsEmptyAndZeroAdapters(t *testing.T) { +func TestParseConfig_RejectsEmptyAndZeroAdapterSources(t *testing.T) { if _, err := parse(t, minimalConfig); err == nil { - t.Fatal("expected an error when no adapters are configured") + t.Fatal("expected an error when neither adapters nor adapterFactory is configured") } if _, err := parse(t, minimalConfig+"adapters:\n - \"0x0000000000000000000000000000000000000000\"\n"); err == nil { t.Fatal("expected an error for a zero adapter address") } + if _, err := parse(t, minimalConfig+"adapterFactory: \"0x0000000000000000000000000000000000000000\"\n"); err == nil { + t.Fatal("expected an error for a zero adapter factory address") + } } diff --git a/internal/solvers/bridgefacilitator/eip712.go b/internal/solvers/bridgefacilitator/eip712.go index 6de16b26..f10b2bea 100644 --- a/internal/solvers/bridgefacilitator/eip712.go +++ b/internal/solvers/bridgefacilitator/eip712.go @@ -118,6 +118,17 @@ func GetOffersDigest(maker common.Address, deadline, chainID *big.Int) common.Ha return crypto.Keccak256Hash([]byte{0x19, 0x01}, gruntAPIDomainSeparator(chainID).Bytes(), sh.Bytes()) } +// cancelOfferTypeHash is the EIP-712 type the maker signs to cancel an unaccepted offer via +// POST /v1/offer/cancel; the field set is checked against the live 3F API in the CancelOffer golden test. +var cancelOfferTypeHash = crypto.Keccak256Hash([]byte("CancelOffer(address maker,uint256 offerId,uint256 deadline)")) + +// CancelOfferDigest computes the EIP-712 digest a maker signs to cancel offerID over the grunt-api +// domain at chainID (the bot's operating chain, matching GetOffersDigest). +func CancelOfferDigest(maker common.Address, offerID, deadline, chainID *big.Int) common.Hash { + sh := crypto.Keccak256Hash(cancelOfferTypeHash.Bytes(), word(maker.Bytes()), word(offerID.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())) diff --git a/internal/solvers/bridgefacilitator/eip712_test.go b/internal/solvers/bridgefacilitator/eip712_test.go index dad21c05..088aa757 100644 --- a/internal/solvers/bridgefacilitator/eip712_test.go +++ b/internal/solvers/bridgefacilitator/eip712_test.go @@ -127,6 +127,63 @@ func TestGetOffersDigest_Golden(t *testing.T) { } } +func TestCancelOfferTypeHash_MatchesGolden(t *testing.T) { + // GOLDEN: keccak256 of the CancelOffer type string from the live 3F /v1/offer/cancel doc. + const want = "0xd7c02cd51344a443f4f661726f2b4637cebad4d56ac50cc0dc8ef4ed3cf684bb" + if got := cancelOfferTypeHash.Hex(); got != want { + t.Fatalf("cancelOffer typehash = %s, want %s", got, want) + } +} + +// TestCancelOfferDigest_MatchesApitypes cross-checks the hand-rolled CancelOffer digest against +// go-ethereum's independent EIP-712 implementation over the same grunt-api domain as GetOffers. +func TestCancelOfferDigest_MatchesApitypes(t *testing.T) { + maker := common.HexToAddress("0x0000000000000000000000000000000000000042") + offerID := big.NewInt(192) + deadline := big.NewInt(4102444800) + + got := CancelOfferDigest(maker, offerID, deadline, big.NewInt(apiKeyDomainChainID)) + + typed := apitypes.TypedData{ + Types: apitypes.Types{ + "EIP712Domain": { + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + }, + "CancelOffer": { + {Name: "maker", Type: "address"}, + {Name: "offerId", Type: "uint256"}, + {Name: "deadline", Type: "uint256"}, + }, + }, + PrimaryType: "CancelOffer", + Domain: apitypes.TypedDataDomain{ + Name: apiKeyDomainName, + Version: apiKeyDomainVersion, + ChainId: math.NewHexOrDecimal256(apiKeyDomainChainID), + }, + Message: apitypes.TypedDataMessage{ + "maker": maker.Hex(), + "offerId": offerID.String(), + "deadline": deadline.String(), + }, + } + domainSep, err := typed.HashStruct("EIP712Domain", typed.Domain.Map()) + if err != nil { + t.Fatalf("hash domain: %v", err) + } + msgHash, err := typed.HashStruct("CancelOffer", 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_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. diff --git a/internal/solvers/bridgefacilitator/offer.go b/internal/solvers/bridgefacilitator/offer.go index 7c101662..3556db87 100644 --- a/internal/solvers/bridgefacilitator/offer.go +++ b/internal/solvers/bridgefacilitator/offer.go @@ -12,9 +12,6 @@ import ( "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies/types" ) -// offerTTL is how long a signed offer stays valid. -const offerTTL = 30 * time.Minute - // 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( @@ -46,7 +43,7 @@ func (s *Solver) buildSignedOffer( } nonce := new(big.Int).SetUint64(s.nextNonce()) - expiration := big.NewInt(time.Now().Add(offerTTL).Unix()) + expiration := offerExpiration(av, s.cfg.OfferExpiryBuffer, time.Now()) signedOffer := Offer{ Maker: offer.Maker, @@ -75,3 +72,16 @@ func (s *Solver) buildSignedOffer( dto.SetSignature(hexutil.Encode(sig)) return *dto, nil } + +// offerExpiration anchors a signed offer's expiration to the auction's solve_start_time plus buffer. +// If the auction omits solve_start_time, the offer expires now+buffer. +// The buffer is long enough to cover a full auction solve window plus slack. +func offerExpiration(av auctionView, buffer time.Duration, now time.Time) *big.Int { + exp := now.Add(buffer) + if s, ok := av.dto.GetSolveStartTimeOk(); ok && s != nil && *s != "" { + if t, err := time.Parse(time.RFC3339, *s); err == nil { + exp = t.Add(buffer) + } + } + return big.NewInt(exp.Unix()) +} diff --git a/internal/solvers/bridgefacilitator/offer_test.go b/internal/solvers/bridgefacilitator/offer_test.go new file mode 100644 index 00000000..98531bde --- /dev/null +++ b/internal/solvers/bridgefacilitator/offer_test.go @@ -0,0 +1,55 @@ +package bridgefacilitator + +import ( + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +func TestOfferExpiration(t *testing.T) { + buffer := 2 * time.Hour + now := time.Unix(1_700_000_000, 0).UTC() + + withSolveStart := func(s string) auctionView { + dto := testAuctionDto(1, common.Address{0xaa}, "100") + if s != "" { + dto.SetSolveStartTime(s) + } + return auctionView{dto} + } + + tests := []struct { + name string + av auctionView + want int64 + }{ + { + name: "future solve start anchors expiry to solveStart+buffer", + av: withSolveStart(now.Add(time.Hour).Format(time.RFC3339)), + want: now.Add(time.Hour).Add(buffer).Unix(), + }, + { + name: "past solve start still anchors expiry to solveStart+buffer", + av: withSolveStart(now.Add(-time.Hour).Format(time.RFC3339)), + want: now.Add(-time.Hour).Add(buffer).Unix(), + }, + { + name: "missing solve start falls back to now+buffer", + av: withSolveStart(""), + want: now.Add(buffer).Unix(), + }, + { + name: "unparseable solve start falls back to now+buffer", + av: withSolveStart("not-a-timestamp"), + want: now.Add(buffer).Unix(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := offerExpiration(tt.av, buffer, now).Int64(); got != tt.want { + t.Fatalf("offerExpiration = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/internal/solvers/bridgefacilitator/offercache.go b/internal/solvers/bridgefacilitator/offercache.go index b710eded..a0a7c2e6 100644 --- a/internal/solvers/bridgefacilitator/offercache.go +++ b/internal/solvers/bridgefacilitator/offercache.go @@ -21,10 +21,10 @@ type offerState struct { 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. +// 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. It is a snapshot +// of the 3F API's live offers, rebuilt from the API before every offer pass (reconcileAdapter); Run +// goroutine only, no locking. type offerTracker struct { offers map[offerKey]offerState } @@ -45,9 +45,29 @@ func (t *offerTracker) liveEntries(now time.Time) []offerKey { 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)} +// reconcileAdapter replaces all of this adapter's cached offers with the API's current live set. The +// 1-2 minute poll is authoritative — it always reflects our own just-submitted offers as well as any +// made out of band — so anything not in `live` is gone and dropped. +func (t *offerTracker) reconcileAdapter(adapter common.Address, live map[int64]offerState) { + for k := range t.offers { + if k.adapter == adapter { + delete(t.offers, k) + } + } + for auctionID, st := range live { + t.offers[offerKey{adapter, auctionID}] = offerState{expiry: st.expiry, principal: new(big.Int).Set(st.principal)} + } +} + +// retainAdapters drops cached offers made by adapters that are no longer usable. In particular, +// rotating an adapter's offerSigner invalidates its outstanding signatures, so those offers must no +// longer reduce the amount covered by the active snapshot. +func (t *offerTracker) retainAdapters(active map[common.Address]struct{}) { + for key := range t.offers { + if _, ok := active[key.adapter]; !ok { + delete(t.offers, key) + } + } } // liveCoverage sums the principal of our unexpired offers on auctionID across every adapter — how much diff --git a/internal/solvers/bridgefacilitator/offercache_test.go b/internal/solvers/bridgefacilitator/offercache_test.go index 0b763727..631e33eb 100644 --- a/internal/solvers/bridgefacilitator/offercache_test.go +++ b/internal/solvers/bridgefacilitator/offercache_test.go @@ -8,6 +8,11 @@ import ( "github.com/ethereum/go-ethereum/common" ) +// seed inserts an offer straight into the tracker's map, standing in for a prior reconcile. +func seed(tr *offerTracker, adapter common.Address, auction int64, expiry time.Time, principal int64) { + tr.offers[offerKey{adapter, auction}] = offerState{expiry: expiry, principal: big.NewInt(principal)} +} + func TestOfferTracker(t *testing.T) { tr := newOfferTracker() now := time.Unix(1_000_000, 0) @@ -27,7 +32,7 @@ func TestOfferTracker(t *testing.T) { t.Fatal("empty tracker should report no live offers") } - tr.record(adapterA, 42, now.Add(30*time.Minute), big.NewInt(100)) + seed(tr, adapterA, 42, now.Add(30*time.Minute), 100) if !live(now, adapterA, 42) { t.Fatal("offer should be live before expiry") } @@ -54,9 +59,9 @@ func TestOfferTrackerLiveCoverage(t *testing.T) { } // 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 + seed(tr, adapterA, 42, now.Add(30*time.Minute), 100) + seed(tr, adapterB, 42, now.Add(30*time.Minute), 60) + seed(tr, adapterA, 7, now.Add(30*time.Minute), 999) // other auction, excluded if got := tr.liveCoverage(42, now); got.Cmp(big.NewInt(160)) != 0 { t.Fatalf("coverage = %s, want 160", got) } @@ -67,6 +72,58 @@ func TestOfferTrackerLiveCoverage(t *testing.T) { } } +// TestOfferTrackerReconcileAdapter covers the wholesale replace: an adapter's cached offers are dropped +// and rebuilt from the API's live set, while other adapters are left untouched. +func TestOfferTrackerReconcileAdapter(t *testing.T) { + now := time.Unix(1_000_000, 0) + exp := now.Add(time.Hour) + adapterA := common.Address{0xAA} + adapterB := common.Address{0xBB} + + // Seed: A holds offers on auctions 1, 2, 3; B holds one on auction 1 that A's reconcile must not touch. + tr := newOfferTracker() + seed(tr, adapterA, 1, exp, 100) + seed(tr, adapterA, 2, exp, 200) + seed(tr, adapterA, 3, exp, 300) + seed(tr, adapterB, 1, exp, 999) + + // API for adapter A now lists only auctions 1 (new principal/expiry) and 4. Auctions 2 and 3 are gone. + newExp := now.Add(2 * time.Hour) + live := map[int64]offerState{ + 1: {expiry: newExp, principal: big.NewInt(150)}, + 4: {expiry: newExp, principal: big.NewInt(400)}, + } + tr.reconcileAdapter(adapterA, live) + + want := map[offerKey]*big.Int{ + {adapterA, 1}: big.NewInt(150), // refreshed from API + {adapterA, 4}: big.NewInt(400), // new live offer inserted + {adapterB, 1}: big.NewInt(999), // other adapter untouched + } + if len(tr.offers) != len(want) { + t.Fatalf("offers = %v, want %d entries", tr.offers, len(want)) + } + for k, wantPrincipal := range want { + st, ok := tr.offers[k] + if !ok { + t.Fatalf("missing entry %v", k) + } + if st.principal.Cmp(wantPrincipal) != 0 { + t.Fatalf("%v principal = %s, want %s", k, st.principal, wantPrincipal) + } + } + if _, ok := tr.offers[offerKey{adapterA, 2}]; ok { + t.Fatal("auction 2 is no longer live and must be cleared") + } + if _, ok := tr.offers[offerKey{adapterA, 3}]; ok { + t.Fatal("auction 3 is no longer live and must be cleared") + } + // The refreshed entry must carry the API's expiry, not the stale one. + if got := tr.offers[offerKey{adapterA, 1}].expiry; !got.Equal(newExp) { + t.Fatalf("auction 1 expiry = %s, want %s", got, newExp) + } +} + func TestParseUnixTime(t *testing.T) { got, err := parseUnixTime("4102444800") if err != nil { diff --git a/internal/solvers/bridgefacilitator/solver.go b/internal/solvers/bridgefacilitator/solver.go index c7ffa5a2..c4753850 100644 --- a/internal/solvers/bridgefacilitator/solver.go +++ b/internal/solvers/bridgefacilitator/solver.go @@ -20,11 +20,14 @@ import ( "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. +// offerStatusIgnored are 3F offer statuses that are not live coverage when reconciling the cache: a +// FAILED consume, a NOT_ACCEPTED bid, or a CANCELLED offer won't cover the auction, so discovery should +// re-offer. var offerStatusIgnored = map[string]bool{ "FAILED": true, "NOT_ACCEPTED": true, + "CANCELLED": true, + "CANCELED": true, } // Name is the registry key that selects this solver from config. @@ -43,9 +46,24 @@ type Solver struct { reader *reader strategy types.Strategy log logr.Logger - signerAddr common.Address // the solver's own EIP-1271 signer address, set in factory + signerAddr common.Address // the solver's own signer address (diagnostics only), set in factory + probe signerProbe // one-time (hash, sig) used to validate offer-signer authorization, set in factory nonceSeq atomic.Uint64 offers *offerTracker // dedup: (adapter, auction) pairs we hold a live offer for (Run goroutine only) + targets []Target // current resolved snapshot; owned exclusively by the Run goroutine +} + +func deduplicateAdapters(adapters []common.Address) []common.Address { + unique := make([]common.Address, 0, len(adapters)) + seen := make(map[common.Address]struct{}, len(adapters)) + for _, adapter := range adapters { + if _, ok := seen[adapter]; ok { + continue + } + seen[adapter] = struct{}{} + unique = append(unique, adapter) + } + return unique } func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { @@ -60,14 +78,20 @@ func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { return nil, err } + probe, err := newSignerProbe(deps.Signer) + if err != nil { + return nil, err + } + s := &Solver{ cfg: cfg, deps: deps, api: api, - reader: newReader(deps.Chain), + reader: newReader(deps.Chain, cfg.LiquidityLens), strategy: offerStrategy, log: deps.Log.WithName(Name), signerAddr: deps.Signer.Address(), + probe: probe, offers: newOfferTracker(), } // Seed the offer nonce sequence from the wall clock so it stays monotonic across restarts. @@ -81,24 +105,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 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 { + // Build the initial explicit-or-factory snapshot. A successfully empty factory is valid: the + // daemon stays alive and picks up future entities on a discovery tick. + if err := s.refreshTargetsAndHydrate(ctx); err != nil { return err } + // Preserve the explicit-list fail-closed startup contract. Factory-discovered deployments may + // start empty because later registry entries are expected. + if s.cfg.Targets != nil && len(s.targets) == 0 { + return errors.Errorf("no configured adapter passed startup validation (must resolve and accept this solver %s as an authorized offer signer via ERC-1271); see per-adapter warnings above", s.signerAddr.Hex()) + } s.log.Info("starting", - "adapters", len(s.cfg.Targets), + "adapters", len(s.targets), "apiBaseUrl", s.cfg.APIBaseURL, "discover", s.cfg.Intervals.Discover.String(), ) - // 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) reconcileT := time.NewTicker(s.cfg.Intervals.Reconcile) @@ -115,6 +138,9 @@ func (s *Solver) Run(ctx context.Context) error { case <-ctx.Done(): return ctx.Err() case <-discoverT.C: + if err := s.refreshTargetsAndHydrate(ctx); err != nil { + s.log.Error(err, "refresh adapters; keeping last-known-good targets") + } s.discoverAndOffer(ctx) case <-redeemT.C: s.redeemAll(ctx) @@ -124,37 +150,40 @@ func (s *Solver) Run(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) { +// reconcileOffers re-lists each target adapter's live offers from the 3F API and replaces that adapter's +// offer cache with them, so coverage reflects our own offers and any made out of band. The poll is +// authoritative. Best-effort: one adapter's list failure can't block the pass (its cache is left as-is). +func (s *Solver) reconcileOffers(ctx context.Context, targets []Target) { now := time.Now() - live := 0 - for _, t := range s.cfg.Targets { + for _, t := range 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()) + s.log.Error(err, "reconcile offers: list offers", "adapter", t.Adapter.Hex()) continue } + live := make(map[int64]offerState) 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 + continue // failed/not-accepted/cancelled offers aren't live coverage } exp, perr := parseUnixTime(o.Expiration) if perr != nil || !exp.After(now) { - continue // unparseable or already expired — we may freely re-offer + continue // unparseable or already expired } principal, ok := new(big.Int).SetString(o.Amount, 10) if !ok { - s.log.V(1).Info("offer cache: unparseable amount; coverage may undercount", + s.log.V(1).Info("reconcile offers: 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++ + // One live offer per (adapter, auction) is assumed; if the API ever lists more, keep the latest. + auctionID := int64(o.AuctionId) + if cur, exists := live[auctionID]; !exists || exp.After(cur.expiry) { + live[auctionID] = offerState{expiry: exp, principal: principal} + } } + s.offers.reconcileAdapter(t.Adapter, live) } - s.log.Info("loaded existing offers into dedup cache", "live", live) } // adapterOffering tracks one adapter's liquidity/exposure snapshot for one offer pass. @@ -166,6 +195,9 @@ type adapterOffering struct { // 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 len(s.targets) == 0 { + return + } auctions, err := s.api.listAuctions(ctx) if err != nil { s.log.Error(err, "discover: list auctions") @@ -173,8 +205,11 @@ func (s *Solver) discoverAndOffer(ctx context.Context) { } s.log.V(1).Info("discovered auctions", "count", len(auctions)) - offerings := make([]*adapterOffering, 0, len(s.cfg.Targets)) - for _, t := range s.cfg.Targets { + // Rebuild coverage from the live API before deciding, so out-of-band offers count and we don't double-offer. + s.reconcileOffers(ctx, s.targets) + + offerings := make([]*adapterOffering, 0, len(s.targets)) + for _, t := range s.targets { st, lerr := s.reader.liquidityAndExposure(ctx, t.Adapter) if lerr != nil { s.log.Error(lerr, "offer: liquidity/exposure", "adapter", t.Adapter.Hex()) @@ -183,7 +218,7 @@ func (s *Solver) discoverAndOffer(ctx context.Context) { 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()) + "minYieldPpm", st.minYieldPpm.String()) offerings = append(offerings, &adapterOffering{target: t, st: st}) } if len(offerings) == 0 { @@ -201,6 +236,13 @@ func (s *Solver) discoverAndOffer(ctx context.Context) { s.log.Error(err, "offer: strategy") return } + // minYieldByAdapter lets the submission loop validate EVERY strategy's offers (default and webhook), + // not just the default strategy's pricing, against the adapter's exact on-chain minYieldPerRequest. + minYieldByAdapter := make(map[common.Address]*big.Int, len(offerings)) + for _, o := range offerings { + minYieldByAdapter[o.target.Adapter] = o.st.minYieldPpm + } + auctionByID := auctionViewsByID(auctions) for _, offer := range out.Offers { av, ok := auctionByID[offer.AuctionID] @@ -208,6 +250,25 @@ func (s *Solver) discoverAndOffer(ctx context.Context) { s.log.Error(errors.Errorf("auction %d not found", offer.AuctionID), "offer: build") continue } + floor, known := minYieldByAdapter[offer.Maker] + if !known { + s.log.Error(errors.Errorf("offer for adapter %s absent from this pass's snapshot", offer.Maker.Hex()), + "offer: unknown maker; skipping", "auctionId", offer.AuctionID) + continue + } + maxRate, rateOk := av.maxRateBps() + if !rateOk { + s.log.Error(errors.Errorf("auction %d has no resolved maxRate", offer.AuctionID), + "offer: unbiddable auction; skipping", "adapter", offer.Maker.Hex()) + continue + } + // Backstop for all strategies: the offer must clear the on-chain floor and stay under the auction + // max rate, or it reverts (FAILED) / is rejected (NOT_ACCEPTED). Also guards nil/invalid amounts. + if err := types.ValidateYield(offer.ExpectedReturn, offer.Principal, floor, maxRate); err != nil { + s.log.Error(err, "offer: yield out of bounds; skipping", + "auctionId", offer.AuctionID, "adapter", offer.Maker.Hex()) + continue + } dto, buildErr := s.buildSignedOffer(av, offer) if buildErr != nil { s.log.Error(buildErr, "offer: build", "auctionId", offer.AuctionID, "adapter", offer.Maker.Hex()) @@ -217,10 +278,7 @@ func (s *Solver) discoverAndOffer(ctx context.Context) { s.log.Error(subErr, "offer: submit", "auctionId", offer.AuctionID, "adapter", offer.Maker.Hex()) continue } - - if exp, perr := parseUnixTime(dto.Expiration); perr == nil { - s.offers.record(offer.Maker, offer.AuctionID, exp, offer.Principal) - } + // No local record: the next reconcile re-lists this offer from the API (the poll is authoritative). s.log.Info("offer submitted", "auctionId", offer.AuctionID, "adapter", offer.Maker.Hex(), "request", offer.Request.Hex(), "principal", offer.Principal.String(), "expectedReturn", dto.ExpectedReturn) } @@ -228,14 +286,14 @@ func (s *Solver) discoverAndOffer(ctx context.Context) { // redeemAll runs the redeemer for every matched adapter. func (s *Solver) redeemAll(ctx context.Context) { - for _, t := range s.cfg.Targets { + for _, t := range s.targets { s.redeemReady(ctx, t) } } // reconcile reports each adapter's live open-position set — a stateless health/observability tick. func (s *Solver) reconcile(ctx context.Context) { - for _, t := range s.cfg.Targets { + for _, t := range s.targets { st, err := s.reader.liquidityAndExposure(ctx, t.Adapter) if err != nil { s.log.Error(err, "reconcile", "adapter", t.Adapter.Hex()) @@ -251,43 +309,76 @@ func (s *Solver) nextNonce() uint64 { return s.nonceSeq.Add(1) } -// 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 +// refreshTargets builds and validates a complete adapter snapshot before installing it. A returned +// error leaves the last-known-good snapshot untouched; a successful empty snapshot is authoritative. +func (s *Solver) refreshTargetsAndHydrate(ctx context.Context) error { + added, err := s.refreshTargets(ctx) + if err != nil { + return err + } + s.reconcileOffers(ctx, added) // hydrate the newly-added adapters' live offers + return nil +} + +func (s *Solver) refreshTargets(ctx context.Context) ([]Target, error) { + var adapters []common.Address + if s.cfg.Targets != nil { + adapters = make([]common.Address, len(s.cfg.Targets)) + for i := range s.cfg.Targets { + adapters[i] = s.cfg.Targets[i].Adapter + } + } else { + var err error + adapters, err = s.reader.factoryAdapters(ctx, s.cfg.AdapterFactory) + if err != nil { + return nil, err + } + } + adapters = deduplicateAdapters(adapters) + if len(adapters) == 0 { + s.offers.retainAdapters(nil) + s.targets = nil + return nil, nil } - resolved, err := s.reader.resolveAdapters(ctx, adapters) + + resolved, err := s.reader.resolveAdapters(ctx, adapters, s.probe) if err != nil { - return err // whole-batch transport/RPC failure, not a per-adapter revert + return nil, err + } + previous := make(map[common.Address]struct{}, len(s.targets)) + for _, target := range s.targets { + previous[target.Adapter] = struct{}{} } - kept := make([]Target, 0, len(s.cfg.Targets)) - for i, t := range s.cfg.Targets { + kept := make([]Target, 0, len(adapters)) + added := make([]Target, 0, len(adapters)) + for i, adapterAddr := range adapters { r := resolved[i] if r.err != nil { - s.log.Error(r.err, "skipping adapter: resolution failed", "adapter", t.Adapter.Hex()) + s.log.Error(r.err, "skipping adapter: resolution failed", "adapter", adapterAddr.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()) + if !r.authorized { + s.log.Info("skipping adapter: solver is not an authorized offer signer", + "adapter", adapterAddr.Hex(), + "solver", s.signerAddr.Hex(), + "offerSigner", r.signer.Hex()) continue } - t.Vault, t.Collateral = r.vault, r.collateral + target := Target{Adapter: adapterAddr, Vault: r.vault, Collateral: r.collateral} + kept = append(kept, target) + if _, ok := previous[adapterAddr]; !ok { + added = append(added, target) + } s.log.Info("resolved target", - "adapter", t.Adapter.Hex(), "vault", r.vault.Hex(), "collateral", r.collateral.Hex()) - kept = append(kept, t) + "adapter", adapterAddr.Hex(), "vault", r.vault.Hex(), "collateral", r.collateral.Hex()) } - 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()) + active := make(map[common.Address]struct{}, len(kept)) + for _, target := range kept { + active[target.Adapter] = struct{}{} } - return nil + s.offers.retainAdapters(active) + s.targets = kept + return added, nil } diff --git a/internal/solvers/bridgefacilitator/solver_test.go b/internal/solvers/bridgefacilitator/solver_test.go new file mode 100644 index 00000000..f944ec6a --- /dev/null +++ b/internal/solvers/bridgefacilitator/solver_test.go @@ -0,0 +1,328 @@ +package bridgefacilitator + +import ( + "context" + "errors" + "math/big" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" +) + +func TestDeduplicateAdapters_PreservesSourceOrder(t *testing.T) { + t.Parallel() + + a := common.HexToAddress("0x00000000000000000000000000000000000000A0") + b := common.HexToAddress("0x00000000000000000000000000000000000000B0") + c := common.HexToAddress("0x00000000000000000000000000000000000000C0") + got := deduplicateAdapters([]common.Address{a, b, a, c, b}) + want := []common.Address{a, b, c} + if len(got) != len(want) { + t.Fatalf("deduplicated adapters = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("deduplicated adapter %d = %s, want %s", i, got[i].Hex(), want[i].Hex()) + } + } +} + +func TestRefreshTargets_ExplicitAdaptersSkipFactoryDiscovery(t *testing.T) { + t.Parallel() + + adapterAddr := common.HexToAddress("0x00000000000000000000000000000000000000A0") + vault := common.HexToAddress("0x00000000000000000000000000000000000000B0") + signer := common.HexToAddress("0x00000000000000000000000000000000000000C0") + asset := common.HexToAddress("0x00000000000000000000000000000000000000D0") + c, stop := newMulticallFakeClient(t, + abiEncodeAggregate3Results(t, abiEncodeAddress(t, vault), abiEncodeAddress(t, signer), abiEncodeBytes4(t, erc1271MagicValue)), + abiEncodeAggregate3Results(t, abiEncodeAddress(t, asset)), + ) + defer stop() + + s := &Solver{ + cfg: &Config{ + Targets: []Target{{Adapter: adapterAddr}}, + AdapterFactory: common.HexToAddress("0x00000000000000000000000000000000000000F0"), + }, + reader: newReader(c, common.Address{}), + log: logr.Discard(), + signerAddr: signer, + offers: newOfferTracker(), + } + added, err := s.refreshTargets(t.Context()) + if err != nil { + t.Fatalf("refreshTargets: %v", err) + } + if len(added) != 1 || len(s.targets) != 1 || s.targets[0].Adapter != adapterAddr { + t.Fatalf("refresh added=%v targets=%v, want only configured adapter %s", added, s.targets, adapterAddr.Hex()) + } +} + +func TestRefreshTargets_RetainsLastKnownGoodOnWholeRefreshFailure(t *testing.T) { + t.Parallel() + + adapterAddr := common.HexToAddress("0x00000000000000000000000000000000000000A0") + vault := common.HexToAddress("0x00000000000000000000000000000000000000B0") + signer := common.HexToAddress("0x00000000000000000000000000000000000000C0") + asset := common.HexToAddress("0x00000000000000000000000000000000000000D0") + round1 := abiEncodeAggregate3Results(t, abiEncodeAddress(t, vault), abiEncodeAddress(t, signer), abiEncodeBytes4(t, erc1271MagicValue)) + round2 := abiEncodeAggregate3Results(t, abiEncodeAddress(t, asset)) + c, stop := newMulticallFakeClient(t, round1, round2, []byte{0x01}) + defer stop() + + s := &Solver{ + cfg: &Config{Targets: []Target{{Adapter: adapterAddr}}}, + reader: newReader(c, common.Address{}), + log: logr.Discard(), + signerAddr: signer, + offers: newOfferTracker(), + } + added, err := s.refreshTargets(t.Context()) + if err != nil { + t.Fatalf("first refresh: %v", err) + } + if len(added) != 1 || len(s.targets) != 1 || s.targets[0].Adapter != adapterAddr { + t.Fatalf("first refresh added=%v targets=%v", added, s.targets) + } + now := time.Now() + seed(s.offers, adapterAddr, 42, now.Add(time.Hour), 100) + + if _, err := s.refreshTargets(t.Context()); err == nil { + t.Fatal("expected the second refresh to fail") + } + if len(s.targets) != 1 || s.targets[0].Adapter != adapterAddr { + t.Fatalf("targets after failed refresh = %v, want last-known-good adapter", s.targets) + } + if got := s.offers.liveCoverage(42, now); got.Cmp(big.NewInt(100)) != 0 { + t.Fatalf("offer coverage after failed refresh = %s, want last-known-good 100", got) + } +} + +func TestRefreshTargets_RemovesAndReaddsWhenSignerEligibilityChanges(t *testing.T) { + t.Parallel() + + adapterAddr := common.HexToAddress("0x00000000000000000000000000000000000000A0") + vault := common.HexToAddress("0x00000000000000000000000000000000000000B0") + signer := common.HexToAddress("0x00000000000000000000000000000000000000C0") + otherSigner := common.HexToAddress("0x00000000000000000000000000000000000000C1") + asset := common.HexToAddress("0x00000000000000000000000000000000000000D0") + matching := abiEncodeAggregate3Results(t, abiEncodeAddress(t, vault), abiEncodeAddress(t, signer), abiEncodeBytes4(t, erc1271MagicValue)) + notMatching := abiEncodeAggregate3Results(t, abiEncodeAddress(t, vault), abiEncodeAddress(t, otherSigner), abiEncodeBytes4(t, [4]byte{0xff, 0xff, 0xff, 0xff})) + assetRound := abiEncodeAggregate3Results(t, abiEncodeAddress(t, asset)) + c, stop := newMulticallFakeClient(t, + matching, assetRound, + notMatching, // unauthorized: no asset round is issued + matching, assetRound, + matching, assetRound, + ) + defer stop() + + s := &Solver{ + cfg: &Config{Targets: []Target{{Adapter: adapterAddr}}}, + reader: newReader(c, common.Address{}), + log: logr.Discard(), + signerAddr: signer, + offers: newOfferTracker(), + } + added, err := s.refreshTargets(t.Context()) + if err != nil || len(added) != 1 || len(s.targets) != 1 { + t.Fatalf("initial refresh added=%v targets=%v err=%v", added, s.targets, err) + } + now := time.Now() + seed(s.offers, adapterAddr, 42, now.Add(time.Hour), 100) + added, err = s.refreshTargets(t.Context()) + if err != nil || len(added) != 0 || len(s.targets) != 0 { + t.Fatalf("removal refresh added=%v targets=%v err=%v", added, s.targets, err) + } + if got := s.offers.liveCoverage(42, now); got.Sign() != 0 { + t.Fatalf("removed adapter still contributes live coverage: %s", got) + } + added, err = s.refreshTargets(t.Context()) + if err != nil || len(added) != 1 || len(s.targets) != 1 { + t.Fatalf("re-add refresh added=%v targets=%v err=%v", added, s.targets, err) + } + added, err = s.refreshTargets(t.Context()) + if err != nil || len(added) != 0 || len(s.targets) != 1 { + t.Fatalf("unchanged refresh added=%v targets=%v err=%v", added, s.targets, err) + } + if len(s.cfg.Targets) != 1 || s.cfg.Targets[0].Adapter != adapterAddr { + t.Fatalf("static source mutated across refreshes: %v", s.cfg.Targets) + } +} + +func TestRun_AllowsEmptyFactorySnapshotAtStartup(t *testing.T) { + t.Parallel() + + countRound := abiEncodeAggregate3Results(t, abiEncodeUint256(t, 0)) + c, stop := newMulticallFakeClient(t, countRound) + defer stop() + + s := &Solver{ + cfg: &Config{ + AdapterFactory: common.HexToAddress("0x00000000000000000000000000000000000000F0"), + Intervals: Intervals{ + Discover: 5 * time.Millisecond, + RedeemPoll: 5 * time.Millisecond, + Reconcile: 5 * time.Millisecond, + }, + }, + reader: newReader(c, common.Address{}), + log: logr.Discard(), + offers: newOfferTracker(), + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + if err := s.Run(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Run returned %v, want context deadline after staying alive", err) + } +} + +func TestRun_ExplicitEmptyAdaptersSkipFactoryDiscoveryAndFailStartup(t *testing.T) { + t.Parallel() + + factoryAddr := common.HexToAddress("0x00000000000000000000000000000000000000F0") + signer := common.HexToAddress("0x00000000000000000000000000000000000000C0") + countRound := abiEncodeAggregate3Results(t, abiEncodeUint256(t, 1)) + c, stop := newMulticallFakeClient(t, countRound) + defer stop() + + s := &Solver{ + cfg: &Config{Targets: []Target{}, AdapterFactory: factoryAddr}, + reader: newReader(c, common.Address{}), + log: logr.Discard(), + signerAddr: signer, + offers: newOfferTracker(), + } + want := "no configured adapter passed startup validation (must resolve and accept this solver " + signer.Hex() + " as an authorized offer signer via ERC-1271); see per-adapter warnings above" + if err := s.Run(t.Context()); err == nil || err.Error() != want { + t.Fatalf("Run error = %v, want %q", err, want) + } +} + +func TestRun_StaticOnlyStillFailsStartupWhenNoAdapterPassesValidation(t *testing.T) { + t.Parallel() + + adapterAddr := common.HexToAddress("0x00000000000000000000000000000000000000A0") + vault := common.HexToAddress("0x00000000000000000000000000000000000000B0") + signer := common.HexToAddress("0x00000000000000000000000000000000000000C0") + otherSigner := common.HexToAddress("0x00000000000000000000000000000000000000C1") + c, stop := newMulticallFakeClient(t, + // Unauthorized (isValidSignature non-magic): the adapter is dropped, so no asset round is issued. + abiEncodeAggregate3Results(t, abiEncodeAddress(t, vault), abiEncodeAddress(t, otherSigner), abiEncodeBytes4(t, [4]byte{0xff, 0xff, 0xff, 0xff})), + ) + defer stop() + + s := &Solver{ + cfg: &Config{Targets: []Target{{Adapter: adapterAddr}}}, + reader: newReader(c, common.Address{}), + log: logr.Discard(), + signerAddr: signer, + offers: newOfferTracker(), + } + want := "no configured adapter passed startup validation (must resolve and accept this solver " + signer.Hex() + " as an authorized offer signer via ERC-1271); see per-adapter warnings above" + if err := s.Run(t.Context()); err == nil || err.Error() != want { + t.Fatalf("Run error = %v, want %q", err, want) + } +} + +func TestRefreshTargetsAndHydrate_HydratesOnlyNewlyUsableAdapters(t *testing.T) { + t.Parallel() + + adapterAddr := common.HexToAddress("0x00000000000000000000000000000000000000A0") + vault := common.HexToAddress("0x00000000000000000000000000000000000000B0") + signer := common.HexToAddress("0x00000000000000000000000000000000000000C0") + otherSigner := common.HexToAddress("0x00000000000000000000000000000000000000C1") + asset := common.HexToAddress("0x00000000000000000000000000000000000000D0") + matching := abiEncodeAggregate3Results(t, abiEncodeAddress(t, vault), abiEncodeAddress(t, signer), abiEncodeBytes4(t, erc1271MagicValue)) + notMatching := abiEncodeAggregate3Results(t, abiEncodeAddress(t, vault), abiEncodeAddress(t, otherSigner), abiEncodeBytes4(t, [4]byte{0xff, 0xff, 0xff, 0xff})) + assetRound := abiEncodeAggregate3Results(t, abiEncodeAddress(t, asset)) + c, stop := newMulticallFakeClient(t, + matching, assetRound, + matching, assetRound, + notMatching, // unauthorized: no asset round is issued + matching, assetRound, + ) + defer stop() + + var listCalls atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + listCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + s := &Solver{ + cfg: &Config{Targets: []Target{{Adapter: adapterAddr}}}, + reader: newReader(c, common.Address{}), + api: newAPIClient(srv.URL, fakeSigner{addr: signer}, big.NewInt(11155111), time.Second, logr.Discard()), + log: logr.Discard(), + signerAddr: signer, + offers: newOfferTracker(), + } + wantCalls := []int64{1, 1, 1, 2} + for i, want := range wantCalls { + if err := s.refreshTargetsAndHydrate(t.Context()); err != nil { + t.Fatalf("refresh %d: %v", i, err) + } + if got := listCalls.Load(); got != want { + t.Fatalf("refresh %d listOffers calls = %d, want %d", i, got, want) + } + } +} + +func TestRefreshTargetsAndHydrate_DiscoversFactoryEntityAfterEmptyStartup(t *testing.T) { + t.Parallel() + + factoryAddr := common.HexToAddress("0x00000000000000000000000000000000000000F0") + adapterAddr := common.HexToAddress("0x00000000000000000000000000000000000000A0") + vault := common.HexToAddress("0x00000000000000000000000000000000000000B0") + signer := common.HexToAddress("0x00000000000000000000000000000000000000C0") + asset := common.HexToAddress("0x00000000000000000000000000000000000000D0") + c, stop := newMulticallFakeClient(t, + abiEncodeAggregate3Results(t, abiEncodeUint256(t, 0)), + abiEncodeAggregate3Results(t, abiEncodeUint256(t, 1)), + abiEncodeAggregate3Results(t, abiEncodeAddress(t, adapterAddr)), + abiEncodeAggregate3Results(t, abiEncodeAddress(t, vault), abiEncodeAddress(t, signer), abiEncodeBytes4(t, erc1271MagicValue)), + abiEncodeAggregate3Results(t, abiEncodeAddress(t, asset)), + ) + defer stop() + + var listCalls atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + listCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + s := &Solver{ + cfg: &Config{AdapterFactory: factoryAddr}, + reader: newReader(c, common.Address{}), + api: newAPIClient(srv.URL, fakeSigner{addr: signer}, big.NewInt(11155111), time.Second, logr.Discard()), + log: logr.Discard(), + signerAddr: signer, + offers: newOfferTracker(), + } + if err := s.refreshTargetsAndHydrate(t.Context()); err != nil { + t.Fatalf("empty startup refresh: %v", err) + } + if len(s.targets) != 0 || listCalls.Load() != 0 { + t.Fatalf("empty startup targets=%v listOffers calls=%d", s.targets, listCalls.Load()) + } + if err := s.refreshTargetsAndHydrate(t.Context()); err != nil { + t.Fatalf("discovery refresh: %v", err) + } + if len(s.targets) != 1 || s.targets[0].Adapter != adapterAddr { + t.Fatalf("discovery targets=%v, want %s", s.targets, adapterAddr.Hex()) + } + if got := listCalls.Load(); got != 1 { + t.Fatalf("listOffers calls = %d, want one hydration", got) + } +} diff --git a/internal/solvers/bridgefacilitator/strategies/default/strategy.go b/internal/solvers/bridgefacilitator/strategies/default/strategy.go index e2f2586c..026cbb0b 100644 --- a/internal/solvers/bridgefacilitator/strategies/default/strategy.go +++ b/internal/solvers/bridgefacilitator/strategies/default/strategy.go @@ -77,12 +77,22 @@ func (s *Strategy) DecideOffers( if st.belowMinAssets(principal) { continue } + // Price at the minYieldPerRequest floor (rounded up to clear it), or the auction max rate when + // there is no floor; ValidateYield drops the pair if the result isn't in [floor, maxRate] + // (including a 0 return). + expectedReturn := types.MinYieldReturn(principal, st.snapshot.MinYieldPpm) + if expectedReturn.Sign() <= 0 { + expectedReturn = types.ExpectedReturn(principal, auction.MaxRateBps) + } + if types.ValidateYield(expectedReturn, principal, st.snapshot.MinYieldPpm, auction.MaxRateBps) != nil { + continue + } offers = append(offers, types.OfferExecution{ AuctionID: auction.AuctionID, Request: auction.Request, Maker: st.snapshot.Adapter, Principal: principal, - ExpectedReturn: types.ExpectedReturn(principal, auction.MaxRateBps), + ExpectedReturn: expectedReturn, }) st.committed.Add(st.committed, principal) st.opened++ @@ -118,10 +128,6 @@ func rankEligibleAdapters( 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 { diff --git a/internal/solvers/bridgefacilitator/strategies/default/strategy_test.go b/internal/solvers/bridgefacilitator/strategies/default/strategy_test.go index b4ca3dbb..568be7df 100644 --- a/internal/solvers/bridgefacilitator/strategies/default/strategy_test.go +++ b/internal/solvers/bridgefacilitator/strategies/default/strategy_test.go @@ -20,7 +20,6 @@ func testAdapter(id byte, fundable int64) types.AdapterSnapshot { Fundable: big.NewInt(fundable), MaxAssets: big.NewInt(fundable), MinAssets: new(big.Int), - MinYieldBps: new(big.Int), MaxConcurrent: 50, } } @@ -40,12 +39,12 @@ func testAuction(id int64, remaining int64) types.AuctionSnapshot { } func TestStrategyLargestFirstClampsLastOffer(t *testing.T) { - a1 := testAdapter(1, 50) // capacity 50 - a2 := testAdapter(2, 80) // capacity 80 + a1 := testAdapter(1, 50_000_000) // capacity 50M + a2 := testAdapter(2, 80_000_000) // capacity 80M input := types.OfferInput{ Now: time.Unix(0, 0), Adapters: []types.AdapterSnapshot{a1, a2}, - Auctions: []types.AuctionSnapshot{testAuction(10, 100)}, + Auctions: []types.AuctionSnapshot{testAuction(10, 100_000_000)}, } got, err := New().DecideOffers(t.Context(), input) @@ -55,14 +54,16 @@ func TestStrategyLargestFirstClampsLastOffer(t *testing.T) { 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]) + // Largest capacity first (a2: 80M), then a1 clamped to the remaining 20M. + if got.Offers[0].Maker != a2.Adapter || got.Offers[0].Principal.Int64() != 80_000_000 { + t.Fatalf("offer0 = %+v, want adapter 2 / 80M", 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[1].Maker != a1.Adapter || got.Offers[1].Principal.Int64() != 20_000_000 { + t.Fatalf("offer1 = %+v, want adapter 1 / 20M", 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) + // 200 bps of each principal, both positive (a 0-return clamped offer would be skipped, not posted). + if got.Offers[0].ExpectedReturn.Int64() != 1_600_000 || got.Offers[1].ExpectedReturn.Int64() != 400_000 { + t.Fatalf("expected returns = %s/%s, want 1600000/400000", got.Offers[0].ExpectedReturn, got.Offers[1].ExpectedReturn) } } @@ -106,14 +107,14 @@ func TestStrategyRejectsZeroAdapterCapacity(t *testing.T) { } func TestStrategyReplaysAdapterCapacityAcrossAuctions(t *testing.T) { - a1 := testAdapter(1, 100) - a1.MaxAssets = big.NewInt(80) + a1 := testAdapter(1, 100_000_000) + a1.MaxAssets = big.NewInt(80_000_000) input := types.OfferInput{ Now: time.Unix(0, 0), Adapters: []types.AdapterSnapshot{a1}, Auctions: []types.AuctionSnapshot{ - testAuction(10, 70), - testAuction(11, 70), + testAuction(10, 70_000_000), + testAuction(11, 70_000_000), }, } @@ -124,9 +125,9 @@ func TestStrategyReplaysAdapterCapacityAcrossAuctions(t *testing.T) { 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) + // Auction 10 takes 70M of the 80M ceiling; auction 11 sees only 100M-70M=30M of budget left. + if got.Offers[0].Principal.Int64() != 70_000_000 || got.Offers[1].Principal.Int64() != 30_000_000 { + t.Fatalf("principals = %s/%s, want 70M/30M", got.Offers[0].Principal, got.Offers[1].Principal) } } @@ -150,12 +151,72 @@ func TestStrategySkipsClampedOfferBelowMinAssets(t *testing.T) { } } +// TestStrategyDropsOfferBelowMinYieldFloor covers the exact ppm floor guard: an offer priced at the +// auction max rate and truncated down must still clear the adapter's minYieldPerRequest, or it is +// dropped (posting it would revert on-chain as FAILED). +func TestStrategyDropsOfferBelowMinYieldFloor(t *testing.T) { + const principal = 600518648976 + a := testAdapter(1, principal) + a.MinYieldPpm = big.NewInt(190) // 1.9 bps floor + + // maxRate exactly at the floor (1.9 bps): floor(principal*1.9/1e4) yields 189.9999… ppm < 190. + auction := testAuction(10, principal) + auction.MaxRateBps = 1.9 + input := types.OfferInput{ + Now: time.Unix(0, 0), + Adapters: []types.AdapterSnapshot{a}, + Auctions: []types.AuctionSnapshot{auction}, + } + 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: truncated expectedReturn is below the 190 ppm floor", got.Offers) + } + + // A hair more headroom (1.91 bps) leaves room, so the offer is made — and priced AT the floor + // (ceil(principal*190/1e6) = 114098544), not at the max rate. + auction.MaxRateBps = 1.91 + input.Auctions = []types.AuctionSnapshot{auction} + got, err = New().DecideOffers(t.Context(), input) + if err != nil { + t.Fatalf("DecideOffers: %v", err) + } + if len(got.Offers) != 1 { + t.Fatalf("offers = %+v, want 1: 1.91 bps leaves room above the 190 ppm floor", got.Offers) + } + if want := big.NewInt(114098544); got.Offers[0].ExpectedReturn.Cmp(want) != 0 { + t.Fatalf("expectedReturn = %s, want %s (priced at the minYieldPerRequest floor)", got.Offers[0].ExpectedReturn, want) + } +} + +// TestStrategySkipsZeroRatePair covers the degenerate case: no adapter floor and a zero auction max rate +// would price the offer at 0 return — the pair must be skipped, not offered at 0 yield. +func TestStrategySkipsZeroRatePair(t *testing.T) { + a := testAdapter(1, 1000) // MinYieldPpm nil (0) + auction := testAuction(10, 500) + auction.MaxRateBps = 0 // both floor and max rate are 0 + input := types.OfferInput{ + Now: time.Unix(0, 0), + Adapters: []types.AdapterSnapshot{a}, + Auctions: []types.AuctionSnapshot{auction}, + } + 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: a 0-floor / 0-maxRate pair must not be offered at 0 return", 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) + a3.MinYieldPpm = big.NewInt(30_000) // 300 bps floor, above the auction's 200-bps (20_000 ppm) max rate auction := testAuction(10, 100) input := types.OfferInput{ Now: time.Unix(0, 0), diff --git a/internal/solvers/bridgefacilitator/strategies/types/math.go b/internal/solvers/bridgefacilitator/strategies/types/math.go index 29ad3f9c..22e9c4e1 100644 --- a/internal/solvers/bridgefacilitator/strategies/types/math.go +++ b/internal/solvers/bridgefacilitator/strategies/types/math.go @@ -1,22 +1,83 @@ package types -import "math/big" +import ( + "math" + "math/big" -// RateDenominatorBps converts a basis-point rate to a fraction (10_000 = 100%). -const RateDenominatorBps = 10_000.0 + "github.com/go-errors/errors" +) -// 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. +// PpmPerBps scales a basis-point rate to parts per million (1 bps = 100 ppm). +const PpmPerBps = 100.0 + +// yieldPpmScale is YIELD_PRECISION on-chain: yield is expectedReturn * 1e6 / principal (ppm). +const yieldPpmScale = 1_000_000 + +// Read-only big.Int constants for the yield math, hoisted out of the per-offer hot path. +var ( + bigYieldPpmScale = big.NewInt(yieldPpmScale) + bigCeilBias = big.NewInt(yieldPpmScale - 1) // for ceil(x/1e6) = (x + 1e6-1) / 1e6 +) + +// ValidateYield checks that expectedReturn on principal is acceptable to BOTH the adapter's on-chain +// minYieldPerRequest floor and the 3F auction's max rate, in exact integer ppm — so the offer can't +// revert on-chain (below floor) nor be rejected by the auction (above maxRate). Yield is compared the way +// the contract computes it, floor(expectedReturn*1e6/principal), which for integer bounds is equivalent +// to the exact integer comparisons below. A zero/absent floor or maxRate skips that bound; nil or +// non-positive amounts are rejected. +func ValidateYield(expectedReturn, principal, minYieldPpm *big.Int, maxRateBps float64) error { + // Reject a non-positive return: a 0 yield (what the pricing helpers produce when floor and maxRate are + // both 0, or on dust principals) is never a real offer, so the pair is skipped rather than offered. + if principal == nil || principal.Sign() <= 0 || expectedReturn == nil || expectedReturn.Sign() <= 0 { + return errors.Errorf("invalid offer amounts (must be positive): principal=%v expectedReturn=%v", principal, expectedReturn) + } + if !MeetsMinYield(expectedReturn, principal, minYieldPpm) { + return errors.Errorf("yield below minYieldPerRequest floor %s ppm", minYieldPpm) + } + // maxRate has tenths-of-a-bps precision, so maxRateBps*100 is a whole ppm value; round off float noise. + if maxRatePpm := int64(math.Round(maxRateBps * PpmPerBps)); maxRatePpm > 0 { + scaled := new(big.Int).Mul(expectedReturn, bigYieldPpmScale) + if scaled.Cmp(new(big.Int).Mul(principal, big.NewInt(maxRatePpm))) > 0 { + return errors.Errorf("yield above auction maxRate %g bps", maxRateBps) + } + } + return nil +} + +// ExpectedReturn is the return for principal at rateBps, truncated down. maxRate has tenths-of-a-bps +// precision so rateBps*100 is whole ppm; the math is exact integer floor(principal*ppm/1e6) — a big.Float +// path drifts by 1 wei for principals above ~2^64. 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 + ratePpm := int64(math.Round(rateBps * PpmPerBps)) + if principal == nil || principal.Sign() <= 0 || ratePpm <= 0 { + return new(big.Int) + } + num := new(big.Int).Mul(principal, big.NewInt(ratePpm)) + return num.Quo(num, bigYieldPpmScale) +} + +// MinYieldReturn is the smallest expectedReturn on principal that clears the adapter's +// minYieldPerRequest floor: ceil(principal * minYieldPpm / 1e6). Pricing an offer here quotes the most +// competitive rate the adapter allows, rounded up so the realised yield is never a hair below the floor +// (which would revert the fill). Returns 0 when there is no floor (minYieldPpm <= 0). +func MinYieldReturn(principal, minYieldPpm *big.Int) *big.Int { + if principal == nil || principal.Sign() <= 0 || minYieldPpm == nil || minYieldPpm.Sign() <= 0 { + return new(big.Int) + } + num := new(big.Int).Mul(principal, minYieldPpm) + num.Add(num, bigCeilBias) + return num.Quo(num, bigYieldPpmScale) } -// 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 +// MeetsMinYield reports whether expectedReturn on principal clears the adapter's on-chain +// minYieldPerRequest floor (minYieldPpm, parts per million): expectedReturn/principal >= minYieldPpm/1e6. +// The on-chain fill enforces this exactly, so an offer under it settles as FAILED — the check is integer +// to avoid the float/bps rounding that lets a truncated maxRate offer land a hair below the floor. +func MeetsMinYield(expectedReturn, principal, minYieldPpm *big.Int) bool { + if minYieldPpm == nil || minYieldPpm.Sign() <= 0 { + return true + } + lhs := new(big.Int).Mul(expectedReturn, bigYieldPpmScale) + rhs := new(big.Int).Mul(principal, minYieldPpm) + return lhs.Cmp(rhs) >= 0 } diff --git a/internal/solvers/bridgefacilitator/strategies/types/math_test.go b/internal/solvers/bridgefacilitator/strategies/types/math_test.go index e88743c0..51616c0a 100644 --- a/internal/solvers/bridgefacilitator/strategies/types/math_test.go +++ b/internal/solvers/bridgefacilitator/strategies/types/math_test.go @@ -13,4 +13,106 @@ func TestExpectedReturn(t *testing.T) { if got.Cmp(want) != 0 { t.Fatalf("expected %s, got %s", want, got) } + + // Exactness at large principals where a big.Float path drifts by 1 wei (18-decimal assets, big amounts). + exact := []struct { + principal string + rateBps float64 + want string + }{ + {"999999999999999999", 1.91, "190999999999999"}, // float path gives 191000000000000 + {"141970357433434898528749", 200, "2839407148668697970574"}, // float path gives ...575 + {"1000000000000000000000000", 3, "300000000000000000000"}, // 1M of an 18-dp token at 3 bps + } + for _, c := range exact { + p, _ := new(big.Int).SetString(c.principal, 10) + if g := ExpectedReturn(p, c.rateBps); g.String() != c.want { + t.Fatalf("ExpectedReturn(%s, %g) = %s, want %s", c.principal, c.rateBps, g, c.want) + } + } +} + +func TestMeetsMinYield(t *testing.T) { + cases := []struct { + name string + er int64 + principal int64 + minPpm int64 + want bool + }{ + {"no floor (zero)", 1, 1000, 0, true}, + {"exactly at floor", 190, 1_000_000, 190, true}, + {"above floor", 300, 1_000_000, 190, true}, + {"one wei below floor", 189, 1_000_000, 190, false}, + // Real case: floor(600518648976*1.9/1e4)=114098543 yields 189.9999 ppm, just under 190. + {"truncated maxRate under floor", 114098543, 600518648976, 190, false}, + {"bumped clears floor", 114098544, 600518648976, 190, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := MeetsMinYield(big.NewInt(c.er), big.NewInt(c.principal), big.NewInt(c.minPpm)) + if got != c.want { + t.Fatalf("MeetsMinYield(%d, %d, %d) = %v, want %v", c.er, c.principal, c.minPpm, got, c.want) + } + }) + } +} + +func TestMinYieldReturn(t *testing.T) { + cases := []struct { + name string + principal int64 + minPpm int64 + want int64 + }{ + {"no floor", 1_000_000, 0, 0}, + {"exact multiple", 1_000_000, 190, 190}, + {"rounds up", 600518648976, 190, 114098544}, // ceil(114098543.305) + {"rounds up 191", 1_000_003, 191, 192}, // ceil(191.000573) + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := MinYieldReturn(big.NewInt(c.principal), big.NewInt(c.minPpm)) + if got.Cmp(big.NewInt(c.want)) != 0 { + t.Fatalf("MinYieldReturn(%d, %d) = %s, want %d", c.principal, c.minPpm, got, c.want) + } + // The result must clear the floor it was derived from. + if c.minPpm > 0 && !MeetsMinYield(got, big.NewInt(c.principal), big.NewInt(c.minPpm)) { + t.Fatalf("MinYieldReturn(%d, %d) = %s does not clear its own floor", c.principal, c.minPpm, got) + } + }) + } +} + +func TestValidateYield(t *testing.T) { + const amount = 600518648976 // ~600.5k USDC; floor 190 ppm → 114098544; maxRate 3 bps = 300 ppm + big190 := big.NewInt(190) + cases := []struct { + name string + er *big.Int + amount *big.Int + minPpm *big.Int + maxBps float64 + wantErr bool + }{ + {"at floor, under max", big.NewInt(114098544), big.NewInt(amount), big190, 3, false}, + {"one below floor", big.NewInt(114098543), big.NewInt(amount), big190, 3, true}, + {"above max rate", big.NewInt(200000000), big.NewInt(amount), big190, 3, true}, // ~333 ppm > 300 + {"at exactly max rate", big.NewInt(180155594), big.NewInt(amount), big190, 3, false}, // floor(180155594*1e6/amount)=300 + {"nil expectedReturn", nil, big.NewInt(amount), big190, 3, true}, + {"zero expectedReturn", big.NewInt(0), big.NewInt(amount), big190, 3, true}, + {"zero expectedReturn, no floor no max", big.NewInt(0), big.NewInt(amount), big.NewInt(0), 0, true}, + {"nil principal", big.NewInt(1), nil, big190, 3, true}, + {"zero principal", big.NewInt(1), big.NewInt(0), big190, 3, true}, + {"no floor, under max", big.NewInt(1), big.NewInt(amount), big.NewInt(0), 3, false}, + {"no maxRate (unresolved), clears floor", big.NewInt(114098544), big.NewInt(amount), big190, 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := ValidateYield(c.er, c.amount, c.minPpm, c.maxBps) + if (err != nil) != c.wantErr { + t.Fatalf("ValidateYield = %v, wantErr=%v", err, c.wantErr) + } + }) + } } diff --git a/internal/solvers/bridgefacilitator/strategies/types/types.go b/internal/solvers/bridgefacilitator/strategies/types/types.go index 12f78ee4..5efa3219 100644 --- a/internal/solvers/bridgefacilitator/strategies/types/types.go +++ b/internal/solvers/bridgefacilitator/strategies/types/types.go @@ -34,7 +34,7 @@ type AdapterSnapshot struct { OpenCount int MaxAssets *big.Int MinAssets *big.Int - MinYieldBps *big.Int + MinYieldPpm *big.Int // minYieldPerRequest in ppm — the exact on-chain floor (webhook derives bps if needed) MaxConcurrent int } diff --git a/internal/solvers/bridgefacilitator/strategies/types/wire_json.go b/internal/solvers/bridgefacilitator/strategies/types/wire_json.go index 06b32340..bfcf0b3f 100644 --- a/internal/solvers/bridgefacilitator/strategies/types/wire_json.go +++ b/internal/solvers/bridgefacilitator/strategies/types/wire_json.go @@ -30,7 +30,7 @@ type adapterSnapshotJSON struct { OpenCount int `json:"openCount"` MaxAssets string `json:"maxAssets"` MinAssets string `json:"minAssets"` - MinYieldBps string `json:"minYieldBps"` + MinYieldPpm string `json:"minYieldPpm"` MaxConcurrent int `json:"maxConcurrent"` } @@ -75,7 +75,7 @@ func (in OfferInput) MarshalJSON() ([]byte, error) { OpenCount: a.OpenCount, MaxAssets: bigString(a.MaxAssets), MinAssets: bigString(a.MinAssets), - MinYieldBps: bigString(a.MinYieldBps), + MinYieldPpm: bigString(a.MinYieldPpm), MaxConcurrent: a.MaxConcurrent, }) } diff --git a/internal/solvers/bridgefacilitator/strategies/types/wire_json_test.go b/internal/solvers/bridgefacilitator/strategies/types/wire_json_test.go index 36b7936e..f4029df0 100644 --- a/internal/solvers/bridgefacilitator/strategies/types/wire_json_test.go +++ b/internal/solvers/bridgefacilitator/strategies/types/wire_json_test.go @@ -31,7 +31,7 @@ func TestOfferInputMarshalJSONWireShape(t *testing.T) { OpenCount: 1, MaxAssets: mustBig(t, "500"), MinAssets: mustBig(t, "100"), - MinYieldBps: mustBig(t, "100"), + MinYieldPpm: mustBig(t, "190"), MaxConcurrent: 3, }}, Auctions: []AuctionSnapshot{{ @@ -55,6 +55,9 @@ func TestOfferInputMarshalJSONWireShape(t *testing.T) { 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) } + if !strings.Contains(string(body), `"minYieldPpm":"190"`) { + t.Fatalf("wire must carry the exact minYieldPpm floor: %s", body) + } var raw map[string]any if err := json.Unmarshal(body, &raw); err != nil { t.Fatalf("Unmarshal raw: %v", err) diff --git a/internal/solvers/bridgefacilitator/strategy.go b/internal/solvers/bridgefacilitator/strategy.go index 34a255c8..02eb71f6 100644 --- a/internal/solvers/bridgefacilitator/strategy.go +++ b/internal/solvers/bridgefacilitator/strategy.go @@ -41,7 +41,7 @@ func buildStrategyInput( OpenCount: off.st.openCount, MaxAssets: cloneBig(off.st.maxAssets), MinAssets: cloneBig(off.st.minAssets), - MinYieldBps: cloneBig(off.st.minYieldBps), + MinYieldPpm: cloneBig(off.st.minYieldPpm), MaxConcurrent: maxRequests, }) } diff --git a/internal/solvers/bridgefacilitator/strategy_test.go b/internal/solvers/bridgefacilitator/strategy_test.go index 7faa2714..408bf4b4 100644 --- a/internal/solvers/bridgefacilitator/strategy_test.go +++ b/internal/solvers/bridgefacilitator/strategy_test.go @@ -40,7 +40,6 @@ func baseOfferInput(t *testing.T) types.OfferInput { Fundable: mustBig(t, "1000"), MaxAssets: mustBig(t, "800"), MinAssets: new(big.Int), - MinYieldBps: new(big.Int), MaxConcurrent: maxRequests, }}, Auctions: []types.AuctionSnapshot{{ @@ -76,7 +75,7 @@ func TestBuildStrategyInputKeepsFullyCoveredAuctions(t *testing.T) { adapter := common.HexToAddress("0x0000000000000000000000000000000000000001") collateral := common.HexToAddress("0x0000000000000000000000000000000000000003") offers := newOfferTracker() - offers.record(adapter, 10, now.Add(time.Minute), big.NewInt(100)) + seed(offers, adapter, 10, now.Add(time.Minute), 100) input := buildStrategyInput( []threef.AuctionDto{testAuctionDto(10, collateral, "100")}, @@ -87,10 +86,9 @@ func TestBuildStrategyInputKeepsFullyCoveredAuctions(t *testing.T) { Collateral: collateral, }, st: exposureState{ - fundable: big.NewInt(100), - maxAssets: big.NewInt(100), - minAssets: new(big.Int), - minYieldBps: new(big.Int), + fundable: big.NewInt(100), + maxAssets: big.NewInt(100), + minAssets: new(big.Int), }, }}, offers, diff --git a/internal/solvers/lifi/chainreader.go b/internal/solvers/lifi/chainreader.go new file mode 100644 index 00000000..b46ba467 --- /dev/null +++ b/internal/solvers/lifi/chainreader.go @@ -0,0 +1,193 @@ +package lifi + +import ( + "context" + "math/big" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/api/bindings/lifi/inputsettler" + "github.com/symbioticfi/vault-solver/internal/chain" + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" + liquidsnapshot "github.com/symbioticfi/vault-solver/internal/liquidlane/snapshot" +) + +var ( + lifiInputSettler = inputsettler.NewILifiInputSettler() +) + +type reader struct { + chain *chain.Client + snapshots *liquidsnapshot.Reader +} + +type route = liquidlane.Route + +type quoteSnapshotSet = liquidsnapshot.Quote +type fillSnapshotSet = liquidsnapshot.Fill + +func newReader(c *chain.Client, log logr.Logger, gasCfg liquidlanegas.OracleConfig, liquidityLens common.Address) (*reader, error) { + snapshots, err := liquidsnapshot.New(c, log, &gasCfg, liquidityLens) + if err != nil { + return nil, err + } + return &reader{chain: c, snapshots: snapshots}, nil +} + +func (r *reader) resolveRoutes(ctx context.Context, adapters []common.Address) ([]route, error) { + return r.snapshots.ResolveRoutes(ctx, adapters) +} + +func (r *reader) validateGasTokens(routes []route) error { + return r.snapshots.ValidateGasTokens(routes) +} + +func (r *reader) quoteSnapshots( + ctx context.Context, + routes []route, + executorAddr common.Address, + chainTime time.Time, +) (quoteSnapshotSet, error) { + return r.snapshots.Quote(ctx, routes, executorAddr, chainTime) +} + +func (r *reader) fillSnapshots( + ctx context.Context, + routes []route, + executorAddr common.Address, + tokenIn common.Address, + amountIn *big.Int, + chainTime time.Time, +) (fillSnapshotSet, error) { + return r.snapshots.Fill(ctx, routes, executorAddr, tokenIn, amountIn, chainTime) +} + +func (r *reader) validateExecutor( + ctx context.Context, + executorAddr common.Address, + inputSettler common.Address, + outputSettler common.Address, + caller common.Address, +) error { + calls := []chain.Call{ + {Target: executorAddr, Data: lifiExecutor.PackINPUTSETTLER()}, + {Target: executorAddr, Data: lifiExecutor.PackOUTPUTSETTLER()}, + {Target: executorAddr, Data: lifiExecutor.PackIsCaller(caller)}, + } + results, err := r.chain.Multicall(ctx, calls) + if err != nil { + return errors.Errorf("executor configuration: %w", err) + } + if len(results) != len(calls) || !results[0].Success || !results[1].Success || !results[2].Success { + return errors.New("executor configuration: unresolved") + } + gotInput, inputErr := lifiExecutor.UnpackINPUTSETTLER(results[0].ReturnData) + gotOutput, outputErr := lifiExecutor.UnpackOUTPUTSETTLER(results[1].ReturnData) + if inputErr != nil || outputErr != nil { + return errors.New("executor immutables: malformed response") + } + if gotInput != inputSettler || gotOutput != outputSettler { + return errors.Errorf("executor immutables mismatch: input=%s output=%s", gotInput.Hex(), gotOutput.Hex()) + } + allowed, err := lifiExecutor.UnpackIsCaller(results[2].ReturnData) + if err != nil { + return errors.New("executor caller authorization: malformed response") + } + if !allowed { + return errors.Errorf("executor caller %s is not authorized", caller.Hex()) + } + return nil +} + +func (r *reader) validateZeroGovernanceFee(ctx context.Context, inputSettler common.Address) error { + ret, err := r.chain.CallContract(ctx, ethereum.CallMsg{ + To: &inputSettler, + Data: lifiInputSettler.PackGovernanceFee(), + }, nil) + if err != nil { + return errors.Errorf("input settler governance fee: %w", err) + } + fee, err := lifiInputSettler.UnpackGovernanceFee(ret) + if err != nil { + return errors.Errorf("input settler governance fee: malformed response: %w", err) + } + if fee != 0 { + return errors.Errorf("input settler governance fee is %d, expected zero", fee) + } + return nil +} + +func (r *reader) validateDirectAuthorization( + ctx context.Context, + executorAddr common.Address, + routes []route, +) error { + direct, err := r.snapshots.FilterAuthorizedRoutes(ctx, routes, executorAddr) + if err != nil { + return err + } + if missing := liquidlane.UnauthorizedAdapters(routes, direct); len(missing) > 0 { + return errors.Errorf( + "executor %s is not authorized as direct filler for configured adapters: %v", + executorAddr.Hex(), missing, + ) + } + return nil +} + +func (r *reader) orderIdentifier( + ctx context.Context, + inputSettler common.Address, + order inputsettler.StandardOrder, +) (common.Hash, error) { + data, err := lifiInputSettler.TryPackOrderIdentifier(order) + if err != nil { + return common.Hash{}, errors.Errorf("pack orderIdentifier: %w", err) + } + ret, err := r.chain.CallContract(ctx, ethereum.CallMsg{To: &inputSettler, Data: data}, nil) + if err != nil { + return common.Hash{}, errors.Errorf("call orderIdentifier: %w", err) + } + orderID, err := lifiInputSettler.UnpackOrderIdentifier(ret) + if err != nil { + return common.Hash{}, errors.Errorf("unpack orderIdentifier: %w", err) + } + return common.Hash(orderID), nil +} + +func (r *reader) orderStatus(ctx context.Context, inputSettler common.Address, orderID common.Hash) (uint8, error) { + data, err := lifiInputSettler.TryPackOrderStatus(orderID) + if err != nil { + return 0, errors.Errorf("pack orderStatus: %w", err) + } + ret, err := r.chain.CallContract(ctx, ethereum.CallMsg{To: &inputSettler, Data: data}, nil) + if err != nil { + return 0, errors.Errorf("call orderStatus: %w", err) + } + status, err := lifiInputSettler.UnpackOrderStatus(ret) + if err != nil { + return 0, errors.Errorf("unpack orderStatus: %w", err) + } + return status, nil +} + +func (r *reader) latestBlockNumber(ctx context.Context) (uint64, error) { + n, err := r.chain.BlockNumber(ctx) + if err != nil { + return 0, errors.Errorf("block number: %w", err) + } + return n, nil +} + +func (r *reader) latestBlockTime(ctx context.Context) (time.Time, error) { + header, err := r.chain.HeaderByNumber(ctx, nil) + if err != nil { + return time.Time{}, errors.Errorf("latest block header: %w", err) + } + return time.Unix(int64(header.Time), 0), nil +} diff --git a/internal/solvers/lifi/chainreader_test.go b/internal/solvers/lifi/chainreader_test.go new file mode 100644 index 00000000..f8f6a361 --- /dev/null +++ b/internal/solvers/lifi/chainreader_test.go @@ -0,0 +1,72 @@ +package lifi + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/chain" +) + +func TestValidateZeroGovernanceFee(t *testing.T) { + tests := []struct { + name string + fee uint64 + wantErr string + }{ + {name: "zero", fee: 0}, + {name: "non-zero", fee: 1, wantErr: "governance fee is 1, expected zero"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := governanceFeeRPC(t, tt.fee) + defer server.Close() + + client, err := chain.Dial(t.Context(), []string{server.URL}, "", common.Address{}.Hex(), logr.Discard()) + if err != nil { + t.Fatalf("chain.Dial: %v", err) + } + defer client.Close() + + err = (&reader{chain: client}).validateZeroGovernanceFee( + t.Context(), common.HexToAddress("0x1111111111111111111111111111111111111111"), + ) + if tt.wantErr == "" && err != nil { + t.Fatalf("validateZeroGovernanceFee: %v", err) + } + if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) { + t.Fatalf("validateZeroGovernanceFee error = %v", err) + } + }) + } +} + +func governanceFeeRPC(t *testing.T, fee uint64) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + var rpcRequest struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + body, err := io.ReadAll(request.Body) + if err != nil { + t.Errorf("read RPC request: %v", err) + } + if err := json.Unmarshal(body, &rpcRequest); err != nil { + t.Errorf("decode RPC request: %v", err) + } + result := `"0xaa36a7"` + if rpcRequest.Method == "eth_call" { + result = fmt.Sprintf(`"0x%064x"`, fee) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%s,"result":%s}`, rpcRequest.ID, result) + })) +} diff --git a/internal/solvers/lifi/config.go b/internal/solvers/lifi/config.go new file mode 100644 index 00000000..3bf92a57 --- /dev/null +++ b/internal/solvers/lifi/config.go @@ -0,0 +1,231 @@ +package lifi + +import ( + "strconv" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "gopkg.in/yaml.v3" + + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" + "github.com/symbioticfi/vault-solver/internal/parse" + "github.com/symbioticfi/vault-solver/internal/solver" + "github.com/symbioticfi/vault-solver/internal/tokenpolicy" +) + +type rawConfig struct { + OrderServer rawOrderServerConfig `yaml:"orderServer"` + InputSettler string `yaml:"inputSettler"` + OutputSettler string `yaml:"outputSettler"` + Executor string `yaml:"executor"` + LiquidityLens string `yaml:"liquidityLens"` + Adapters []string `yaml:"adapters"` + TokensToQuote string `yaml:"tokensToQuote"` + PermissionedTokens []string `yaml:"permissionedTokens"` + QuoteIntervalMs int `yaml:"quoteIntervalMs"` + QuoteTTL string `yaml:"quoteTtl"` + QuoteRefreshMode string `yaml:"quoteRefreshMode"` + SolverMode string `yaml:"solverMode"` + DiscountsURL string `yaml:"privateDiscountsUrl"` + Gas liquidlanegas.RawConfig `yaml:"gas"` + Strategy rawStrategyConfig `yaml:"strategy"` +} + +type rawOrderServerConfig struct { + BaseURL string `yaml:"baseUrl"` + WSURL string `yaml:"wsUrl"` + APIKeyEnv string `yaml:"apiKeyEnv"` + HTTPTimeout string `yaml:"httpTimeout"` +} + +type rawStrategyConfig struct { + Name string `yaml:"name"` + Config yaml.Node `yaml:"config"` +} + +type Config struct { + OrderServer OrderServerConfig + InputSettler common.Address + OutputSettler common.Address + Executor common.Address + // LiquidityLens is the optional FrontendLiquidityLens address. When set, LiquidLane swappable headroom + // is read from the lens's cross-adapter deallocation-cascade estimate instead of each adapter's own + // getMaxAssets(tokenToRedeem); zero falls back to the adapter getter. + LiquidityLens common.Address + Adapters []common.Address + TokenPolicy tokenpolicy.Policy + QuoteInterval time.Duration + QuoteTTL time.Duration + QuoteRefreshMode string + SolverMode string + DiscountsURL string + Gas liquidlanegas.OracleConfig + Strategy StrategyConfig +} + +type OrderServerConfig struct { + BaseURL string + WSURL string + APIKeyEnv string + HTTPTimeout time.Duration +} + +type StrategyConfig struct { + Name string + Config yaml.Node +} + +const ( + defaultHTTPTimeout = 10 * time.Second + defaultQuoteInterval = 30 * time.Second + defaultQuoteTTL = 36 * time.Second + defaultBlockPollInterval = time.Second + defaultQuoteRefreshMode = quoteRefreshModeBlock + defaultStrategyName = "default" + defaultSolverMode = solverModeExternal +) + +const ( + quoteRefreshModeInterval = "interval" + quoteRefreshModeBlock = "block" + solverModeExternal = "external" + solverModeInternal = "internal" +) + +func parseConfig(node yaml.Node) (*Config, error) { + var raw rawConfig + if err := solver.DecodeStrict(node, &raw); err != nil { + return nil, err + } + + inputSettler, err := parse.NonZeroAddress(raw.InputSettler, "inputSettler") + if err != nil { + return nil, err + } + outputSettler, err := parse.NonZeroAddress(raw.OutputSettler, "outputSettler") + if err != nil { + return nil, err + } + executor, err := parse.NonZeroAddress(raw.Executor, "executor") + if err != nil { + return nil, err + } + var liquidityLens common.Address + if raw.LiquidityLens != "" { + if liquidityLens, err = parse.NonZeroAddress(raw.LiquidityLens, "liquidityLens"); err != nil { + return nil, err + } + } + adapters, err := parseAdapters(raw.Adapters) + if err != nil { + return nil, err + } + tokenPolicy, err := tokenpolicy.Parse(raw.TokensToQuote, raw.PermissionedTokens) + if err != nil { + return nil, err + } + httpTimeout, err := parse.Duration(raw.OrderServer.HTTPTimeout, defaultHTTPTimeout, "orderServer.httpTimeout") + if err != nil { + return nil, err + } + quoteRefreshMode := parse.OrDefault(raw.QuoteRefreshMode, defaultQuoteRefreshMode) + if quoteRefreshMode != quoteRefreshModeInterval && quoteRefreshMode != quoteRefreshModeBlock { + return nil, errors.Errorf("quoteRefreshMode: must be %q or %q, got %q", + quoteRefreshModeInterval, quoteRefreshModeBlock, quoteRefreshMode) + } + quoteInterval, err := parseQuoteInterval(raw.QuoteIntervalMs, quoteRefreshMode) + if err != nil { + return nil, err + } + quoteTTL, err := parse.Duration(raw.QuoteTTL, defaultQuoteTTL, "quoteTtl") + if err != nil { + return nil, err + } + if quoteTTL/2 < quoteInterval { + return nil, errors.Errorf("quoteTtl must be at least twice quote interval %s, got %s", quoteInterval, quoteTTL) + } + apiKeyEnv := raw.OrderServer.APIKeyEnv + if apiKeyEnv == "" { + return nil, errors.New("orderServer.apiKeyEnv is required") + } + if raw.OrderServer.BaseURL == "" { + return nil, errors.New("orderServer.baseUrl is required") + } + if raw.OrderServer.WSURL == "" { + return nil, errors.New("orderServer.wsUrl is required") + } + solverMode := parse.OrDefault(raw.SolverMode, defaultSolverMode) + if solverMode != solverModeExternal && solverMode != solverModeInternal { + return nil, errors.Errorf("solverMode: must be %q or %q, got %q", solverModeExternal, solverModeInternal, solverMode) + } + if solverMode == solverModeInternal && raw.DiscountsURL == "" { + return nil, errors.New("privateDiscountsUrl is required in internal solverMode") + } + if solverMode == solverModeExternal && raw.DiscountsURL != "" { + return nil, errors.New("privateDiscountsUrl requires internal solverMode") + } + gas, err := liquidlanegas.ParseConfig(raw.Gas) + if err != nil { + return nil, err + } + return &Config{ + OrderServer: OrderServerConfig{ + BaseURL: raw.OrderServer.BaseURL, + WSURL: raw.OrderServer.WSURL, + APIKeyEnv: apiKeyEnv, + HTTPTimeout: httpTimeout, + }, + InputSettler: inputSettler, + OutputSettler: outputSettler, + Executor: executor, + LiquidityLens: liquidityLens, + Adapters: adapters, + TokenPolicy: tokenPolicy, + QuoteInterval: quoteInterval, + QuoteTTL: quoteTTL, + QuoteRefreshMode: quoteRefreshMode, + SolverMode: solverMode, + DiscountsURL: raw.DiscountsURL, + Gas: gas, + Strategy: StrategyConfig{ + Name: parse.OrDefault(raw.Strategy.Name, defaultStrategyName), + Config: raw.Strategy.Config, + }, + }, nil +} + +func (c *Config) usesDiscounts() bool { return c.SolverMode == solverModeInternal } + +func parseQuoteInterval(ms int, mode string) (time.Duration, error) { + if ms == 0 { + if mode == quoteRefreshModeBlock { + return defaultBlockPollInterval, nil + } + return defaultQuoteInterval, nil + } + if ms < 0 { + return 0, errors.Errorf("quoteIntervalMs: must be positive, got %d", ms) + } + return time.Duration(ms) * time.Millisecond, nil +} + +func parseAdapters(raw []string) ([]common.Address, error) { + if len(raw) == 0 { + return nil, errors.New("at least one adapters entry is required") + } + out := make([]common.Address, 0, len(raw)) + seen := make(map[common.Address]bool, len(raw)) + for i, a := range raw { + addr, err := parse.NonZeroAddress(a, "adapters["+strconv.Itoa(i)+"]") + if err != nil { + return nil, err + } + if seen[addr] { + return nil, errors.Errorf("adapters[%d]: duplicate adapter %s", i, addr.Hex()) + } + seen[addr] = true + out = append(out, addr) + } + return out, nil +} diff --git a/internal/solvers/lifi/config_test.go b/internal/solvers/lifi/config_test.go new file mode 100644 index 00000000..aa2fee87 --- /dev/null +++ b/internal/solvers/lifi/config_test.go @@ -0,0 +1,367 @@ +package lifi + +import ( + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" + "github.com/symbioticfi/vault-solver/internal/tokenpolicy" + "gopkg.in/yaml.v3" +) + +func TestParseConfigValid(t *testing.T) { + cfg := parseConfigYAML(t, ` +orderServer: + baseUrl: https://order.example + wsUrl: wss://order.example + apiKeyEnv: LIFI_SOLVER_API_KEY +inputSettler: "0x2222222222222222222222222222222222222222" +outputSettler: "0x3333333333333333333333333333333333333333" +executor: "0x4444444444444444444444444444444444444444" +adapters: + - "0x5555555555555555555555555555555555555555" +tokensToQuote: permissioned +permissionedTokens: + - "0x6666666666666666666666666666666666666666" +gas: + nativeUsdFeed: "0x7777777777777777777777777777777777777777" + nativeMaxAge: 30m + tokenUsdFeeds: + - token: "0x6666666666666666666666666666666666666666" + feed: "0x8888888888888888888888888888888888888888" + maxAge: 1h +strategy: + name: default +quoteIntervalMs: 45000 +quoteTtl: 90s +quoteRefreshMode: block +`) + + if cfg.OrderServer.BaseURL != "https://order.example" { + t.Fatalf("baseURL = %q", cfg.OrderServer.BaseURL) + } + if cfg.OrderServer.WSURL != "wss://order.example" { + t.Fatalf("wsURL = %q", cfg.OrderServer.WSURL) + } + if cfg.OrderServer.HTTPTimeout != defaultHTTPTimeout { + t.Fatalf("httpTimeout = %s", cfg.OrderServer.HTTPTimeout) + } + if cfg.QuoteInterval != 45*time.Second { + t.Fatalf("quoteInterval = %s", cfg.QuoteInterval) + } + if cfg.QuoteTTL != 90*time.Second { + t.Fatalf("quoteTTL = %s", cfg.QuoteTTL) + } + if cfg.QuoteRefreshMode != quoteRefreshModeBlock { + t.Fatalf("quoteRefreshMode = %q", cfg.QuoteRefreshMode) + } + if cfg.Strategy.Name != "default" { + t.Fatalf("strategy = %q", cfg.Strategy.Name) + } + permissioned := common.HexToAddress("0x6666666666666666666666666666666666666666") + if cfg.Gas.NativeUSDFeed.MaxAge != 30*time.Minute || + cfg.Gas.TokenUSDFeeds[permissioned].MaxAge != time.Hour { + t.Fatalf("gas oracle config = %+v", cfg.Gas) + } + if cfg.SolverMode != solverModeExternal || cfg.usesDiscounts() { + t.Fatalf("solver mode = %q discounts=%v", cfg.SolverMode, cfg.usesDiscounts()) + } + if !cfg.TokenPolicy.RequiresSingleRoute(permissioned) { + t.Fatalf("token scope = %q", cfg.TokenPolicy.Scope()) + } + if _, err := newStrategy(cfg.Strategy); err != nil { + t.Fatalf("newStrategy: %v", err) + } +} + +func TestParseConfigRejectsLegacySolverAddress(t *testing.T) { + _, err := parseConfig(parseYAMLNode(t, `solverAddress: "0x1111111111111111111111111111111111111111"`)) + if err == nil || !strings.Contains(err.Error(), "solverAddress") { + t.Fatalf("err = %v", err) + } +} + +func TestParseConfigTokenScope(t *testing.T) { + const base = ` +orderServer: + baseUrl: https://order.example + wsUrl: wss://order.example + apiKeyEnv: LIFI_SOLVER_API_KEY +inputSettler: "0x2222222222222222222222222222222222222222" +outputSettler: "0x3333333333333333333333333333333333333333" +executor: "0x4444444444444444444444444444444444444444" +adapters: + - "0x5555555555555555555555555555555555555555" +permissionedTokens: + - "0x6666666666666666666666666666666666666666" +gas: + nativeUsdFeed: "0x7777777777777777777777777777777777777777" + nativeMaxAge: 30m + tokenUsdFeeds: + - token: "0x6666666666666666666666666666666666666666" + feed: "0x8888888888888888888888888888888888888888" + maxAge: 1h +` + permissioned := common.HexToAddress("0x6666666666666666666666666666666666666666") + all := parseConfigYAML(t, base) + if all.TokenPolicy.Scope() != tokenpolicy.All || all.TokenPolicy.RequiresSingleRoute(permissioned) { + t.Fatalf("default policy = %q", all.TokenPolicy.Scope()) + } + permissionedOnly := parseConfigYAML(t, base+"tokensToQuote: permissioned\n") + if !permissionedOnly.TokenPolicy.Allows(permissioned) || + !permissionedOnly.TokenPolicy.RequiresSingleRoute(permissioned) { + t.Fatal("permissioned scope did not admit and constrain configured token") + } + if _, err := parseConfig(parseYAMLNode(t, base+"tokensToQuote: bogus\n")); err == nil { + t.Fatal("expected invalid tokensToQuote error") + } +} + +func TestParseConfigEnablesPrivateDiscountsOnlyInInternalMode(t *testing.T) { + base := ` +orderServer: + baseUrl: https://order.example + wsUrl: wss://order.example + apiKeyEnv: LIFI_SOLVER_API_KEY +inputSettler: "0x2222222222222222222222222222222222222222" +outputSettler: "0x3333333333333333333333333333333333333333" +executor: "0x4444444444444444444444444444444444444444" +adapters: + - "0x5555555555555555555555555555555555555555" +gas: + nativeUsdFeed: "0x7777777777777777777777777777777777777777" + nativeMaxAge: 30m + tokenUsdFeeds: + - token: "0x6666666666666666666666666666666666666666" + feed: "0x8888888888888888888888888888888888888888" + maxAge: 1h +` + cfg := parseConfigYAML(t, base+"solverMode: internal\nprivateDiscountsUrl: https://rfq.example\n") + if !cfg.usesDiscounts() || cfg.DiscountsURL != "https://rfq.example" { + t.Fatalf("config = %+v", cfg) + } + + for _, raw := range []string{ + base + "solverMode: internal\n", + base + "privateDiscountsUrl: https://rfq.example\n", + } { + if _, err := parseConfig(parseYAMLNode(t, raw)); err == nil { + t.Fatalf("expected mode/url validation error for %q", raw) + } + } +} + +func TestParseConfigRejectsMissingAPIKeyEnv(t *testing.T) { + _, err := parseConfig(parseYAMLNode(t, ` +inputSettler: "0x2222222222222222222222222222222222222222" +outputSettler: "0x3333333333333333333333333333333333333333" +executor: "0x4444444444444444444444444444444444444444" +adapters: + - "0x5555555555555555555555555555555555555555" +`)) + if err == nil || !strings.Contains(err.Error(), "orderServer.apiKeyEnv is required") { + t.Fatalf("err = %v", err) + } +} + +func TestParseConfigRequiresOrderServerEndpoints(t *testing.T) { + const base = ` +orderServer: + apiKeyEnv: LIFI_SOLVER_API_KEY +%s +inputSettler: "0x2222222222222222222222222222222222222222" +outputSettler: "0x3333333333333333333333333333333333333333" +executor: "0x4444444444444444444444444444444444444444" +adapters: + - "0x5555555555555555555555555555555555555555" +` + for _, tc := range []struct { + name string + endpoint string + want string + }{ + {name: "base URL", endpoint: " wsUrl: wss://order.example", want: "orderServer.baseUrl is required"}, + {name: "websocket URL", endpoint: " baseUrl: https://order.example", want: "orderServer.wsUrl is required"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := parseConfig(parseYAMLNode(t, strings.Replace(base, "%s", tc.endpoint, 1))) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v", err) + } + }) + } +} + +func TestParseConfigRequiresGasOracleFeeds(t *testing.T) { + _, err := parseConfig(parseYAMLNode(t, ` +orderServer: + baseUrl: https://order.example + wsUrl: wss://order.example + apiKeyEnv: LIFI_SOLVER_API_KEY +inputSettler: "0x2222222222222222222222222222222222222222" +outputSettler: "0x3333333333333333333333333333333333333333" +executor: "0x4444444444444444444444444444444444444444" +adapters: + - "0x5555555555555555555555555555555555555555" +`)) + if err == nil || !strings.Contains(err.Error(), "gas.nativeUsdFeed") { + t.Fatalf("err = %v", err) + } +} + +func TestParseGasConfigRequiresPerFeedMaxAge(t *testing.T) { + const address = "0x7777777777777777777777777777777777777777" + for _, tc := range []struct { + name string + raw liquidlanegas.RawConfig + want string + }{ + { + name: "native", + raw: liquidlanegas.RawConfig{ + NativeUSDFeed: address, + TokenUSDFeeds: []liquidlanegas.RawTokenFeed{{Token: address, Feed: address, MaxAge: "1h"}}, + }, + want: "gas.nativeMaxAge is required", + }, + { + name: "token", + raw: liquidlanegas.RawConfig{ + NativeUSDFeed: address, + NativeMaxAge: "1h", + TokenUSDFeeds: []liquidlanegas.RawTokenFeed{{Token: address, Feed: address}}, + }, + want: "gas.tokenUsdFeeds[0].maxAge is required", + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := liquidlanegas.ParseConfig(tc.raw) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v", err) + } + }) + } +} + +func TestParseConfigDefaultsToBlockPollingAndShortTTL(t *testing.T) { + cfg := parseConfigYAML(t, ` +orderServer: + baseUrl: https://order.example + wsUrl: wss://order.example + apiKeyEnv: LIFI_SOLVER_API_KEY +inputSettler: "0x2222222222222222222222222222222222222222" +outputSettler: "0x3333333333333333333333333333333333333333" +executor: "0x4444444444444444444444444444444444444444" +adapters: + - "0x5555555555555555555555555555555555555555" +gas: + nativeUsdFeed: "0x7777777777777777777777777777777777777777" + nativeMaxAge: 30m + tokenUsdFeeds: + - token: "0x6666666666666666666666666666666666666666" + feed: "0x8888888888888888888888888888888888888888" + maxAge: 1h +`) + if cfg.QuoteInterval != time.Second { + t.Fatalf("quoteInterval = %s", cfg.QuoteInterval) + } + if cfg.QuoteRefreshMode != quoteRefreshModeBlock { + t.Fatalf("quoteRefreshMode = %q", cfg.QuoteRefreshMode) + } + if cfg.QuoteTTL != 36*time.Second { + t.Fatalf("quoteTTL = %s", cfg.QuoteTTL) + } +} + +func TestParseConfigRejectsQuoteTTLBelowTwiceRefreshInterval(t *testing.T) { + _, err := parseConfig(parseYAMLNode(t, ` +orderServer: + baseUrl: https://order.example + wsUrl: wss://order.example + apiKeyEnv: LIFI_SOLVER_API_KEY +inputSettler: "0x2222222222222222222222222222222222222222" +outputSettler: "0x3333333333333333333333333333333333333333" +executor: "0x4444444444444444444444444444444444444444" +adapters: + - "0x5555555555555555555555555555555555555555" +quoteIntervalMs: 30000 +quoteTtl: 30s +`)) + if err == nil || !strings.Contains(err.Error(), "quoteTtl must be at least twice quote interval") { + t.Fatalf("err = %v", err) + } +} + +func TestParseConfigRejectsDuplicateAdapters(t *testing.T) { + _, err := parseConfig(parseYAMLNode(t, ` +orderServer: + baseUrl: https://order.example + wsUrl: wss://order.example + apiKeyEnv: LIFI_SOLVER_API_KEY +inputSettler: "0x2222222222222222222222222222222222222222" +outputSettler: "0x3333333333333333333333333333333333333333" +executor: "0x4444444444444444444444444444444444444444" +adapters: + - "0x5555555555555555555555555555555555555555" + - "0x5555555555555555555555555555555555555555" +`)) + if err == nil || !strings.Contains(err.Error(), "duplicate adapter") { + t.Fatalf("err = %v", err) + } +} + +func TestParseConfigRejectsInvalidPermissionedTokens(t *testing.T) { + base := ` +orderServer: + baseUrl: https://order.example + wsUrl: wss://order.example + apiKeyEnv: LIFI_SOLVER_API_KEY +inputSettler: "0x2222222222222222222222222222222222222222" +outputSettler: "0x3333333333333333333333333333333333333333" +executor: "0x4444444444444444444444444444444444444444" +adapters: + - "0x5555555555555555555555555555555555555555" +permissionedTokens: +` + for _, entries := range []string{ + ` - "0x0000000000000000000000000000000000000000"`, + ` - "0x6666666666666666666666666666666666666666" + - "0x6666666666666666666666666666666666666666"`, + } { + if _, err := parseConfig(parseYAMLNode(t, base+entries+"\n")); err == nil { + t.Fatalf("expected permissionedTokens validation error for:\n%s", entries) + } + } +} + +func parseConfigYAML(t *testing.T, raw string) *Config { + t.Helper() + cfg, err := parseConfig(parseYAMLNode(t, raw)) + if err != nil { + t.Fatalf("parseConfig: %v", err) + } + return cfg +} + +func parseYAMLNode(t *testing.T, raw string) yaml.Node { + t.Helper() + var node yaml.Node + if err := yaml.Unmarshal([]byte(raw), &node); err != nil { + t.Fatalf("yaml: %v", err) + } + if len(node.Content) != 1 { + t.Fatalf("unexpected yaml document content len %d", len(node.Content)) + } + return *node.Content[0] +} + +func testTokenPolicy(t *testing.T, scope tokenpolicy.Scope, tokens ...common.Address) tokenpolicy.Policy { + t.Helper() + policy, err := tokenpolicy.New(scope, tokens) + if err != nil { + t.Fatalf("tokenpolicy.New: %v", err) + } + return policy +} diff --git a/internal/solvers/lifi/discounts.go b/internal/solvers/lifi/discounts.go new file mode 100644 index 00000000..00e24081 --- /dev/null +++ b/internal/solvers/lifi/discounts.go @@ -0,0 +1,136 @@ +package lifi + +import ( + "context" + "sort" + "time" + + "github.com/ethereum/go-ethereum/common" + "golang.org/x/sync/errgroup" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" +) + +const ( + maxPrivateDiscountsPerFill = 16 + maxConcurrentResolutions = 4 +) + +func (s *Solver) quoteDiscountInventories( + ctx context.Context, + bases []liquidlane.Inventory, + now time.Time, +) []liquidlane.Inventory { + if s.discounts == nil { + return nil + } + listed, err := s.discounts.ListDiscounts(ctx) + if err != nil { + s.log.Error(err, "private discounts: list for quote") + return nil + } + inventory, issues := discounts.MatchInventories(listed, bases, discounts.MatchOptions{Now: now}) + s.logDiscountIssues(issues) + return inventory +} + +func (s *Solver) fillDiscountQuotes( + ctx context.Context, + bases []liquidlane.FillQuote, + now time.Time, +) ([]liquidlane.FillQuote, map[common.Hash]*discounts.Signed) { + if s.discounts == nil || len(bases) == 0 { + return nil, nil + } + inventory := make([]liquidlane.Inventory, 0, len(bases)) + baseByRoute := make(map[liquidlane.RouteID]liquidlane.FillQuote, len(bases)) + for _, quote := range bases { + inventory = append(inventory, quote.Inventory) + baseByRoute[quote.ID] = quote + } + listed, err := s.discounts.ListDiscounts(ctx) + if err != nil { + s.log.Error(err, "private discounts: list for fill") + return nil, nil + } + candidates, issues := discounts.MatchInventories(listed, inventory, discounts.MatchOptions{Now: now}) + s.logDiscountIssues(issues) + sort.Slice(candidates, func(i, j int) bool { + if cmp := candidates[i].MaxRate.Cmp(candidates[j].MaxRate); cmp != 0 { + return cmp > 0 + } + return candidates[i].DiscountID.Hex() < candidates[j].DiscountID.Hex() + }) + if len(candidates) > maxPrivateDiscountsPerFill { + candidates = candidates[:maxPrivateDiscountsPerFill] + } + + type resolution struct { + quote *liquidlane.FillQuote + signed *discounts.Signed + } + resolutions := make([]resolution, len(candidates)) + g, resolveCtx := errgroup.WithContext(ctx) + g.SetLimit(maxConcurrentResolutions) + for i, candidate := range candidates { + g.Go(func() error { + if candidate.DiscountID == nil { + return nil + } + baseQuote, ok := baseByRoute[candidate.ID] + if !ok { + return nil + } + selection := discounts.Selection{ + DiscountID: *candidate.DiscountID, + Adapter: candidate.Adapter, TokenIn: candidate.TokenIn, + } + resolved, resolveErr := s.discounts.Resolve(resolveCtx, candidate.DiscountID.Hex()) + if resolveErr != nil { + s.log.Error(resolveErr, "private discounts: resolve", "discountId", candidate.DiscountID.Hex()) + return nil + } + signed, validateErr := discounts.ParseAndValidate(resolved, selection, baseQuote, now) + if validateErr != nil { + s.logInvalidDiscount(candidate.DiscountID.Hex(), validateErr) + return nil + } + maxAmountOut := liquidlane.AmountOutAfterDiscount(baseQuote.GrossAmountOut, signed.Terms.Discount) + if maxAmountOut.Sign() <= 0 { + return nil + } + candidate.ValidUntil = discounts.ValidUntil(signed) + quote := &liquidlane.FillQuote{ + Inventory: candidate, + AmountIn: liquidlane.CloneBig(baseQuote.AmountIn), + GrossAmountOut: liquidlane.CloneBig(baseQuote.GrossAmountOut), + MaxAmountOut: maxAmountOut, + MinDiscount: liquidlane.CloneBig(baseQuote.MinDiscount), + } + resolutions[i] = resolution{quote: quote, signed: signed} + return nil + }) + } + _ = g.Wait() + quotes := make([]liquidlane.FillQuote, 0, len(candidates)) + resolvedByID := make(map[common.Hash]*discounts.Signed, len(candidates)) + for _, resolution := range resolutions { + if resolution.quote == nil || resolution.signed == nil { + continue + } + quotes = append(quotes, *resolution.quote) + resolvedByID[resolution.signed.DiscountID] = resolution.signed + } + return quotes, resolvedByID +} + +func (s *Solver) logInvalidDiscount(discountID string, err error) { + s.log.V(1).Info("private discounts: ignored", "discountId", discountID, "error", err.Error()) +} + +func (s *Solver) logDiscountIssues(issues []discounts.OfferIssue) { + for _, issue := range issues { + s.logInvalidDiscount(issue.DiscountID, issue.Err) + } +} diff --git a/internal/solvers/lifi/discounts_test.go b/internal/solvers/lifi/discounts_test.go new file mode 100644 index 00000000..60140a08 --- /dev/null +++ b/internal/solvers/lifi/discounts_test.go @@ -0,0 +1,192 @@ +package lifi + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" +) + +const testDiscountID = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +type fakeDiscountClient struct { + listed *discounts.List + resolved *discounts.Resolved + listCalls int + resolveCalls int +} + +func (f *fakeDiscountClient) ListDiscounts(context.Context) (*discounts.List, error) { + f.listCalls++ + return f.listed, nil +} + +func (f *fakeDiscountClient) Resolve(context.Context, string) (*discounts.Resolved, error) { + f.resolveCalls++ + return f.resolved, nil +} + +func TestFillDiscountQuotesUsesFreshSignedTerms(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + directInventory := testDirectDiscountInventory() + direct := liquidlane.FillQuote{ + Inventory: directInventory, AmountIn: big.NewInt(1_000), + GrossAmountOut: big.NewInt(1_000), MaxAmountOut: big.NewInt(900), MinDiscount: big.NewInt(100_000), + } + fake := &fakeDiscountClient{ + listed: &discounts.List{Discounts: []discounts.ListItem{ + testDiscountListItem(directInventory, 1_000, now.Add(time.Minute)), + }}, + resolved: testResolvedDiscount(directInventory, 100_000, now.Add(time.Minute)), + } + s := &Solver{discounts: fake, log: logr.Discard()} + + quotes, resolved := s.fillDiscountQuotes(context.Background(), []liquidlane.FillQuote{direct}, now) + if len(quotes) != 1 || quotes[0].MaxAmountOut.String() != "900" || + !quotes[0].ValidUntil.Equal(now.Add(time.Minute)) { + t.Fatalf("quotes = %+v", quotes) + } + id := common.HexToHash(testDiscountID) + if resolved[id] == nil || fake.listCalls != 1 || fake.resolveCalls != 1 { + t.Fatalf("resolved = %+v calls=%d/%d", resolved, fake.listCalls, fake.resolveCalls) + } +} + +func TestFillDiscountQuotesRejectsResolvedTermsBelowAdapterMinimum(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + directInventory := testDirectDiscountInventory() + fake := &fakeDiscountClient{ + listed: &discounts.List{Discounts: []discounts.ListItem{ + testDiscountListItem(directInventory, 1_000, now.Add(time.Minute)), + }}, + resolved: testResolvedDiscount(directInventory, 50_000, now.Add(time.Minute)), + } + s := &Solver{discounts: fake, log: logr.Discard()} + direct := liquidlane.FillQuote{ + Inventory: directInventory, AmountIn: big.NewInt(1_000), + GrossAmountOut: big.NewInt(1_000), MaxAmountOut: big.NewInt(900), MinDiscount: big.NewInt(100_000), + } + + quotes, resolved := s.fillDiscountQuotes(context.Background(), []liquidlane.FillQuote{direct}, now) + if len(quotes) != 0 || len(resolved) != 0 { + t.Fatalf("unsafe discount survived: quotes=%+v resolved=%+v", quotes, resolved) + } +} + +func TestRefreshResolvedDiscountQuotesUsesFreshAdapterState(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + direct := testDirectDiscountInventory() + id := common.HexToHash(testDiscountID) + candidateInventory := liquidlane.DiscountInventory( + direct.Route, big.NewInt(1_000), direct.MaxRate, id, now.Add(time.Minute), + ) + candidateInventory.AdapterMinDiscount = big.NewInt(100_000) + candidate := liquidlane.FillQuote{ + Inventory: candidateInventory, + AmountIn: big.NewInt(1_000), GrossAmountOut: big.NewInt(1_000), MaxAmountOut: big.NewInt(900), + MinDiscount: big.NewInt(100_000), + } + fresh := liquidlane.FillQuote{ + Inventory: direct, AmountIn: big.NewInt(800), GrossAmountOut: big.NewInt(880), + MaxAmountOut: big.NewInt(792), MinDiscount: big.NewInt(100_000), + } + fresh.MaxAssets = big.NewInt(700) + signed, err := discounts.ParseSigned(testResolvedDiscount(direct, 100_000, now.Add(time.Minute))) + if err != nil { + t.Fatalf("ParseSigned: %v", err) + } + + got, issues := discounts.RefreshFillQuotes( + []liquidlane.FillQuote{candidate}, map[common.Hash]*discounts.Signed{id: signed}, + []liquidlane.FillQuote{fresh}, now, + ) + if len(issues) != 0 || len(got) != 1 { + t.Fatalf("quotes = %+v issues = %+v", got, issues) + } + if got[0].AmountIn.String() != "800" || got[0].GrossAmountOut.String() != "880" || + got[0].MaxAmountOut.String() != "792" || got[0].MaxAssets.String() != "700" { + t.Fatalf("refreshed quote = %+v", got[0]) + } +} + +func TestRefreshResolvedDiscountQuotesRejectsRateAboveFreshAdapterLimit(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + direct := testDirectDiscountInventory() + id := common.HexToHash(testDiscountID) + candidateInventory := liquidlane.DiscountInventory( + direct.Route, big.NewInt(1_000), direct.MaxRate, id, now.Add(time.Minute), + ) + candidateInventory.AdapterMinDiscount = big.NewInt(100_000) + candidate := liquidlane.FillQuote{ + Inventory: candidateInventory, + AmountIn: big.NewInt(1_000), GrossAmountOut: big.NewInt(1_000), MaxAmountOut: big.NewInt(900), + MinDiscount: big.NewInt(100_000), + } + fresh := candidate + fresh.Inventory = direct + fresh.MaxRate = big.NewInt(800_000_000_000_000_000) + signed, err := discounts.ParseSigned(testResolvedDiscount(direct, 100_000, now.Add(time.Minute))) + if err != nil { + t.Fatalf("ParseSigned: %v", err) + } + + got, _ := discounts.RefreshFillQuotes( + []liquidlane.FillQuote{candidate}, map[common.Hash]*discounts.Signed{id: signed}, + []liquidlane.FillQuote{fresh}, now, + ) + if len(got) != 0 { + t.Fatalf("unsafe quote survived: %+v", got) + } +} + +func testDirectDiscountInventory() liquidlane.Inventory { + routeItem := liquidlane.NewRoute( + 11155111, + common.HexToAddress("0x1111111111111111111111111111111111111111"), + common.HexToAddress("0x2222222222222222222222222222222222222222"), + common.HexToAddress("0x3333333333333333333333333333333333333333"), + common.HexToAddress("0x4444444444444444444444444444444444444444"), + 6, + 6, + ) + inventory := liquidlane.DirectInventory(routeItem, big.NewInt(1_000), big.NewInt(900_000_000_000_000_000)) + inventory.AdapterMinDiscount = big.NewInt(100_000) + return inventory +} + +func testDiscountListItem( + direct liquidlane.Inventory, + maxAssets int64, + deadline time.Time, +) discounts.ListItem { + return discounts.ListItem{ + DiscountID: testDiscountID, + Adapter: direct.Adapter.Hex(), TokenToRedeem: direct.TokenIn.Hex(), Collateral: direct.TokenOut.Hex(), + CollateralDecimals: direct.TokenOutDecimals, Deadline: deadline.Unix(), + Discount: "100000", + MaxRate: direct.MaxRate.String(), MaxAssets: big.NewInt(maxAssets).String(), + } +} + +func testResolvedDiscount( + direct liquidlane.Inventory, + discount int64, + deadline time.Time, +) *discounts.Resolved { + return &discounts.Resolved{ + DiscountID: testDiscountID, + Discount: discounts.Terms{ + Adapter: direct.Adapter.Hex(), TokenToRedeem: direct.TokenIn.Hex(), Discount: big.NewInt(discount).String(), + Signer: common.HexToAddress("0x5555555555555555555555555555555555555555").Hex(), + Protocol: common.HexToAddress("0x6666666666666666666666666666666666666666").Hex(), + Nonce: "0x1", Deadline: deadline.Unix(), + }, + SignerSignature: "0x1234", ProtocolDeadline: deadline.Unix(), ProtocolSignature: "0x5678", + } +} diff --git a/internal/solvers/lifi/execution.go b/internal/solvers/lifi/execution.go new file mode 100644 index 00000000..0124ca40 --- /dev/null +++ b/internal/solvers/lifi/execution.go @@ -0,0 +1,231 @@ +package lifi + +import ( + "context" + "sync" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/go-errors/errors" + "golang.org/x/sync/errgroup" + + "github.com/symbioticfi/vault-solver/internal/txmanager" +) + +const ( + fillCompletionCapacity = 128 + orderInboxCapacity = 1_024 +) + +var errOrderInboxFull = errors.New("order inbox is full") + +type pendingFill struct { + order *submittedOrder + orderID common.Hash + reservationKey string + result <-chan txmanager.Result +} + +type fillCompletion struct { + fill *pendingFill + result txmanager.Result +} + +type pendingFillState struct { + byOrder map[string]*pendingFill +} + +// orderInbox keeps the WebSocket reader independent from slower on-chain planning. +// The feed is the only producer and run is the only consumer. +type orderInbox struct { + mu sync.Mutex + orders []*submittedOrder + queued map[string]bool + capacity int + ready chan struct{} +} + +func newOrderInbox(capacity int) *orderInbox { + if capacity <= 0 { + panic("lifi: order inbox capacity must be positive") + } + return &orderInbox{ + queued: make(map[string]bool), capacity: capacity, ready: make(chan struct{}, 1), + } +} + +func (q *orderInbox) enqueue(order *submittedOrder) error { + if order == nil { + return nil + } + key := orderInboxKey(order) + q.mu.Lock() + if key != "" && q.queued[key] { + q.mu.Unlock() + return nil + } + if len(q.orders) >= q.capacity { + q.mu.Unlock() + return errOrderInboxFull + } + q.orders = append(q.orders, order) + if key != "" { + q.queued[key] = true + } + q.mu.Unlock() + select { + case q.ready <- struct{}{}: + default: + } + return nil +} + +func (q *orderInbox) run(ctx context.Context, out chan<- *submittedOrder) error { + defer close(out) + for { + q.mu.Lock() + if len(q.orders) == 0 { + q.mu.Unlock() + select { + case <-ctx.Done(): + return ctx.Err() + case <-q.ready: + continue + } + } + order := q.orders[0] + q.orders[0] = nil + q.orders = q.orders[1:] + if len(q.orders) == 0 { + q.orders = nil + } + q.mu.Unlock() + select { + case <-ctx.Done(): + return ctx.Err() + case out <- order: + } + if key := orderInboxKey(order); key != "" { + q.mu.Lock() + delete(q.queued, key) + q.mu.Unlock() + } + } +} + +func orderInboxKey(order *submittedOrder) string { + if order.OnChainOrderID != "" { + return order.OnChainOrderID + } + return order.OrderID +} + +func (s *Solver) runOrderFeed(ctx context.Context, routes []route) error { + inbox := newOrderInbox(orderInboxCapacity) + orders := make(chan *submittedOrder) + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { + return s.feed.run(gctx, func(_ context.Context, msg orderMessage) { + order := s.parseOrderMessage(msg) + if err := inbox.enqueue(order); err != nil { + s.log.Error(err, "order feed: dropped order", "event", msg.Event) + } + }) + }) + g.Go(func() error { return inbox.run(gctx, orders) }) + g.Go(func() error { return s.runOrderWorker(gctx, routes, orders) }) + return g.Wait() +} + +func (s *Solver) parseOrderMessage(msg orderMessage) *submittedOrder { + order, err := parseSubmittedOrder(msg.Data, s.cfg, s.chainID) + if err != nil { + s.log.Error(err, "order feed: ignored order", "event", msg.Event) + return nil + } + if isDutchAuctionContext(order.Output.Context) { + s.log.Info("order feed: ignored unsupported Dutch auction", + "event", msg.Event, + "orderId", order.OrderID, + "onChainOrderId", order.OnChainOrderID, + "quoteId", order.QuoteID, + "contextType", hexutil.Encode(order.Output.Context[:1]), + ) + return nil + } + s.log.Info("order received", + "event", msg.Event, + "orderStatus", order.OrderStatus, + "orderId", order.OrderID, + "onChainOrderId", order.OnChainOrderID, + "quoteId", order.QuoteID, + "inputSettler", order.InputSettler.Hex(), + ) + return order +} + +func (s *Solver) runOrderWorker( + ctx context.Context, + routes []route, + orders <-chan *submittedOrder, +) error { + pending := pendingFillState{byOrder: make(map[string]*pendingFill)} + completions := make(chan fillCompletion, fillCompletionCapacity) + for orders != nil || pending.len() > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case completion := <-completions: + s.completeFill(&pending, completion) + case order, ok := <-orders: + if !ok { + orders = nil + continue + } + fill := s.processOrderWithPending(ctx, routes, order, &pending) + if fill == nil { + continue + } + pending.add(fill) + go awaitFill(ctx, fill, completions) + } + } + return nil +} + +func awaitFill(ctx context.Context, fill *pendingFill, completions chan<- fillCompletion) { + select { + case result, ok := <-fill.result: + if !ok { + result.Err = errors.New("transaction result channel closed without a result") + } + select { + case completions <- fillCompletion{fill: fill, result: result}: + case <-ctx.Done(): + } + case <-ctx.Done(): + } +} + +func (s *pendingFillState) len() int { + if s == nil { + return 0 + } + return len(s.byOrder) +} + +func (s *pendingFillState) contains(key string) bool { + if s == nil { + return false + } + _, ok := s.byOrder[key] + return ok +} + +func (s *pendingFillState) add(fill *pendingFill) { + s.byOrder[fill.reservationKey] = fill +} + +func (s *pendingFillState) remove(key string) { + delete(s.byOrder, key) +} diff --git a/internal/solvers/lifi/execution_test.go b/internal/solvers/lifi/execution_test.go new file mode 100644 index 00000000..8dce9592 --- /dev/null +++ b/internal/solvers/lifi/execution_test.go @@ -0,0 +1,131 @@ +package lifi + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/go-errors/errors" + "github.com/go-logr/logr/funcr" + + "github.com/symbioticfi/vault-solver/internal/txmanager" +) + +func TestOrderInboxDoesNotBlockAndPreservesOrder(t *testing.T) { + const count = 5_000 + inbox := newOrderInbox(count) + + enqueued := make(chan struct{}) + go func() { + for i := range count { + if err := inbox.enqueue(&submittedOrder{OrderID: strconv.Itoa(i)}); err != nil { + t.Errorf("enqueue %d: %v", i, err) + return + } + } + close(enqueued) + }() + select { + case <-enqueued: + case <-time.After(time.Second): + t.Fatal("enqueue blocked without a consumer") + } + + orders := make(chan *submittedOrder) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- inbox.run(ctx, orders) }() + for i := range count { + order := <-orders + if order.OrderID != strconv.Itoa(i) { + t.Fatalf("order %d = %s", i, order.OrderID) + } + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("inbox did not stop after cancellation") + } +} + +func TestParseOrderMessageIgnoresDutchAuctions(t *testing.T) { + tests := []byte{dutchAuctionContextType, exclusiveDutchAuctionContextType} + for _, contextType := range tests { + t.Run(hexutil.Encode([]byte{contextType}), func(t *testing.T) { + cfg := testLifiConfig() + var body map[string]any + if err := json.Unmarshal(testOrderJSON( + t, + cfg, + common.HexToAddress("0x6666666666666666666666666666666666666666"), + common.HexToAddress("0x7777777777777777777777777777777777777777"), + ), &body); err != nil { + t.Fatalf("unmarshal order: %v", err) + } + output := sliceField(t, mapField(t, body, "order"), "outputs")[0].(map[string]any) + output["context"] = hexutil.Encode([]byte{contextType}) + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal order: %v", err) + } + + var logs []string + solver := &Solver{ + cfg: cfg, + chainID: 11155111, + log: funcr.NewJSON(func(entry string) { logs = append(logs, entry) }, funcr.Options{}), + } + if order := solver.parseOrderMessage(orderMessage{Event: orderSubmitEvent, Data: raw}); order != nil { + t.Fatalf("parseOrderMessage() = %+v, want ignored order", order) + } + logged := strings.Join(logs, "\n") + if !strings.Contains(logged, "ignored unsupported Dutch auction") || + !strings.Contains(logged, hexutil.Encode([]byte{contextType})) { + t.Fatalf("unsupported auction log = %s", logged) + } + }) + } +} + +func TestOrderInboxCoalescesQueuedReplay(t *testing.T) { + inbox := newOrderInbox(2) + first := &submittedOrder{OrderID: "api-1", OnChainOrderID: "chain-1"} + if err := inbox.enqueue(first); err != nil { + t.Fatal(err) + } + if err := inbox.enqueue(&submittedOrder{OrderID: "api-2", OnChainOrderID: "chain-1"}); err != nil { + t.Fatal(err) + } + if len(inbox.orders) != 1 { + t.Fatalf("queued orders = %d, want 1", len(inbox.orders)) + } +} + +func TestOrderInboxRejectsOverflow(t *testing.T) { + inbox := newOrderInbox(1) + if err := inbox.enqueue(&submittedOrder{OrderID: "first"}); err != nil { + t.Fatal(err) + } + if err := inbox.enqueue(&submittedOrder{OrderID: "second"}); !errors.Is(err, errOrderInboxFull) { + t.Fatalf("enqueue error = %v, want %v", err, errOrderInboxFull) + } +} + +func TestAwaitFillTreatsClosedResultChannelAsFailure(t *testing.T) { + results := make(chan txmanager.Result) + close(results) + fill := &pendingFill{result: results} + completions := make(chan fillCompletion, 1) + + awaitFill(t.Context(), fill, completions) + completion := <-completions + if completion.result.Err == nil { + t.Fatal("closed transaction result channel was treated as a successful fill") + } +} diff --git a/internal/solvers/lifi/fill.go b/internal/solvers/lifi/fill.go new file mode 100644 index 00000000..85336435 --- /dev/null +++ b/internal/solvers/lifi/fill.go @@ -0,0 +1,157 @@ +package lifi + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/api/bindings/lifi/executor" + "github.com/symbioticfi/vault-solver/api/bindings/lifi/inputsettler" + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" +) + +var lifiExecutor = executor.NewLiquidLaneLifiExecutor() + +type fillCalldata struct { + OrderID common.Hash + Finalise []byte +} + +func executorRoutes( + order submittedOrder, + plan *types.FillPlan, + resolvedDiscounts map[common.Hash]*discounts.Signed, +) ([]executor.ILiquidLaneLifiExecutorFillRoute, error) { + if plan == nil || len(plan.Routes) == 0 { + return nil, errors.New("fill plan has no routes") + } + routes := make([]executor.ILiquidLaneLifiExecutorFillRoute, 0, len(plan.Routes)) + totalAmountIn := new(big.Int) + for i, route := range plan.Routes { + if route.Adapter == (common.Address{}) || route.AmountIn == nil || route.AmountIn.Sign() <= 0 || + route.ExpectedAmountOut == nil || route.ExpectedAmountOut.Sign() <= 0 || + route.MinAmountOut == nil || route.MinAmountOut.Sign() <= 0 || + route.MinAmountOut.Cmp(route.ExpectedAmountOut) > 0 { + return nil, errors.Errorf("fill plan route %d is invalid", i) + } + discount, err := executorDiscount(route, order.TokenIn, resolvedDiscounts) + if err != nil { + return nil, errors.Errorf("fill plan route %d discount: %w", i, err) + } + routes = append(routes, executor.ILiquidLaneLifiExecutorFillRoute{ + Adapter: route.Adapter, AmountIn: route.AmountIn, AmountOut: route.ExpectedAmountOut, + Discount: discount, + }) + totalAmountIn.Add(totalAmountIn, route.AmountIn) + } + if totalAmountIn.Cmp(order.AmountIn) != 0 { + return nil, errors.Errorf("fill plan input sum %s does not match order input %s", totalAmountIn, order.AmountIn) + } + return routes, nil +} + +func executorDiscount( + route types.FillRoute, + tokenIn common.Address, + resolvedDiscounts map[common.Hash]*discounts.Signed, +) (executor.ILiquidLaneLifiExecutorFillDiscount, error) { + if route.DiscountID == nil { + return emptyExecutorDiscount(), nil + } + if *route.DiscountID == (common.Hash{}) { + return executor.ILiquidLaneLifiExecutorFillDiscount{}, errors.New("discount id is zero") + } + resolved := resolvedDiscounts[*route.DiscountID] + if resolved == nil { + return executor.ILiquidLaneLifiExecutorFillDiscount{}, errors.New("resolved discount is missing") + } + if resolved.DiscountID != *route.DiscountID { + return executor.ILiquidLaneLifiExecutorFillDiscount{}, errors.New("resolved discount id mismatch") + } + if resolved.Adapter != route.Adapter { + return executor.ILiquidLaneLifiExecutorFillDiscount{}, errors.New("resolved discount adapter mismatch") + } + if resolved.Terms.TokenToRedeem != tokenIn { + return executor.ILiquidLaneLifiExecutorFillDiscount{}, errors.New("resolved discount token mismatch") + } + return executor.ILiquidLaneLifiExecutorFillDiscount{ + DiscountId: [32]byte(resolved.DiscountID), + DiscountSwap: executor.ILiquidLaneAdapterDiscountSwap{ + Discount: executor.ILiquidLaneAdapterDiscount{ + TokenToRedeem: resolved.Terms.TokenToRedeem, + Discount: liquidlane.CloneBig(resolved.Terms.Discount), + Signer: resolved.Terms.Signer, + Protocol: resolved.Terms.Protocol, + Nonce: liquidlane.CloneBig(resolved.Terms.Nonce), + Deadline: liquidlane.CloneBig(resolved.Terms.Deadline), + }, + SignerSignature: append([]byte(nil), resolved.SignerSignature...), + ProtocolDeadline: liquidlane.CloneBig(resolved.ProtocolDeadline), + }, + ProtocolSignature: append([]byte(nil), resolved.ProtocolSignature...), + }, nil +} + +func emptyExecutorDiscount() executor.ILiquidLaneLifiExecutorFillDiscount { + return executor.ILiquidLaneLifiExecutorFillDiscount{ + DiscountSwap: executor.ILiquidLaneAdapterDiscountSwap{ + Discount: executor.ILiquidLaneAdapterDiscount{ + Discount: new(big.Int), Nonce: new(big.Int), Deadline: new(big.Int), + }, + ProtocolDeadline: new(big.Int), + }, + } +} + +func buildFillCalldata( + order submittedOrder, + orderID common.Hash, + plan *types.FillPlan, + resolvedDiscounts map[common.Hash]*discounts.Signed, +) (*fillCalldata, error) { + routes, err := executorRoutes(order, plan, resolvedDiscounts) + if err != nil { + return nil, err + } + finaliseCalldata, err := lifiExecutor.TryPackFinaliseWithCurrentTimestamp( + toExecutorOrder(order.Order), + routes, + ) + if err != nil { + return nil, errors.Errorf("pack finaliseWithCurrentTimestamp: %w", err) + } + return &fillCalldata{OrderID: orderID, Finalise: finaliseCalldata}, nil +} + +func toExecutorOutput(output inputsettler.MandateOutput) executor.MandateOutput { + return executor.MandateOutput{ + Oracle: output.Oracle, + Settler: output.Settler, + ChainId: output.ChainId, + Token: output.Token, + Amount: output.Amount, + Recipient: output.Recipient, + CallbackData: output.CallbackData, + Context: output.Context, + } +} + +func toExecutorOrder(order inputsettler.StandardOrder) executor.IInputSettlerStandardOrder { + outputs := make([]executor.MandateOutput, 0, len(order.Outputs)) + for _, out := range order.Outputs { + outputs = append(outputs, toExecutorOutput(out)) + } + return executor.IInputSettlerStandardOrder{ + User: order.User, + Nonce: order.Nonce, + OriginChainId: order.OriginChainId, + Expires: order.Expires, + FillDeadline: order.FillDeadline, + InputOracle: order.InputOracle, + Inputs: order.Inputs, + Outputs: outputs, + } +} diff --git a/internal/solvers/lifi/fill_test.go b/internal/solvers/lifi/fill_test.go new file mode 100644 index 00000000..34109ae9 --- /dev/null +++ b/internal/solvers/lifi/fill_test.go @@ -0,0 +1,225 @@ +package lifi + +import ( + "bytes" + "context" + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/symbioticfi/vault-solver/api/bindings/lifi/executor" + "github.com/symbioticfi/vault-solver/api/bindings/lifi/inputsettler" + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" +) + +type fakeLifiReader struct { + orderID common.Hash + orderIDFn func(inputsettler.StandardOrder) common.Hash + status uint8 + statusErr error + latestBlock uint64 + latestBlockErr error + fill []liquidlane.FillQuote + fillSet *fillSnapshotSet + fillSetFn func() fillSnapshotSet + fillSnapshotsFn func() []liquidlane.FillQuote + routes []route + executorErr error + directAuthErr error + governanceFeeErr error +} + +func (f fakeLifiReader) resolveRoutes(context.Context, []common.Address) ([]route, error) { + return f.routes, nil +} + +func (f fakeLifiReader) validateExecutor( + context.Context, common.Address, common.Address, common.Address, common.Address, +) error { + return f.executorErr +} + +func (f fakeLifiReader) validateZeroGovernanceFee(context.Context, common.Address) error { + return f.governanceFeeErr +} + +func (f fakeLifiReader) validateDirectAuthorization(context.Context, common.Address, []route) error { + return f.directAuthErr +} + +func (f fakeLifiReader) validateGasTokens([]route) error { return nil } + +func (f fakeLifiReader) quoteSnapshots(context.Context, []route, common.Address, time.Time) (quoteSnapshotSet, error) { + return quoteSnapshotSet{}, nil +} + +func (f fakeLifiReader) fillSnapshots( + context.Context, []route, common.Address, common.Address, *big.Int, time.Time, +) (fillSnapshotSet, error) { + if f.fillSetFn != nil { + return withFakeGasPrices(f.fillSetFn()), nil + } + if f.fillSet != nil { + return withFakeGasPrices(*f.fillSet), nil + } + if f.fillSnapshotsFn != nil { + fill := f.fillSnapshotsFn() + return withFakeGasPrices(fillSnapshotSet{Direct: fill, Physical: fill}), nil + } + return withFakeGasPrices(fillSnapshotSet{Direct: f.fill, Physical: f.fill}), nil +} + +func withFakeGasPrices(set fillSnapshotSet) fillSnapshotSet { + rates := make(map[common.Address]*big.Int) + for _, quote := range append(append([]liquidlane.FillQuote(nil), set.Direct...), set.Physical...) { + rates[quote.TokenOut] = big.NewInt(1) + } + set.GasPrices = liquidlanegas.NewPriceSnapshot(rates) + return set +} + +func (f fakeLifiReader) orderIdentifier( + _ context.Context, + _ common.Address, + order inputsettler.StandardOrder, +) (common.Hash, error) { + if f.orderIDFn != nil { + return f.orderIDFn(order), nil + } + return f.orderID, nil +} + +func (f fakeLifiReader) orderStatus(context.Context, common.Address, common.Hash) (uint8, error) { + return f.status, f.statusErr +} + +func (f fakeLifiReader) latestBlockNumber(context.Context) (uint64, error) { + return f.latestBlock, f.latestBlockErr +} + +func (f fakeLifiReader) latestBlockTime(context.Context) (time.Time, error) { + return time.Unix(1_700_000_000, 0), nil +} + +func TestBuildFillCalldata(t *testing.T) { + cfg := testLifiConfig() + tokenIn := common.HexToAddress("0x6666666666666666666666666666666666666666") + tokenOut := common.HexToAddress("0x7777777777777777777777777777777777777777") + submitted, err := parseSubmittedOrder(testOrderJSON(t, cfg, tokenIn, tokenOut), cfg, 11155111) + if err != nil { + t.Fatalf("parseSubmittedOrder: %v", err) + } + orderID := common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + plan := &types.FillPlan{ + Routes: []types.FillRoute{{ + RouteID: "route-1", CapacityID: "capacity-1", + Adapter: common.HexToAddress("0x9999999999999999999999999999999999999999"), + AmountIn: submitted.AmountIn, ExpectedAmountOut: big.NewInt(1_000_000), + MinAmountOut: big.NewInt(990_000), + }}, + } + + calldata, err := buildFillCalldata(*submitted, orderID, plan, nil) + if err != nil { + t.Fatalf("buildFillCalldata: %v", err) + } + if calldata.OrderID != orderID { + t.Fatalf("order id = %s", calldata.OrderID) + } + + executorABI, err := executor.LiquidLaneLifiExecutorMetaData.ParseABI() + if err != nil { + t.Fatalf("parse executor ABI: %v", err) + } + method := executorABI.Methods["finaliseWithCurrentTimestamp"] + if !bytes.Equal(calldata.Finalise[:4], method.ID) { + t.Fatalf("finalise selector = %s, want %s", hexutil.Encode(calldata.Finalise[:4]), hexutil.Encode(method.ID)) + } + args, err := method.Inputs.Unpack(calldata.Finalise[4:]) + if err != nil { + t.Fatalf("unpack finaliseWithCurrentTimestamp: %v", err) + } + if len(args) != 2 { + t.Fatalf("finalise arguments = %d, want order and routes", len(args)) + } + encodedOrder := *abi.ConvertType( + args[0], new(executor.IInputSettlerStandardOrder), + ).(*executor.IInputSettlerStandardOrder) + if encodedOrder.User != submitted.Order.User || encodedOrder.Nonce.Cmp(submitted.Order.Nonce) != 0 || + len(encodedOrder.Outputs) != 1 || encodedOrder.Outputs[0].Amount.Cmp(submitted.OutputAmount) != 0 { + t.Fatalf("encoded order = %+v", encodedOrder) + } + routes := *abi.ConvertType( + args[1], new([]executor.ILiquidLaneLifiExecutorFillRoute), + ).(*[]executor.ILiquidLaneLifiExecutorFillRoute) + if len(routes) != 1 || routes[0].Adapter != plan.Routes[0].Adapter || + routes[0].AmountIn.Cmp(plan.Routes[0].AmountIn) != 0 || + routes[0].AmountOut.Cmp(plan.Routes[0].ExpectedAmountOut) != 0 { + t.Fatalf("fill routes = %+v", routes) + } +} + +func TestExecutorRoutesRejectsInputMismatch(t *testing.T) { + order := submittedOrder{AmountIn: big.NewInt(100)} + plan := &types.FillPlan{Routes: []types.FillRoute{{ + Adapter: common.HexToAddress("0x9999999999999999999999999999999999999999"), + AmountIn: big.NewInt(99), + ExpectedAmountOut: big.NewInt(90), + MinAmountOut: big.NewInt(80), + }}} + + _, err := executorRoutes(order, plan, nil) + if err == nil || !strings.Contains(err.Error(), "input sum 99 does not match order input 100") { + t.Fatalf("executorRoutes() error = %v", err) + } +} + +func TestExecutorRoutesIncludesResolvedPrivateDiscount(t *testing.T) { + cfg := testLifiConfig() + tokenIn := common.HexToAddress("0x6666666666666666666666666666666666666666") + tokenOut := common.HexToAddress("0x7777777777777777777777777777777777777777") + adapter := common.HexToAddress("0x9999999999999999999999999999999999999999") + submitted, err := parseSubmittedOrder(testOrderJSON(t, cfg, tokenIn, tokenOut), cfg, 11155111) + if err != nil { + t.Fatalf("parseSubmittedOrder: %v", err) + } + discountID := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + plan := &types.FillPlan{Routes: []types.FillRoute{{ + RouteID: "route-1", Adapter: adapter, AmountIn: submitted.AmountIn, + ExpectedAmountOut: big.NewInt(900_000), MinAmountOut: big.NewInt(850_000), DiscountID: &discountID, + }}} + resolved := &discounts.Signed{ + DiscountID: discountID, Adapter: adapter, + Terms: discounts.SignedTerms{ + TokenToRedeem: tokenIn, Discount: big.NewInt(100_000), + Signer: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Protocol: common.HexToAddress("0x2222222222222222222222222222222222222222"), + Nonce: big.NewInt(7), Deadline: big.NewInt(1_900_000_000), + }, + SignerSignature: []byte{0x12, 0x34}, ProtocolDeadline: big.NewInt(1_900_000_001), + ProtocolSignature: []byte{0x56, 0x78}, + } + + routes, err := executorRoutes( + *submitted, plan, + map[common.Hash]*discounts.Signed{discountID: resolved}, + ) + if err != nil { + t.Fatalf("executorRoutes: %v", err) + } + discount := routes[0].Discount + if common.Hash(discount.DiscountId) != discountID || + discount.DiscountSwap.Discount.TokenToRedeem != tokenIn || + discount.DiscountSwap.Discount.Discount.Cmp(big.NewInt(100_000)) != 0 || + !bytes.Equal(discount.ProtocolSignature, resolved.ProtocolSignature) { + t.Fatalf("encoded discount = %+v", discount) + } +} diff --git a/internal/solvers/lifi/order.go b/internal/solvers/lifi/order.go new file mode 100644 index 00000000..00d89747 --- /dev/null +++ b/internal/solvers/lifi/order.go @@ -0,0 +1,417 @@ +package lifi + +import ( + "encoding/json" + "math" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/api/bindings/lifi/inputsettler" + "github.com/symbioticfi/vault-solver/api/lifiorder" +) + +const ( + dutchAuctionContextType byte = 0x01 + exclusiveDutchAuctionContextType byte = 0xe1 +) + +type submittedOrderEvent struct { + OrderType string `json:"orderType"` + Order lifiorder.SubmitOrderDtoOrder `json:"order"` + QuoteID *string `json:"quoteId,omitempty"` + InputSettler string `json:"inputSettler"` + Meta submittedOrderEventMeta `json:"meta"` +} + +type submittedOrderEventMeta struct { + OrderStatus string `json:"orderStatus"` + OrderID string `json:"orderIdentifier"` + OnChainOrderID string `json:"onChainOrderId"` + QuoteID json.RawMessage `json:"quoteId"` +} + +type submittedOrder struct { + QuoteID string + OrderStatus string + OrderID string + OnChainOrderID string + + Order inputsettler.StandardOrder + InputSettler common.Address + + TokenIn common.Address + AmountIn *big.Int + TokenOut common.Address + OutputAmount *big.Int + Output inputsettler.MandateOutput +} + +func isDutchAuctionContext(context []byte) bool { + if len(context) == 0 { + return false + } + return context[0] == dutchAuctionContextType || context[0] == exclusiveDutchAuctionContextType +} + +type parsedStandardOrder struct { + order inputsettler.StandardOrder + tokenIn common.Address + amountIn *big.Int + tokenOut common.Address + outputAmount *big.Int + output inputsettler.MandateOutput +} + +type parsedOutput struct { + output inputsettler.MandateOutput + tokenOut common.Address + amount *big.Int +} + +func parseSubmittedOrder(data []byte, cfg *Config, chainID int64) (*submittedOrder, error) { + var event submittedOrderEvent + if err := json.Unmarshal(data, &event); err != nil { + return nil, errors.Errorf("decode submit order dto: %w", err) + } + + if !isFillableOrderStatus(event.Meta.OrderStatus) { + return nil, errors.Errorf("unsupported order status %q", event.Meta.OrderStatus) + } + if !isOnChainOrderEvent(event) { + if event.OrderType == "" { + return nil, errors.New("missing orderType requires onChainOrderId and inputSettler") + } + return nil, errors.Errorf("unsupported non-onchain order type %q", event.OrderType) + } + + inputSettler, err := parseAddress(event.InputSettler, "inputSettler") + if err != nil { + return nil, err + } + if inputSettler != cfg.InputSettler { + return nil, errors.Errorf("inputSettler %s does not match configured %s", inputSettler.Hex(), cfg.InputSettler.Hex()) + } + parsed, err := parseStandardOrder(event.Order, cfg, chainID) + if err != nil { + return nil, err + } + return &submittedOrder{ + QuoteID: eventQuoteID(event), + OrderStatus: event.Meta.OrderStatus, + OrderID: event.Meta.OrderID, + OnChainOrderID: event.Meta.OnChainOrderID, + Order: parsed.order, + InputSettler: inputSettler, + TokenIn: parsed.tokenIn, + AmountIn: parsed.amountIn, + TokenOut: parsed.tokenOut, + OutputAmount: new(big.Int).Set(parsed.outputAmount), + Output: parsed.output, + }, nil +} + +func isFillableOrderStatus(status string) bool { + return status == "Signed" || status == "Delivered" +} + +func isOnChainOrderType(orderType string) bool { + normalized := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(orderType)) + switch normalized { + case "onchainorder", "oifuseropenv0": + return true + default: + return false + } +} + +func isOnChainOrderEvent(event submittedOrderEvent) bool { + if event.OrderType == "" { + return event.Meta.OnChainOrderID != "" && event.InputSettler != "" + } + return isOnChainOrderType(event.OrderType) +} + +func parseStandardOrder( + dto lifiorder.SubmitOrderDtoOrder, + cfg *Config, + chainID int64, +) (*parsedStandardOrder, error) { + user, err := parseAddress(dto.User, "order.user") + if err != nil { + return nil, err + } + inputOracle, err := parseAddress(dto.InputOracle, "order.inputOracle") + if err != nil { + return nil, err + } + if inputOracle != cfg.OutputSettler { + return nil, errors.Errorf("order.inputOracle %s does not match outputSettler %s", inputOracle.Hex(), cfg.OutputSettler.Hex()) + } + if len(dto.Inputs) != 1 { + return nil, errors.Errorf("order.inputs: expected 1 input, got %d", len(dto.Inputs)) + } + if len(dto.Outputs) != 1 { + return nil, errors.Errorf("order.outputs: expected 1 output, got %d", len(dto.Outputs)) + } + + nonce, err := parseUint(dto.Nonce, "order.nonce") + if err != nil { + return nil, err + } + originChainID, err := parseUint(dto.OriginChainId, "order.originChainId") + if err != nil { + return nil, err + } + if originChainID.Cmp(big.NewInt(chainID)) != 0 { + return nil, errors.Errorf("order.originChainId %s does not match chain %d", originChainID, chainID) + } + expires, err := parseUint32(dto.Expires, "order.expires") + if err != nil { + return nil, err + } + fillDeadline, err := parseUint32(dto.FillDeadline, "order.fillDeadline") + if err != nil { + return nil, err + } + + inputPair := dto.Inputs[0] + if len(inputPair) != 2 { + return nil, errors.Errorf("order.inputs[0]: expected [tokenId, amount], got %d values", len(inputPair)) + } + tokenID, err := parseTupleUint(inputPair[0], "order.inputs[0][0]") + if err != nil { + return nil, err + } + tokenIn, err := tokenIDToAddress(tokenID, "order.inputs[0][0]") + if err != nil { + return nil, err + } + amountIn, err := parseTupleUint(inputPair[1], "order.inputs[0][1]") + if err != nil { + return nil, err + } + if amountIn.Sign() <= 0 { + return nil, errors.New("order.inputs[0][1]: must be positive") + } + + output, err := parseOutput(dto.Outputs[0], cfg, chainID) + if err != nil { + return nil, err + } + order := inputsettler.StandardOrder{ + User: user, + Nonce: nonce, + OriginChainId: originChainID, + Expires: expires, + FillDeadline: fillDeadline, + InputOracle: inputOracle, + Inputs: [][2]*big.Int{{new(big.Int).Set(tokenID), new(big.Int).Set(amountIn)}}, + Outputs: []inputsettler.MandateOutput{output.output}, + } + return &parsedStandardOrder{ + order: order, + tokenIn: tokenIn, + amountIn: amountIn, + tokenOut: output.tokenOut, + outputAmount: output.amount, + output: output.output, + }, nil +} + +func parseOutput( + dto lifiorder.SubmitOrderDtoOrderOutputsInner, + cfg *Config, + chainID int64, +) (*parsedOutput, error) { + oracle, err := parseBytes32(dto.Oracle, "order.outputs[0].oracle") + if err != nil { + return nil, err + } + settler, err := parseBytes32(dto.Settler, "order.outputs[0].settler") + if err != nil { + return nil, err + } + wantSettler := addressIdentifier(cfg.OutputSettler) + if oracle != wantSettler { + return nil, errors.New("order.outputs[0].oracle does not match outputSettler") + } + if settler != wantSettler { + return nil, errors.New("order.outputs[0].settler does not match outputSettler") + } + + tokenID, err := parseBytes32(dto.Token, "order.outputs[0].token") + if err != nil { + return nil, err + } + tokenOut, err := identifierAddress(tokenID, "order.outputs[0].token") + if err != nil { + return nil, err + } + recipientID, err := parseBytes32(dto.Recipient, "order.outputs[0].recipient") + if err != nil { + return nil, err + } + if _, err := identifierAddress(recipientID, "order.outputs[0].recipient"); err != nil { + return nil, err + } + + amountOut, err := parseUint(dto.Amount, "order.outputs[0].amount") + if err != nil { + return nil, err + } + if amountOut.Sign() <= 0 { + return nil, errors.New("order.outputs[0].amount: must be positive") + } + outputChainID, err := parseUint(dto.ChainId, "order.outputs[0].chainId") + if err != nil { + return nil, err + } + if outputChainID.Cmp(big.NewInt(chainID)) != 0 { + return nil, errors.Errorf("order.outputs[0].chainId %s does not match chain %d", outputChainID, chainID) + } + + callbackData, err := nullableHexBytes(dto.CallbackData, "order.outputs[0].callbackData") + if err != nil { + return nil, err + } + contextData, err := nullableHexBytes(dto.Context, "order.outputs[0].context") + if err != nil { + return nil, err + } + if len(callbackData) != 0 { + return nil, errors.New("non-empty output callbackData is not supported") + } + + output := inputsettler.MandateOutput{ + Oracle: oracle, + Settler: settler, + ChainId: outputChainID, + Token: tokenID, + Amount: amountOut, + Recipient: recipientID, + CallbackData: callbackData, + Context: contextData, + } + return &parsedOutput{output: output, tokenOut: tokenOut, amount: amountOut}, nil +} + +func eventQuoteID(event submittedOrderEvent) string { + if event.QuoteID != nil && *event.QuoteID != "" { + return *event.QuoteID + } + if len(event.Meta.QuoteID) == 0 { + return "" + } + var s string + if err := json.Unmarshal(event.Meta.QuoteID, &s); err == nil { + return s + } + return "" +} + +func parseAddress(raw, field string) (common.Address, error) { + if !common.IsHexAddress(raw) { + return common.Address{}, errors.Errorf("%s: invalid address %q", field, raw) + } + addr := common.HexToAddress(raw) + if addr == (common.Address{}) { + return common.Address{}, errors.Errorf("%s: zero address", field) + } + return addr, nil +} + +func parseUint32(raw, field string) (uint32, error) { + n, err := parseUint(raw, field) + if err != nil { + return 0, err + } + if !n.IsUint64() || n.Uint64() > math.MaxUint32 { + return 0, errors.Errorf("%s: overflows uint32", field) + } + return uint32(n.Uint64()), nil +} + +func parseTupleUint(raw any, field string) (*big.Int, error) { + value, ok := raw.(string) + if !ok { + return nil, errors.Errorf("%s: expected decimal string, got %T", field, raw) + } + return parseUint(value, field) +} + +func parseUint(raw, field string) (*big.Int, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.Errorf("%s: empty integer", field) + } + n, ok := new(big.Int).SetString(raw, 10) + if !ok || n.Sign() < 0 { + return nil, errors.Errorf("%s: invalid uint %q", field, raw) + } + return n, nil +} + +func parseBytes32(raw, field string) ([32]byte, error) { + b, err := decodeHexBytes(raw, field) + if err != nil { + return [32]byte{}, err + } + if len(b) != 32 { + return [32]byte{}, errors.Errorf("%s: expected 32 bytes, got %d", field, len(b)) + } + var out [32]byte + copy(out[:], b) + return out, nil +} + +func decodeHexBytes(raw, field string) ([]byte, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.Errorf("%s: empty hex", field) + } + if !strings.HasPrefix(raw, "0x") && !strings.HasPrefix(raw, "0X") { + raw = "0x" + raw + } + out, err := hexutil.Decode(raw) + if err != nil { + return nil, errors.Errorf("%s: invalid hex: %w", field, err) + } + return out, nil +} + +func nullableHexBytes(value lifiorder.NullableString, field string) ([]byte, error) { + if !value.IsSet() || value.Get() == nil || *value.Get() == "" { + return nil, nil + } + return decodeHexBytes(*value.Get(), field) +} + +func tokenIDToAddress(n *big.Int, field string) (common.Address, error) { + addr := common.BytesToAddress(n.Bytes()) + roundTrip := new(big.Int).SetBytes(addr.Bytes()) + if addr == (common.Address{}) || roundTrip.Cmp(n) != 0 { + return common.Address{}, errors.Errorf("%s: not a clean address identifier", field) + } + return addr, nil +} + +func addressIdentifier(addr common.Address) [32]byte { + var out [32]byte + copy(out[12:], addr.Bytes()) + return out +} + +func identifierAddress(id [32]byte, field string) (common.Address, error) { + addr := common.BytesToAddress(id[12:]) + if addr == (common.Address{}) { + return common.Address{}, errors.Errorf("%s: zero address identifier", field) + } + if addressIdentifier(addr) != id { + return common.Address{}, errors.Errorf("%s: not a clean address identifier", field) + } + return addr, nil +} diff --git a/internal/solvers/lifi/order_test.go b/internal/solvers/lifi/order_test.go new file mode 100644 index 00000000..5b3d49c5 --- /dev/null +++ b/internal/solvers/lifi/order_test.go @@ -0,0 +1,354 @@ +package lifi + +import ( + "encoding/json" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +func TestParseSubmittedOrder(t *testing.T) { + cfg := testLifiConfig() + tokenIn := common.HexToAddress("0x6666666666666666666666666666666666666666") + tokenOut := common.HexToAddress("0x7777777777777777777777777777777777777777") + + order, err := parseSubmittedOrder(testOrderJSON(t, cfg, tokenIn, tokenOut), cfg, 11155111) + if err != nil { + t.Fatalf("parseSubmittedOrder: %v", err) + } + if order.QuoteID != "quote-1" { + t.Fatalf("quote id = %q", order.QuoteID) + } + if order.TokenIn != tokenIn || order.TokenOut != tokenOut { + t.Fatalf("tokens = %s/%s", order.TokenIn, order.TokenOut) + } + if got := order.AmountIn.String(); got != "1000000" { + t.Fatalf("amount in = %s", got) + } + if got := order.OutputAmount.String(); got != "990000" { + t.Fatalf("amount out = %s", got) + } + if order.Output.Oracle != addressIdentifier(cfg.OutputSettler) { + t.Fatal("output oracle was not parsed as output settler identifier") + } + order.OutputAmount.SetInt64(1) + if order.Output.Amount.String() != "990000" { + t.Fatalf("output amount aliases mandate output: %s", order.Output.Amount) + } +} + +func TestParseSubmittedOrderRejectsNonStringInputTuple(t *testing.T) { + cfg := testLifiConfig() + var body map[string]any + if err := json.Unmarshal(testOrderJSON( + t, + cfg, + common.HexToAddress("0x6666666666666666666666666666666666666666"), + common.HexToAddress("0x7777777777777777777777777777777777777777"), + ), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + inputs := sliceField(t, mapField(t, body, "order"), "inputs") + inputs[0].([]any)[1] = float64(1_000_000) + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + _, err = parseSubmittedOrder(raw, cfg, 11155111) + if err == nil || !strings.Contains(err.Error(), "expected decimal string") { + t.Fatalf("err = %v", err) + } +} + +func TestParseSubmittedOrderAllowsMissingQuoteID(t *testing.T) { + cfg := testLifiConfig() + var body map[string]any + if err := json.Unmarshal(testOrderJSON( + t, + cfg, + common.HexToAddress("0x6666666666666666666666666666666666666666"), + common.HexToAddress("0x7777777777777777777777777777777777777777"), + ), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + delete(body, "quoteId") + mapField(t, body, "meta")["quoteId"] = nil + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + order, err := parseSubmittedOrder(raw, cfg, 11155111) + if err != nil { + t.Fatalf("parseSubmittedOrder: %v", err) + } + if order.QuoteID != "" { + t.Fatalf("quote id = %q", order.QuoteID) + } +} + +func TestParseSubmittedOrderInfersOnChainOrderWhenTypeMissing(t *testing.T) { + cfg := testLifiConfig() + var body map[string]any + if err := json.Unmarshal(testOrderJSON( + t, + cfg, + common.HexToAddress("0x6666666666666666666666666666666666666666"), + common.HexToAddress("0x7777777777777777777777777777777777777777"), + ), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + delete(body, "orderType") + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + order, err := parseSubmittedOrder(raw, cfg, 11155111) + if err != nil { + t.Fatalf("parseSubmittedOrder: %v", err) + } + if order.OnChainOrderID == "" { + t.Fatal("on-chain order id was not parsed") + } +} + +func TestParseSubmittedOrderRejectsMissingTypeWithoutOnChainMetadata(t *testing.T) { + cfg := testLifiConfig() + var body map[string]any + if err := json.Unmarshal(testOrderJSON( + t, + cfg, + common.HexToAddress("0x6666666666666666666666666666666666666666"), + common.HexToAddress("0x7777777777777777777777777777777777777777"), + ), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + delete(body, "orderType") + meta := body["meta"].(map[string]any) + delete(meta, "onChainOrderId") + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + if _, err = parseSubmittedOrder(raw, cfg, 11155111); err == nil || + !strings.Contains(err.Error(), "missing orderType requires onChainOrderId") { + t.Fatalf("err = %v", err) + } +} + +func TestParseSubmittedOrderPreservesOutputContext(t *testing.T) { + cfg := testLifiConfig() + var body map[string]any + if err := json.Unmarshal(testOrderJSON( + t, + cfg, + common.HexToAddress("0x6666666666666666666666666666666666666666"), + common.HexToAddress("0x7777777777777777777777777777777777777777"), + ), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + outputs := sliceField(t, mapField(t, body, "order"), "outputs") + output := outputs[0].(map[string]any) + output["context"] = "0x01000000010000000200000000000000000000000000000000000000000000000000000000000003" + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + order, err := parseSubmittedOrder(raw, cfg, 11155111) + if err != nil { + t.Fatalf("parseSubmittedOrder: %v", err) + } + if got := hexutil.Encode(order.Output.Context); got != output["context"] { + t.Fatalf("context = %s", got) + } +} + +func TestParseSubmittedOrderRejectsDirtyOutputIdentifier(t *testing.T) { + cfg := testLifiConfig() + var body map[string]any + if err := json.Unmarshal(testOrderJSON( + t, + cfg, + common.HexToAddress("0x6666666666666666666666666666666666666666"), + common.HexToAddress("0x7777777777777777777777777777777777777777"), + ), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + outputs := sliceField(t, mapField(t, body, "order"), "outputs") + output, ok := outputs[0].(map[string]any) + if !ok { + t.Fatalf("output type = %T", outputs[0]) + } + dirty := addressIdentifier(common.HexToAddress("0x7777777777777777777777777777777777777777")) + dirty[0] = 1 + output["token"] = hexutil.Encode(dirty[:]) + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + _, err = parseSubmittedOrder(raw, cfg, 11155111) + if err == nil || !strings.Contains(err.Error(), "clean address identifier") { + t.Fatalf("err = %v", err) + } +} + +func TestParseSubmittedOrderRejectsNonOnChainOrderType(t *testing.T) { + cfg := testLifiConfig() + var body map[string]any + if err := json.Unmarshal(testOrderJSON( + t, + cfg, + common.HexToAddress("0x6666666666666666666666666666666666666666"), + common.HexToAddress("0x7777777777777777777777777777777777777777"), + ), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + body["orderType"] = "GaslessCrosschainOrder" + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + _, err = parseSubmittedOrder(raw, cfg, 11155111) + if err == nil || !strings.Contains(err.Error(), "unsupported non-onchain order type") { + t.Fatalf("err = %v", err) + } + + body["orderType"] = "NonOnChainOrder" + raw, err = json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + _, err = parseSubmittedOrder(raw, cfg, 11155111) + if err == nil || !strings.Contains(err.Error(), "unsupported non-onchain order type") { + t.Fatalf("err = %v", err) + } +} + +func TestParseSubmittedOrderAcceptsOIFUserOpenOrderType(t *testing.T) { + cfg := testLifiConfig() + var body map[string]any + if err := json.Unmarshal(testOrderJSON( + t, + cfg, + common.HexToAddress("0x6666666666666666666666666666666666666666"), + common.HexToAddress("0x7777777777777777777777777777777777777777"), + ), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + body["orderType"] = "oif-user-open-v0" + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + if _, err = parseSubmittedOrder(raw, cfg, 11155111); err != nil { + t.Fatalf("parseSubmittedOrder: %v", err) + } +} + +func TestParseSubmittedOrderRejectsMissingOrderStatus(t *testing.T) { + cfg := testLifiConfig() + var body map[string]any + if err := json.Unmarshal(testOrderJSON( + t, + cfg, + common.HexToAddress("0x6666666666666666666666666666666666666666"), + common.HexToAddress("0x7777777777777777777777777777777777777777"), + ), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + delete(mapField(t, body, "meta"), "orderStatus") + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + _, err = parseSubmittedOrder(raw, cfg, 11155111) + if err == nil || !strings.Contains(err.Error(), "unsupported order status") { + t.Fatalf("err = %v", err) + } +} + +func testLifiConfig() *Config { + return &Config{ + InputSettler: common.HexToAddress("0x2222222222222222222222222222222222222222"), + OutputSettler: common.HexToAddress("0x3333333333333333333333333333333333333333"), + Executor: common.HexToAddress("0x4444444444444444444444444444444444444444"), + } +} + +func testOrderJSON(t *testing.T, cfg *Config, tokenIn, tokenOut common.Address) []byte { + t.Helper() + user := common.HexToAddress("0x1111111111111111111111111111111111111111") + recipient := common.HexToAddress("0x8888888888888888888888888888888888888888") + body := map[string]any{ + "orderType": "OnChainOrder", + "quoteId": "quote-1", + "inputSettler": cfg.InputSettler.Hex(), + "order": map[string]any{ + "user": user.Hex(), + "nonce": "7", + "originChainId": "11155111", + "expires": "1800000000", + "fillDeadline": "1800000300", + "inputOracle": cfg.OutputSettler.Hex(), + "inputs": [][]string{ + {new(big.Int).SetBytes(tokenIn.Bytes()).String(), "1000000"}, + }, + "outputs": []map[string]any{{ + "oracle": hexID(cfg.OutputSettler), + "settler": hexID(cfg.OutputSettler), + "chainId": "11155111", + "token": hexID(tokenOut), + "amount": "990000", + "recipient": hexID(recipient), + "callbackData": "0x", + "context": "0x", + }}, + }, + "meta": map[string]any{ + "orderStatus": "Signed", + "orderIdentifier": "intent-1", + "onChainOrderId": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "quoteId": "quote-from-meta", + }, + } + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal order: %v", err) + } + return raw +} + +func hexID(addr common.Address) string { + id := addressIdentifier(addr) + return hexutil.Encode(id[:]) +} + +func mapField(t *testing.T, m map[string]any, field string) map[string]any { + t.Helper() + out, ok := m[field].(map[string]any) + if !ok { + t.Fatalf("%s type = %T", field, m[field]) + } + return out +} + +func sliceField(t *testing.T, m map[string]any, field string) []any { + t.Helper() + out, ok := m[field].([]any) + if !ok { + t.Fatalf("%s type = %T", field, m[field]) + } + return out +} diff --git a/internal/solvers/lifi/orderclient.go b/internal/solvers/lifi/orderclient.go new file mode 100644 index 00000000..ac83094d --- /dev/null +++ b/internal/solvers/lifi/orderclient.go @@ -0,0 +1,240 @@ +package lifi + +import ( + "context" + "math" + "net/http" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/api/lifiorder" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" +) + +type orderClient struct { + api *lifiorder.APIClient + apiKey string + chain string +} + +func newOrderClient(baseURL, apiKey string, timeout time.Duration, chainID int64) *orderClient { + cfg := lifiorder.NewConfiguration() + cfg.Servers = lifiorder.ServerConfigurations{{URL: strings.TrimRight(baseURL, "/")}} + cfg.HTTPClient = &http.Client{Timeout: timeout} + return &orderClient{api: lifiorder.NewAPIClient(cfg), apiKey: apiKey, chain: strconv.FormatInt(chainID, 10)} +} + +func (c *orderClient) withAuth(ctx context.Context) context.Context { + return context.WithValue(ctx, lifiorder.ContextAPIKeys, map[string]lifiorder.APIKey{ + "api-key": {Key: c.apiKey}, + }) +} + +func (c *orderClient) validateExecutorRegistration(ctx context.Context, executor common.Address) error { + identities, httpResp, err := c.api.SolverAPIAPI. + SolverApiV0ControllerGetSolverIdentities(c.withAuth(ctx)). + Execute() + closeResp(httpResp) + if err != nil { + return apiErr("get solver identities", httpResp, err) + } + if identities != nil { + for _, identity := range identities.Data { + if strings.EqualFold(identity.Address, executor.Hex()) { + return nil + } + } + } + return errors.Errorf("lifi order server: executor %s is not registered for this API key", executor.Hex()) +} + +func (c *orderClient) replaceSupportedContracts( + ctx context.Context, dto lifiorder.PutSupportedContractsDto, +) error { + _, httpResp, err := c.api.SolverAPIV1API. + SupportedContractsControllerReplaceSupportedContracts(c.withAuth(ctx)). + PutSupportedContractsDto(dto). + Execute() + closeResp(httpResp) + if err != nil { + return apiErr("put supported contracts", httpResp, err) + } + return nil +} + +func (c *orderClient) ensureSupportedContracts( + ctx context.Context, chainID int64, inputSettler, outputSettler common.Address, +) error { + chain := chainRef(chainID) + current, httpResp, err := c.api.SolverAPIV1API. + SupportedContractsControllerGetSupportedContracts(c.withAuth(ctx)). + Execute() + closeResp(httpResp) + if err != nil { + return apiErr("get supported contracts", httpResp, err) + } + if current != nil && supportsConfiguredContracts(current.Data, chain, inputSettler, outputSettler) { + return nil + } + contracts := lifiorder.ContractsByKindDto{} + if current != nil { + contracts = current.Data + } + return c.replaceSupportedContracts(ctx, supportedContractsDTO(contracts, chain, inputSettler, outputSettler)) +} + +func chainRef(chainID int64) string { + return "eip155:" + strconv.FormatInt(chainID, 10) +} + +func supportedContractsDTO( + current lifiorder.ContractsByKindDto, + chain string, + inputSettler, outputSettler common.Address, +) lifiorder.PutSupportedContractsDto { + dto := lifiorder.PutSupportedContractsDto{ + Oracle: supportedContractEntries(current.Oracle), + InputSettler: supportedContractEntries(current.InputSettler), + OutputSettler: supportedContractEntries(current.OutputSettler), + } + dto.InputSettler = appendSupportedContract(dto.InputSettler, chain, inputSettler) + dto.OutputSettler = appendSupportedContract(dto.OutputSettler, chain, outputSettler) + dto.Oracle = appendSupportedContract(dto.Oracle, chain, outputSettler) + return dto +} + +func supportedContractEntries(items []lifiorder.ChainAddressDto) []lifiorder.QuoteRequestDtoIntentMetadataOracleInner { + if len(items) == 0 { + return nil + } + out := make([]lifiorder.QuoteRequestDtoIntentMetadataOracleInner, len(items)) + for i, item := range items { + out[i] = lifiorder.QuoteRequestDtoIntentMetadataOracleInner(item) + } + return out +} + +func appendSupportedContract( + items []lifiorder.QuoteRequestDtoIntentMetadataOracleInner, + chain string, + address common.Address, +) []lifiorder.QuoteRequestDtoIntentMetadataOracleInner { + for _, item := range items { + if item.Chain == chain && strings.EqualFold(item.Address, address.Hex()) { + return items + } + } + return append(items, lifiorder.QuoteRequestDtoIntentMetadataOracleInner{Chain: chain, Address: address.Hex()}) +} + +func supportsConfiguredContracts( + contracts lifiorder.ContractsByKindDto, + chain string, + inputSettler, outputSettler common.Address, +) bool { + return hasChainAddress(contracts.InputSettler, chain, inputSettler) && + hasChainAddress(contracts.OutputSettler, chain, outputSettler) && + hasChainAddress(contracts.Oracle, chain, outputSettler) +} + +func hasChainAddress(items []lifiorder.ChainAddressDto, chain string, address common.Address) bool { + for _, item := range items { + if item.Chain == chain && strings.EqualFold(item.Address, address.Hex()) { + return true + } + } + return false +} + +func (c *orderClient) submitQuotes(ctx context.Context, quotes []types.Quote) error { + dtoQuotes := make([]lifiorder.SubmitQuotesDtoQuotesInner, 0, len(quotes)) + for i, quote := range quotes { + dto, err := submitQuoteDTO(c.chain, quote, i) + if err != nil { + return err + } + dtoQuotes = append(dtoQuotes, dto) + } + + _, httpResp, err := c.api.SolverAPIAPI. + QuotesControllerSubmitQuotes(c.withAuth(ctx)). + SubmitQuotesDto(lifiorder.SubmitQuotesDto{Quotes: dtoQuotes}). + Execute() + closeResp(httpResp) + if err != nil { + return apiErr("submit quotes", httpResp, err) + } + return nil +} + +func submitQuoteDTO(chain string, quote types.Quote, index int) (lifiorder.SubmitQuotesDtoQuotesInner, error) { + field := "quotes[" + strconv.Itoa(index) + "]" + expiry, err := int32Checked(quote.Expiry, field+".expiry") + if err != nil { + return lifiorder.SubmitQuotesDtoQuotesInner{}, err + } + fromDecimals, err := int32Checked(int64(quote.FromDecimals), field+".fromDecimals") + if err != nil { + return lifiorder.SubmitQuotesDtoQuotesInner{}, err + } + toDecimals, err := int32Checked(int64(quote.ToDecimals), field+".toDecimals") + if err != nil { + return lifiorder.SubmitQuotesDtoQuotesInner{}, err + } + ranges := make([]lifiorder.SubmitQuotesDtoQuotesInnerRangesInner, 0, len(quote.Ranges)) + for i, quoteRange := range quote.Ranges { + if quoteRange.MinAmount == nil || quoteRange.MaxAmount == nil || quoteRange.Quote == "" { + return lifiorder.SubmitQuotesDtoQuotesInner{}, errors.Errorf("%s.ranges[%d]: incomplete range", field, i) + } + ranges = append(ranges, lifiorder.SubmitQuotesDtoQuotesInnerRangesInner{ + MinAmount: quoteRange.MinAmount.String(), + MaxAmount: quoteRange.MaxAmount.String(), + Quote: quoteRange.Quote, + }) + } + dto := lifiorder.SubmitQuotesDtoQuotesInner{ + FromChain: chain, ToChain: chain, + FromAsset: quote.FromAsset.Hex(), ToAsset: quote.ToAsset.Hex(), + FromDecimals: fromDecimals, ToDecimals: toDecimals, + Ranges: ranges, Expiry: expiry, + } + if quote.ExclusiveFor != (common.Address{}) { + exclusiveFor := quote.ExclusiveFor.Hex() + dto.ExclusiveFor = &exclusiveFor + } + return dto, nil +} + +func closeResp(resp *http.Response) { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } +} + +func apiErr(what string, resp *http.Response, err error) error { + var genErr *lifiorder.GenericOpenAPIError + if errors.As(err, &genErr) { + if body := strings.TrimSpace(string(genErr.Body())); body != "" { + return errors.Errorf("lifi order server: %s: %s: %s: %w", what, statusOf(resp), body, err) + } + } + return errors.Errorf("lifi order server: %s: %s: %w", what, statusOf(resp), err) +} + +func statusOf(resp *http.Response) string { + if resp == nil { + return "no response" + } + return resp.Status +} + +func int32Checked(v int64, field string) (int32, error) { + if v < math.MinInt32 || v > math.MaxInt32 { + return 0, errors.Errorf("%s: %d overflows int32", field, v) + } + return int32(v), nil +} diff --git a/internal/solvers/lifi/orderclient_test.go b/internal/solvers/lifi/orderclient_test.go new file mode 100644 index 00000000..36e8942e --- /dev/null +++ b/internal/solvers/lifi/orderclient_test.go @@ -0,0 +1,243 @@ +package lifi + +import ( + "context" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/api/lifiorder" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" +) + +func TestOrderClientSubmitQuotes(t *testing.T) { + var gotHeader string + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/quotes/submit" { + t.Fatalf("path = %s", r.URL.Path) + } + gotHeader = r.Header.Get("x-api-key") + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"success","quotesAdded":1}`)) + })) + defer srv.Close() + + client := newOrderClient(srv.URL, "test-key", time.Second, 11155111) + err := client.submitQuotes(context.Background(), []types.Quote{{ + FromAsset: common.HexToAddress("0x1111111111111111111111111111111111111111"), + ToAsset: common.HexToAddress("0x2222222222222222222222222222222222222222"), + FromDecimals: 6, + ToDecimals: 18, + Expiry: 1_800_000_000, + ExclusiveFor: common.HexToAddress("0x3333333333333333333333333333333333333333"), + Ranges: []types.QuoteRange{{ + MinAmount: big.NewInt(1), + MaxAmount: big.NewInt(1_000_000), + Quote: "0.99", + }}, + }}) + if err != nil { + t.Fatalf("submitQuotes: %v", err) + } + if gotHeader != "test-key" { + t.Fatalf("x-api-key = %q", gotHeader) + } + + quotes := gotBody["quotes"].([]any) + q := quotes[0].(map[string]any) + if q["fromChain"] != "11155111" || q["toChain"] != "11155111" { + t.Fatalf("chains = %v/%v", q["fromChain"], q["toChain"]) + } + if q["fromAsset"] != "0x1111111111111111111111111111111111111111" { + t.Fatalf("fromAsset = %v", q["fromAsset"]) + } + if q["fromDecimals"] != float64(6) || q["toDecimals"] != float64(18) { + t.Fatalf("decimals = %v/%v", q["fromDecimals"], q["toDecimals"]) + } + if q["exclusiveFor"] != "0x3333333333333333333333333333333333333333" { + t.Fatalf("exclusiveFor = %v", q["exclusiveFor"]) + } + ranges := q["ranges"].([]any) + rng := ranges[0].(map[string]any) + if rng["minAmount"] != "1" || rng["maxAmount"] != "1000000" || rng["quote"] != "0.99" { + t.Fatalf("range = %#v", rng) + } +} + +func TestOrderClientValidateExecutorRegistration(t *testing.T) { + executor := common.HexToAddress("0x4444444444444444444444444444444444444444") + for _, tc := range []struct { + name string + address string + wantErr bool + }{ + {name: "registered", address: executor.Hex()}, + {name: "missing", address: "0x5555555555555555555555555555555555555555", wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/solver-api/solver/identities" { + t.Fatalf("%s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("x-api-key"); got != "test-key" { + t.Fatalf("x-api-key = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":1,"createdAt":"now","updatedAt":"now","address":"` + + tc.address + `","solverId":1}]}`)) + })) + defer srv.Close() + + client := newOrderClient(srv.URL, "test-key", time.Second, 11155111) + err := client.validateExecutorRegistration(context.Background(), executor) + if (err != nil) != tc.wantErr { + t.Fatalf("validateExecutorRegistration() error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +func TestOrderClientReplaceSupportedContracts(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/api/v1/solver/supported-contracts" { + t.Fatalf("%s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"oracle":[],"inputSettler":[],"outputSettler":[]}}`)) + })) + defer srv.Close() + + client := newOrderClient(srv.URL, "test-key", time.Second, 11155111) + err := client.replaceSupportedContracts( + context.Background(), + supportedContractsDTO( + lifiorder.ContractsByKindDto{}, + chainRef(11155111), + common.HexToAddress("0x1111111111111111111111111111111111111111"), + common.HexToAddress("0x2222222222222222222222222222222222222222"), + ), + ) + if err != nil { + t.Fatalf("replaceSupportedContracts: %v", err) + } + if got := gotBody["inputSettler"].([]any)[0].(map[string]any)["chain"]; got != "eip155:11155111" { + t.Fatalf("chain = %v", got) + } + if got := gotBody["oracle"].([]any)[0].(map[string]any)["address"]; got != "0x2222222222222222222222222222222222222222" { + t.Fatalf("oracle address = %v", got) + } +} + +func TestOrderClientEnsureSupportedContractsSkipsPutWhenPresent(t *testing.T) { + var putCalled bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/solver/supported-contracts" { + t.Fatalf("path = %s", r.URL.Path) + } + if r.Method == http.MethodPut { + putCalled = true + t.Fatal("unexpected PUT") + } + if r.Method != http.MethodGet { + t.Fatalf("method = %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"oracle":[{"chain":"eip155:11155111","address":"0x2222222222222222222222222222222222222222"}],"inputSettler":[{"chain":"eip155:11155111","address":"0x1111111111111111111111111111111111111111"}],"outputSettler":[{"chain":"eip155:11155111","address":"0x2222222222222222222222222222222222222222"}]}}`)) + })) + defer srv.Close() + + client := newOrderClient(srv.URL, "test-key", time.Second, 11155111) + err := client.ensureSupportedContracts( + context.Background(), + 11155111, + common.HexToAddress("0x1111111111111111111111111111111111111111"), + common.HexToAddress("0x2222222222222222222222222222222222222222"), + ) + if err != nil { + t.Fatalf("ensureSupportedContracts: %v", err) + } + if putCalled { + t.Fatal("PUT was called") + } +} + +func TestOrderClientEnsureSupportedContractsPutsWhenMissing(t *testing.T) { + var methods []string + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/solver/supported-contracts" { + t.Fatalf("path = %s", r.URL.Path) + } + methods = append(methods, r.Method) + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + _, _ = w.Write([]byte(`{"data":{"oracle":[{"chain":"eip155:1","address":"0x3333333333333333333333333333333333333333"}],"inputSettler":[{"chain":"eip155:1","address":"0x4444444444444444444444444444444444444444"}],"outputSettler":[{"chain":"eip155:1","address":"0x5555555555555555555555555555555555555555"}]}}`)) + case http.MethodPut: + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + _, _ = w.Write([]byte(`{"data":{"oracle":[],"inputSettler":[],"outputSettler":[]}}`)) + default: + t.Fatalf("method = %s", r.Method) + } + })) + defer srv.Close() + + client := newOrderClient(srv.URL, "test-key", time.Second, 11155111) + err := client.ensureSupportedContracts( + context.Background(), + 11155111, + common.HexToAddress("0x1111111111111111111111111111111111111111"), + common.HexToAddress("0x2222222222222222222222222222222222222222"), + ) + if err != nil { + t.Fatalf("ensureSupportedContracts: %v", err) + } + if len(methods) != 2 || methods[0] != http.MethodGet || methods[1] != http.MethodPut { + t.Fatalf("methods = %v", methods) + } + inputSettlers := gotBody["inputSettler"].([]any) + if got := len(inputSettlers); got != 2 { + t.Fatalf("inputSettler count = %d", got) + } + if got := inputSettlers[0].(map[string]any)["address"]; got != "0x4444444444444444444444444444444444444444" { + t.Fatalf("preserved inputSettler address = %v", got) + } + if got := inputSettlers[1].(map[string]any)["address"]; got != "0x1111111111111111111111111111111111111111" { + t.Fatalf("configured inputSettler address = %v", got) + } + outputSettlers := gotBody["outputSettler"].([]any) + if got := len(outputSettlers); got != 2 { + t.Fatalf("outputSettler count = %d", got) + } + if got := outputSettlers[0].(map[string]any)["address"]; got != "0x5555555555555555555555555555555555555555" { + t.Fatalf("preserved outputSettler address = %v", got) + } + if got := outputSettlers[1].(map[string]any)["address"]; got != "0x2222222222222222222222222222222222222222" { + t.Fatalf("configured outputSettler address = %v", got) + } + oracles := gotBody["oracle"].([]any) + if got := len(oracles); got != 2 { + t.Fatalf("oracle count = %d", got) + } + if got := oracles[0].(map[string]any)["address"]; got != "0x3333333333333333333333333333333333333333" { + t.Fatalf("preserved oracle address = %v", got) + } + if got := oracles[1].(map[string]any)["address"]; got != "0x2222222222222222222222222222222222222222" { + t.Fatalf("configured oracle address = %v", got) + } +} diff --git a/internal/solvers/lifi/planning.go b/internal/solvers/lifi/planning.go new file mode 100644 index 00000000..7715b4aa --- /dev/null +++ b/internal/solvers/lifi/planning.go @@ -0,0 +1,277 @@ +package lifi + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" +) + +type fillState struct { + snapshots fillSnapshotSet + discountQuotes []liquidlane.FillQuote + signedDiscounts map[common.Hash]*discounts.Signed + chainTime time.Time +} + +type preparedFill struct { + input types.FillInput + signedDiscounts map[common.Hash]*discounts.Signed +} + +func (s *Solver) processOrderWithPending( + ctx context.Context, + routes []route, + order *submittedOrder, + pending *pendingFillState, +) *pendingFill { + if !s.cfg.TokenPolicy.Allows(order.TokenIn) { + s.log.V(1).Info("order skipped: input token out of scope", + "orderId", order.OrderID, "quoteId", order.QuoteID, + "tokenIn", order.TokenIn.Hex(), "scope", s.cfg.TokenPolicy.Scope()) + return nil + } + if err := s.reader.validateZeroGovernanceFee(ctx, s.cfg.InputSettler); err != nil { + s.log.Error(err, "order skipped: governance fee invariant failed", + "orderId", order.OrderID, "quoteId", order.QuoteID, + "inputSettler", s.cfg.InputSettler.Hex()) + return nil + } + orderID, ok := s.openedOrderID(ctx, order) + if !ok { + return nil + } + reservationKey := orderID.Hex() + if pending != nil && pending.contains(reservationKey) { + s.log.V(1).Info("order skipped: already pending", "orderId", order.OrderID, + "onChainOrderId", orderID.Hex(), "quoteId", order.QuoteID) + return nil + } + prepared := s.prepareFill(ctx, routes, order) + if prepared == nil { + return nil + } + plan, err := s.strategy.DecideFill(ctx, prepared.input) + if err != nil { + s.log.Error(err, "order fill: strategy", "orderId", order.OrderID, "quoteId", order.QuoteID) + return nil + } + if plan == nil { + s.log.V(1).Info("order skipped: no immediate fill plan", "orderId", order.OrderID, + "quoteId", order.QuoteID, "routes", len(prepared.input.Quotes)) + return nil + } + if err := validateFillPlan(prepared.input, plan); err != nil { + s.log.Error(err, "order fill: reject strategy plan", "orderId", order.OrderID, + "quoteId", order.QuoteID) + return nil + } + calldata, err := buildFillCalldata(*order, orderID, plan, prepared.signedDiscounts) + if err != nil { + s.log.Error(err, "order fill: build calldata", "orderId", order.OrderID, "quoteId", order.QuoteID) + return nil + } + return s.submitFill(ctx, order, plan, calldata, prepared.input.MaxFeePerGas) +} + +func validateFillPlan(input types.FillInput, plan *types.FillPlan) error { + routes, err := liquidstrategies.ValidateFillRoutes(liquidstrategies.FillValidation{ + TokenIn: input.TokenIn, TokenOut: input.TokenOut, AmountIn: input.AmountIn, + RequiredAmountOut: input.OutputAmount, RequireSingleRoute: input.RequireSingleRoute, + MaxRoutes: types.MaxRoutes, Quotes: input.Quotes, Reservations: input.Reservations, + GasSnapshot: input.GasSnapshot, GasPrices: input.GasPrices, MaxFeePerGas: input.MaxFeePerGas, + GasEnvelope: types.LiquidLaneGasEnvelope(), + }, plan.Routes) + if err != nil { + return errors.Errorf("strategy returned invalid fill plan: %w", err) + } + plan.Routes = routes + return nil +} + +func (s *Solver) openedOrderID(ctx context.Context, order *submittedOrder) (common.Hash, bool) { + orderID, err := s.reader.orderIdentifier(ctx, s.cfg.InputSettler, order.Order) + if err != nil { + s.log.Error(err, "order fill: identify order", "orderId", order.OrderID, "quoteId", order.QuoteID) + return common.Hash{}, false + } + status, err := s.reader.orderStatus(ctx, s.cfg.InputSettler, orderID) + if err != nil { + s.log.Error(err, "order fill: read initial order status", "orderId", order.OrderID, + "onChainOrderId", orderID.Hex(), "quoteId", order.QuoteID) + return common.Hash{}, false + } + if status != lifiOrderStatusDeposited { + s.log.Info("order skipped: on-chain order is not deposited", "orderId", order.OrderID, + "onChainOrderId", orderID.Hex(), "quoteId", order.QuoteID, "status", status) + return common.Hash{}, false + } + return orderID, true +} + +func (s *Solver) prepareFill( + ctx context.Context, + routes []route, + order *submittedOrder, +) *preparedFill { + pairRoutes := routesForPair(routes, order.TokenIn, order.TokenOut) + if len(pairRoutes) == 0 { + s.log.V(1).Info("order skipped: no configured route for pair", "orderId", order.OrderID, + "quoteId", order.QuoteID, "tokenIn", order.TokenIn.Hex(), "tokenOut", order.TokenOut.Hex()) + return nil + } + state, err := s.loadFillState(ctx, pairRoutes, order) + if err != nil { + s.log.Error(err, "order fill: prepare current state", "orderId", order.OrderID, "quoteId", order.QuoteID) + return nil + } + if state == nil { + return nil + } + maxFeePerGas, err := s.readMaxFeePerGas(ctx) + if err != nil { + s.log.Error(err, "order fill: read max fee per gas", "orderId", order.OrderID, "quoteId", order.QuoteID) + return nil + } + quotes := append([]liquidlane.FillQuote(nil), state.snapshots.Direct...) + quotes = append(quotes, state.discountQuotes...) + return &preparedFill{ + input: types.FillInput{ + OrderID: order.OrderID, + QuoteID: order.QuoteID, + Solver: s.cfg.Executor, + TokenIn: order.TokenIn, + TokenOut: order.TokenOut, + AmountIn: order.AmountIn, + OutputAmount: order.OutputAmount, + OutputContext: order.Output.Context, + Expires: order.Order.Expires, + FillDeadline: order.Order.FillDeadline, + RequireSingleRoute: s.cfg.TokenPolicy.RequiresSingleRoute(order.TokenIn), + Quotes: quotes, + Reservations: s.capacity.Snapshot(), + GasSnapshot: state.snapshots.GasSnapshot, + GasPrices: state.snapshots.GasPrices, + MaxFeePerGas: maxFeePerGas, + ChainTime: state.chainTime, + }, + signedDiscounts: state.signedDiscounts, + } +} + +func (s *Solver) loadFillState( + ctx context.Context, + routes []route, + order *submittedOrder, +) (*fillState, error) { + snapshots, chainTime, err := s.readFillSnapshot(ctx, routes, order) + if err != nil { + return nil, err + } + if s.skipExpiredOrder(order, chainTime) { + return nil, nil + } + state := &fillState{snapshots: snapshots, chainTime: chainTime} + if s.discounts == nil || len(snapshots.Physical) == 0 { + return state, nil + } + + resolveCtx, cancel := context.WithTimeout(ctx, s.cfg.OrderServer.HTTPTimeout) + state.discountQuotes, state.signedDiscounts = s.fillDiscountQuotes( + resolveCtx, snapshots.Physical, chainTime, + ) + cancel() + + state.snapshots, state.chainTime, err = s.readFillSnapshot(ctx, routes, order) + if err != nil { + return nil, errors.Errorf("refresh after private discount resolution: %w", err) + } + if s.skipExpiredOrder(order, state.chainTime) { + return nil, nil + } + refreshedDiscountQuotes, discountIssues := discounts.RefreshFillQuotes( + state.discountQuotes, + state.signedDiscounts, + state.snapshots.Physical, + state.chainTime, + ) + state.discountQuotes = refreshedDiscountQuotes + s.logDiscountIssues(discountIssues) + return state, nil +} + +func (s *Solver) readFillSnapshot( + ctx context.Context, + routes []route, + order *submittedOrder, +) (fillSnapshotSet, time.Time, error) { + chainTime, err := s.now(ctx) + if err != nil { + return fillSnapshotSet{}, time.Time{}, errors.Errorf("read latest block time: %w", err) + } + snapshots, err := s.reader.fillSnapshots(ctx, routes, s.cfg.Executor, order.TokenIn, order.AmountIn, chainTime) + if err != nil { + return fillSnapshotSet{}, time.Time{}, errors.Errorf("read routes: %w", err) + } + return snapshots, chainTime, nil +} + +func (s *Solver) skipExpiredOrder(order *submittedOrder, chainTime time.Time) bool { + if !orderExpired(order, chainTime) { + return false + } + s.log.Info("order skipped: expired", "orderId", order.OrderID, "quoteId", order.QuoteID, + "chainTime", uint32Unix(chainTime), "expires", order.Order.Expires, + "fillDeadline", order.Order.FillDeadline) + return true +} + +func routesForPair(routes []route, tokenIn, tokenOut common.Address) []route { + out := make([]route, 0, len(routes)) + for _, candidate := range routes { + if candidate.TokenIn == tokenIn && candidate.TokenOut == tokenOut { + out = append(out, candidate) + } + } + return out +} + +func (s *Solver) readMaxFeePerGas(ctx context.Context) (*big.Int, error) { + if s.maxFeePerGas == nil { + return nil, errors.New("max fee per gas reader is not configured") + } + maxFee, err := s.maxFeePerGas(ctx) + if err != nil { + return nil, errors.Errorf("max fee per gas: %w", err) + } + if maxFee == nil || maxFee.Sign() <= 0 { + return nil, errors.New("max fee per gas must be positive") + } + return new(big.Int).Set(maxFee), nil +} + +func uint32Unix(t time.Time) uint32 { + unix := t.Unix() + if unix <= 0 { + return 0 + } + if unix > int64(^uint32(0)) { + return ^uint32(0) + } + return uint32(unix) +} + +func orderExpired(order *submittedOrder, now time.Time) bool { + chainTime := uint32Unix(now) + if order.Order.Expires != 0 && chainTime >= order.Order.Expires { + return true + } + return order.Order.FillDeadline != 0 && chainTime >= order.Order.FillDeadline +} diff --git a/internal/solvers/lifi/quotes.go b/internal/solvers/lifi/quotes.go new file mode 100644 index 00000000..f1eb9f6c --- /dev/null +++ b/internal/solvers/lifi/quotes.go @@ -0,0 +1,267 @@ +package lifi + +import ( + "context" + "math/big" + "sort" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" + "github.com/symbioticfi/vault-solver/internal/tokenpolicy" +) + +type quoteSubmitter interface { + submitQuotes(ctx context.Context, quotes []types.Quote) error +} + +type quotePairKey struct { + fromAsset common.Address + toAsset common.Address + fromDecimals int + toDecimals int +} + +type quotePairState struct { + fingerprint string + expiry int64 + quotes []types.Quote +} + +type quoteState struct { + active map[quotePairKey]quotePairState + renewBefore time.Duration +} + +func (s *Solver) quoteLoop(ctx context.Context, routes []route, refresh <-chan struct{}) error { + ticker := time.NewTicker(s.cfg.QuoteInterval) + defer ticker.Stop() + + state := newQuoteState(max(s.cfg.QuoteInterval, s.cfg.QuoteTTL/3)) + s.refreshQuotes(ctx, routes, state) + var lastBlock uint64 + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-refresh: + s.refreshQuotes(ctx, routes, state) + case <-ticker.C: + if s.shouldRefreshQuotes(ctx, state, &lastBlock) { + s.refreshQuotes(ctx, routes, state) + } + } + } +} + +func (s *Solver) shouldRefreshQuotes(ctx context.Context, state *quoteState, lastBlock *uint64) bool { + if s.cfg.QuoteRefreshMode != quoteRefreshModeBlock { + return true + } + needsRenewal := state.needsRenewal(s.wallNow()) + block, err := s.reader.latestBlockNumber(ctx) + if err != nil { + s.log.Error(err, "quote refresh: read latest block") + return needsRenewal + } + if block == *lastBlock && !needsRenewal { + return false + } + *lastBlock = block + return true +} + +func (s *Solver) refreshQuotes(ctx context.Context, routes []route, state *quoteState) { + chainTime, err := s.now(ctx) + if err != nil { + s.log.Error(err, "quote refresh: read latest block time") + return + } + snapshotSet, err := s.reader.quoteSnapshots(ctx, routes, s.cfg.Executor, chainTime) + if err != nil { + s.log.Error(err, "quote refresh: read routes") + return + } + maxFeePerGas, err := s.readMaxFeePerGas(ctx) + if err != nil { + s.log.Error(err, "quote refresh: read max fee per gas") + return + } + direct := filterQuoteInventory(snapshotSet.Direct, s.cfg.TokenPolicy) + discountBases := filterQuoteInventory(snapshotSet.Physical, s.cfg.TokenPolicy) + inventory := append([]liquidlane.Inventory(nil), direct...) + inventory = append(inventory, s.quoteDiscountInventories(ctx, discountBases, chainTime)...) + serverTime := s.wallNow() + out, err := s.strategy.DecideQuotes(ctx, types.QuoteInput{ + Solver: s.cfg.Executor, + Inventory: inventory, + Reservations: s.capacity.Snapshot(), + SingleRouteTokens: s.cfg.TokenPolicy.SingleRouteTokens(), + GasSnapshot: snapshotSet.GasSnapshot, + GasPrices: snapshotSet.GasPrices, + MaxFeePerGas: maxFeePerGas, + ChainTime: chainTime, + ServerTime: serverTime, + QuoteExpiresAt: serverTime.Add(s.cfg.QuoteTTL), + }) + if err != nil { + s.log.Error(err, "quote refresh: strategy") + return + } + if len(out.Quotes) == 0 { + s.log.V(1).Info("quote refresh: strategy produced no quotes", "routes", len(inventory)) + } + removed, err := state.reconcile(ctx, s.orders, out.Quotes, serverTime) + if err != nil { + s.log.Error(err, "quote refresh: submit quotes", "quotes", len(out.Quotes)) + return + } + s.log.Info("quotes reconciled", "quotes", len(out.Quotes), "removedPairs", removed, "routes", len(inventory)) +} + +func filterQuoteInventory(inventory []liquidlane.Inventory, policy tokenpolicy.Policy) []liquidlane.Inventory { + filtered := make([]liquidlane.Inventory, 0, len(inventory)) + for _, item := range inventory { + if policy.Allows(item.TokenIn) { + filtered = append(filtered, item) + } + } + return filtered +} + +func newQuoteState(renewBefore time.Duration) *quoteState { + return "eState{ + active: make(map[quotePairKey]quotePairState), renewBefore: renewBefore, + } +} + +func (s *quoteState) needsRenewal(now time.Time) bool { + deadline := now.Add(s.renewBefore).Unix() + for _, pair := range s.active { + if pair.expiry <= deadline { + return true + } + } + return false +} + +func (s *quoteState) reconcile( + ctx context.Context, + submitter quoteSubmitter, + quotes []types.Quote, + now time.Time, +) (int, error) { + next := indexQuotePairs(quotes) + expire := make([]quotePairKey, 0) + publish := make(map[quotePairKey]bool, len(next)) + for key, current := range s.active { + upcoming, ok := next[key] + if !ok { + expire = append(expire, key) + continue + } + if shouldReplaceQuotePair(current, upcoming, now, s.renewBefore) { + publish[key] = true + } + } + for key := range next { + if _, ok := s.active[key]; !ok { + publish[key] = true + } + } + publishKeys := make([]quotePairKey, 0, len(publish)) + for key, enabled := range publish { + if enabled { + publishKeys = append(publishKeys, key) + } + } + sort.Slice(publishKeys, func(i, j int) bool { + return quotePairKeyString(publishKeys[i]) < quotePairKeyString(publishKeys[j]) + }) + sort.Slice(expire, func(i, j int) bool { return quotePairKeyString(expire[i]) < quotePairKeyString(expire[j]) }) + toPublish := make([]types.Quote, 0, len(quotes)+len(expire)) + for _, key := range expire { + for _, quote := range s.active[key].quotes { + quote.Expiry = now.Add(-time.Second).Unix() + toPublish = append(toPublish, quote) + } + } + for _, key := range publishKeys { + toPublish = append(toPublish, next[key].quotes...) + } + if len(toPublish) != 0 { + if err := submitter.submitQuotes(ctx, toPublish); err != nil { + return len(expire), err + } + } + for _, key := range expire { + delete(s.active, key) + } + for _, key := range publishKeys { + s.active[key] = next[key] + } + return len(expire), nil +} + +func shouldReplaceQuotePair(current, upcoming quotePairState, now time.Time, renewBefore time.Duration) bool { + if current.fingerprint != upcoming.fingerprint || upcoming.expiry < current.expiry { + return true + } + return current.expiry <= now.Add(renewBefore).Unix() +} + +func indexQuotePairs(quotes []types.Quote) map[quotePairKey]quotePairState { + grouped := make(map[quotePairKey][]types.Quote) + for _, quote := range quotes { + key := pairKey(quote) + grouped[key] = append(grouped[key], quote) + } + + out := make(map[quotePairKey]quotePairState, len(grouped)) + for key, pairQuotes := range grouped { + fingerprints := make([]string, 0, len(pairQuotes)) + expiry := int64(0) + for _, quote := range pairQuotes { + ranges := make([]string, 0, len(quote.Ranges)) + for _, r := range quote.Ranges { + ranges = append(ranges, bigString(r.MinAmount)+":"+bigString(r.MaxAmount)+":"+r.Quote) + } + fingerprints = append(fingerprints, strings.ToLower(quote.ExclusiveFor.Hex())+":"+strings.Join(ranges, ",")) + if expiry == 0 || quote.Expiry < expiry { + expiry = quote.Expiry + } + } + sort.Strings(fingerprints) + out[key] = quotePairState{ + fingerprint: strings.Join(fingerprints, "|"), + expiry: expiry, + quotes: append([]types.Quote(nil), pairQuotes...), + } + } + return out +} + +func pairKey(quote types.Quote) quotePairKey { + return quotePairKey{ + fromAsset: quote.FromAsset, toAsset: quote.ToAsset, + fromDecimals: quote.FromDecimals, toDecimals: quote.ToDecimals, + } +} + +func quotePairKeyString(key quotePairKey) string { + return strings.Join([]string{ + strings.ToLower(key.fromAsset.Hex()), strings.ToLower(key.toAsset.Hex()), + strconv.Itoa(key.fromDecimals), strconv.Itoa(key.toDecimals), + }, ":") +} + +func bigString(n *big.Int) string { + if n == nil { + return "" + } + return n.String() +} diff --git a/internal/solvers/lifi/quotes_test.go b/internal/solvers/lifi/quotes_test.go new file mode 100644 index 00000000..53682c91 --- /dev/null +++ b/internal/solvers/lifi/quotes_test.go @@ -0,0 +1,194 @@ +package lifi + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" + "github.com/symbioticfi/vault-solver/internal/tokenpolicy" +) + +type fakeQuoteSubmitter struct { + calls [][]types.Quote +} + +func TestFilterQuoteInventoryAppliesTokenScope(t *testing.T) { + permissioned := common.HexToAddress("0x1111111111111111111111111111111111111111") + permissionless := common.HexToAddress("0x2222222222222222222222222222222222222222") + inventory := []liquidlane.Inventory{ + {Route: liquidlane.Route{TokenIn: permissioned}}, + {Route: liquidlane.Route{TokenIn: permissionless}}, + } + + tests := []struct { + name string + scope tokenpolicy.Scope + want common.Address + }{ + {"permissioned", tokenpolicy.Permissioned, permissioned}, + {"permissionless", tokenpolicy.Permissionless, permissionless}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filtered := filterQuoteInventory(inventory, testTokenPolicy(t, tt.scope, permissioned)) + if len(filtered) != 1 || filtered[0].TokenIn != tt.want { + t.Fatalf("filtered inventory = %+v", filtered) + } + }) + } +} + +func (f *fakeQuoteSubmitter) submitQuotes(_ context.Context, quotes []types.Quote) error { + copyOfQuotes := append([]types.Quote(nil), quotes...) + f.calls = append(f.calls, copyOfQuotes) + return nil +} + +func TestQuoteStatePublishesAndReplacesChangedTopology(t *testing.T) { + routeItem := testQuoteRoute() + state := newQuoteState(30 * time.Second) + submitter := &fakeQuoteSubmitter{} + now := time.Unix(1_800_000_000, 0) + + first := testStandingQuote(routeItem, 1_000) + removed, err := state.reconcile(context.Background(), submitter, []types.Quote{first}, now) + if err != nil { + t.Fatalf("first reconcile: %v", err) + } + if removed != 0 || len(submitter.calls) != 1 || len(submitter.calls[0][0].Ranges) == 0 { + t.Fatalf("initial reconcile: removed=%d calls=%#v", removed, submitter.calls) + } + + second := testStandingQuote(routeItem, 1_000) + second.Ranges[0].Quote = "0.98" + if _, err := state.reconcile(context.Background(), submitter, []types.Quote{second}, now); err != nil { + t.Fatalf("same topology reconcile: %v", err) + } + if len(submitter.calls) != 2 || submitter.calls[1][0].Ranges[0].Quote != "0.98" { + t.Fatalf("changed price calls = %#v", submitter.calls) + } + + changed := testStandingQuote(routeItem, 2_000) + removed, err = state.reconcile(context.Background(), submitter, []types.Quote{changed}, now) + if err != nil { + t.Fatalf("changed topology reconcile: %v", err) + } + if removed != 0 || len(submitter.calls) != 3 || submitter.calls[2][0].Ranges[0].MaxAmount.String() != "2000" { + t.Fatalf("changed topology: removed=%d calls=%#v", removed, submitter.calls) + } +} + +func TestQuoteStateSkipsUnchangedPairUntilRenewalWindow(t *testing.T) { + routeItem := testQuoteRoute() + state := newQuoteState(30 * time.Second) + submitter := &fakeQuoteSubmitter{} + now := time.Unix(1_800_000_000, 0) + quote := testStandingQuote(routeItem, 1_000) + + if _, err := state.reconcile(context.Background(), submitter, []types.Quote{quote}, now); err != nil { + t.Fatalf("publish: %v", err) + } + refreshed := testStandingQuote(routeItem, 1_000) + refreshed.Expiry += 60 + if _, err := state.reconcile(context.Background(), submitter, []types.Quote{refreshed}, now.Add(30*time.Second)); err != nil { + t.Fatalf("unchanged: %v", err) + } + if len(submitter.calls) != 1 { + t.Fatalf("unchanged pair was republished: calls=%d", len(submitter.calls)) + } + if _, err := state.reconcile(context.Background(), submitter, []types.Quote{refreshed}, now.Add(90*time.Second)); err != nil { + t.Fatalf("renew: %v", err) + } + if len(submitter.calls) != 2 || submitter.calls[1][0].Ranges[0].MaxAmount.String() != "1000" { + t.Fatalf("renew calls = %#v", submitter.calls) + } +} + +func TestQuoteStateNeedsRenewalWithoutNewBlock(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + state := newQuoteState(30 * time.Second) + state.active[quotePairKey{}] = quotePairState{expiry: now.Add(time.Minute).Unix()} + + if state.needsRenewal(now) { + t.Fatal("quote entered renewal window too early") + } + if !state.needsRenewal(now.Add(30 * time.Second)) { + t.Fatal("quote should renew by wall clock even without a new block") + } +} + +func TestShouldRefreshQuotesInBlockMode(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + state := newQuoteState(30 * time.Second) + state.active[quotePairKey{}] = quotePairState{expiry: now.Add(time.Minute).Unix()} + solver := &Solver{ + cfg: &Config{QuoteRefreshMode: quoteRefreshModeBlock}, + reader: fakeLifiReader{latestBlock: 10}, + wallNow: func() time.Time { return now }, + log: logr.Discard(), + } + lastBlock := uint64(10) + + if solver.shouldRefreshQuotes(context.Background(), state, &lastBlock) { + t.Fatal("unchanged block outside renewal window should not refresh") + } + solver.reader = fakeLifiReader{latestBlock: 11} + if !solver.shouldRefreshQuotes(context.Background(), state, &lastBlock) || lastBlock != 11 { + t.Fatalf("new block was not observed: lastBlock=%d", lastBlock) + } + state.active[quotePairKey{}] = quotePairState{expiry: now.Add(30 * time.Second).Unix()} + solver.reader = fakeLifiReader{latestBlockErr: errors.New("rpc unavailable")} + if !solver.shouldRefreshQuotes(context.Background(), state, &lastBlock) { + t.Fatal("renewal should proceed when the block-number read fails") + } +} + +func TestQuoteStateRemovesPairWhenStrategyStopsQuoting(t *testing.T) { + routeItem := testQuoteRoute() + state := newQuoteState(30 * time.Second) + submitter := &fakeQuoteSubmitter{} + now := time.Unix(1_800_000_000, 0) + if _, err := state.reconcile(context.Background(), submitter, []types.Quote{testStandingQuote(routeItem, 1_000)}, now); err != nil { + t.Fatalf("publish: %v", err) + } + submitter.calls = nil + + removed, err := state.reconcile(context.Background(), submitter, nil, now) + if err != nil { + t.Fatalf("remove: %v", err) + } + if removed != 1 || len(submitter.calls) != 1 || len(submitter.calls[0]) != 1 || + len(submitter.calls[0][0].Ranges) == 0 || submitter.calls[0][0].Expiry >= now.Unix() { + t.Fatalf("remove: removed=%d calls=%#v", removed, submitter.calls) + } +} + +func testQuoteRoute() route { + return liquidlane.NewRoute( + 11155111, + common.HexToAddress("0x1111111111111111111111111111111111111111"), + common.HexToAddress("0x2222222222222222222222222222222222222222"), + common.HexToAddress("0x3333333333333333333333333333333333333333"), + common.HexToAddress("0x4444444444444444444444444444444444444444"), + 6, + 6, + ) +} + +func testStandingQuote(route route, maxAmount int64) types.Quote { + return types.Quote{ + FromAsset: route.TokenIn, ToAsset: route.TokenOut, + FromDecimals: route.TokenInDecimals, ToDecimals: route.TokenOutDecimals, + Ranges: []types.QuoteRange{{ + MinAmount: big.NewInt(100), MaxAmount: big.NewInt(maxAmount), Quote: "0.99", + }}, + Expiry: 1_800_000_120, + } +} diff --git a/internal/solvers/lifi/solver.go b/internal/solvers/lifi/solver.go new file mode 100644 index 00000000..fef22757 --- /dev/null +++ b/internal/solvers/lifi/solver.go @@ -0,0 +1,182 @@ +// Package lifi implements the LI.FI same-chain intent solver. It publishes LiquidLane-backed +// standing quotes to the LI.FI order server and listens for matched escrow orders. +package lifi + +import ( + "context" + "math/big" + "os" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + "golang.org/x/sync/errgroup" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/api/bindings/lifi/inputsettler" + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" + "github.com/symbioticfi/vault-solver/internal/solver" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" + "github.com/symbioticfi/vault-solver/internal/txmanager" +) + +const Name = "lifi-samechain" + +const lifiOrderStatusDeposited uint8 = 1 + +//nolint:gochecknoinits // self-registration with the solver framework is the intended plugin pattern. +func init() { + solver.Register(Name, factory) +} + +type Solver struct { + cfg *Config + chainID int64 + reader chainReader + strategy types.Strategy + caller common.Address + orders *orderClient + feed *orderFeed + txm txSender + log logr.Logger + now func(context.Context) (time.Time, error) + maxFeePerGas func(context.Context) (*big.Int, error) + wallNow func() time.Time + capacity liquidlane.CapacityLedger + quoteRefresh chan struct{} + discounts discounts.Provider +} + +type chainReader interface { + resolveRoutes(ctx context.Context, adapters []common.Address) ([]route, error) + validateExecutor( + ctx context.Context, + executor, inputSettler, outputSettler, caller common.Address, + ) error + validateZeroGovernanceFee(ctx context.Context, inputSettler common.Address) error + validateDirectAuthorization(ctx context.Context, executor common.Address, routes []route) error + validateGasTokens(routes []route) error + quoteSnapshots(ctx context.Context, routes []route, executor common.Address, chainTime time.Time) (quoteSnapshotSet, error) + fillSnapshots( + ctx context.Context, routes []route, executor, tokenIn common.Address, amountIn *big.Int, chainTime time.Time, + ) (fillSnapshotSet, error) + orderIdentifier(ctx context.Context, inputSettler common.Address, order inputsettler.StandardOrder) (common.Hash, error) + orderStatus(ctx context.Context, inputSettler common.Address, orderID common.Hash) (uint8, error) + latestBlockNumber(ctx context.Context) (uint64, error) + latestBlockTime(ctx context.Context) (time.Time, error) +} + +type txSender interface { + SendAsync(ctx context.Context, req txmanager.Request) (<-chan txmanager.Result, bool) +} + +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.OrderServer.APIKeyEnv) + if apiKey == "" { + return nil, errors.Errorf("%s: order server api key env %q is empty", Name, cfg.OrderServer.APIKeyEnv) + } + + log := deps.Log.WithName(Name) + chainID := deps.Chain.ChainID().Int64() + strategy, err := newStrategy(cfg.Strategy) + if err != nil { + return nil, err + } + reader, err := newReader(deps.Chain, log, cfg.Gas, cfg.LiquidityLens) + if err != nil { + return nil, err + } + result := &Solver{ + cfg: cfg, + chainID: chainID, + reader: reader, + strategy: strategy, + caller: deps.Signer.Address(), + orders: newOrderClient(cfg.OrderServer.BaseURL, apiKey, cfg.OrderServer.HTTPTimeout, chainID), + feed: newOrderFeed(cfg.OrderServer.WSURL, apiKey, log), + txm: deps.TxManager, + log: log, + now: reader.latestBlockTime, + maxFeePerGas: deps.TxManager.MaxFeePerGas, + wallNow: time.Now, + } + if cfg.usesDiscounts() { + result.discounts = discounts.NewClient(cfg.DiscountsURL) + } + return result, nil +} + +func (s *Solver) Name() string { return Name } + +func (s *Solver) Run(ctx context.Context) error { + routes, err := s.reader.resolveRoutes(ctx, s.cfg.Adapters) + if err != nil { + startupErr := errors.Errorf("lifi: resolve routes: %w", err) + s.log.Error(startupErr, "adapter resolution failed", + "solverMode", s.cfg.SolverMode, "executor", s.cfg.Executor.Hex(), "adapters", s.cfg.Adapters) + return startupErr + } + if len(routes) == 0 { + startupErr := errors.New("lifi: no quoteable routes resolved from configured adapters") + s.log.Error(startupErr, "adapter resolution failed", + "solverMode", s.cfg.SolverMode, "executor", s.cfg.Executor.Hex(), "adapters", s.cfg.Adapters) + return startupErr + } + if err := s.reader.validateGasTokens(routes); err != nil { + return errors.Errorf("lifi: validate gas oracles: %w", err) + } + if err := s.reader.validateExecutor( + ctx, s.cfg.Executor, s.cfg.InputSettler, s.cfg.OutputSettler, s.caller, + ); err != nil { + startupErr := errors.Errorf("lifi: validate executor: %w", err) + s.log.Error(startupErr, "executor validation failed", + "executor", s.cfg.Executor.Hex(), "caller", s.caller.Hex(), + "inputSettler", s.cfg.InputSettler.Hex(), "outputSettler", s.cfg.OutputSettler.Hex()) + return startupErr + } + if err := s.reader.validateZeroGovernanceFee(ctx, s.cfg.InputSettler); err != nil { + return errors.Errorf("lifi: validate governance fee: %w", err) + } + if !s.cfg.usesDiscounts() { + if err := s.reader.validateDirectAuthorization(ctx, s.cfg.Executor, routes); err != nil { + startupErr := errors.Errorf("lifi: validate direct authorization: %w", err) + s.log.Error(startupErr, "external adapter authorization failed", + "solverMode", s.cfg.SolverMode, + "executor", s.cfg.Executor.Hex(), + "adapters", s.cfg.Adapters, + ) + return startupErr + } + } + if err := s.orders.validateExecutorRegistration(ctx, s.cfg.Executor); err != nil { + return err + } + if err := s.orders.ensureSupportedContracts(ctx, s.chainID, s.cfg.InputSettler, s.cfg.OutputSettler); err != nil { + return err + } + + s.log.Info("starting", + "routes", len(routes), + "baseUrl", s.cfg.OrderServer.BaseURL, + "wsUrl", s.cfg.OrderServer.WSURL, + "quoteRefreshMode", s.cfg.QuoteRefreshMode, + "quoteInterval", s.cfg.QuoteInterval.String(), + "quoteTTL", s.cfg.QuoteTTL.String(), + "solverMode", s.cfg.SolverMode, + "tokensToQuote", s.cfg.TokenPolicy.Scope(), + "executor", s.cfg.Executor.Hex(), + "caller", s.caller.Hex(), + ) + + s.quoteRefresh = make(chan struct{}, 1) + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { return s.quoteLoop(gctx, routes, s.quoteRefresh) }) + g.Go(func() error { return s.runOrderFeed(gctx, routes) }) + return g.Wait() +} diff --git a/internal/solvers/lifi/solver_test.go b/internal/solvers/lifi/solver_test.go new file mode 100644 index 00000000..3349bfa3 --- /dev/null +++ b/internal/solvers/lifi/solver_test.go @@ -0,0 +1,703 @@ +package lifi + +import ( + "context" + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + "github.com/go-logr/logr/funcr" + + "github.com/symbioticfi/vault-solver/api/bindings/lifi/inputsettler" + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" + defaultstrategy "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/default" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" + "github.com/symbioticfi/vault-solver/internal/tokenpolicy" + "github.com/symbioticfi/vault-solver/internal/txmanager" +) + +func TestRunLogsExternalAdapterAuthorizationFailure(t *testing.T) { + adapter := common.HexToAddress("0x1111111111111111111111111111111111111111") + executor := common.HexToAddress("0x2222222222222222222222222222222222222222") + var logs []string + s := &Solver{ + cfg: &Config{ + SolverMode: solverModeExternal, + Adapters: []common.Address{adapter}, + Executor: executor, + }, + reader: fakeLifiReader{ + routes: []route{{Adapter: adapter}}, + directAuthErr: errors.New("executor is not an authorized filler"), + }, + log: funcr.NewJSON(func(entry string) { logs = append(logs, entry) }, funcr.Options{}), + } + + err := s.Run(t.Context()) + if err == nil || !strings.Contains(err.Error(), "validate direct authorization") { + t.Fatalf("Run() error = %v", err) + } + logged := strings.Join(logs, "\n") + if !strings.Contains(logged, "external adapter authorization failed") || + !strings.Contains(logged, "executor is not an authorized filler") || + !strings.Contains(logged, executor.Hex()) || + !strings.Contains(logged, `"error"`) { + t.Fatalf("authorization failure was not logged as an error: %s", logged) + } +} + +func TestRunRejectsNonZeroGovernanceFee(t *testing.T) { + adapter := common.HexToAddress("0x1111111111111111111111111111111111111111") + s := &Solver{ + cfg: &Config{Adapters: []common.Address{adapter}}, + reader: fakeLifiReader{ + routes: []route{{Adapter: adapter}}, + governanceFeeErr: errors.New("input settler governance fee is 1, expected zero"), + }, + log: logr.Discard(), + } + + err := s.Run(t.Context()) + if err == nil || !strings.Contains(err.Error(), "validate governance fee") { + t.Fatalf("Run() error = %v", err) + } +} + +func TestRunLogsExecutorValidationFailure(t *testing.T) { + adapter := common.HexToAddress("0x1111111111111111111111111111111111111111") + executor := common.HexToAddress("0x2222222222222222222222222222222222222222") + caller := common.HexToAddress("0x3333333333333333333333333333333333333333") + var logs []string + s := &Solver{ + cfg: &Config{Adapters: []common.Address{adapter}, Executor: executor}, + caller: caller, + reader: fakeLifiReader{ + routes: []route{{Adapter: adapter}}, + executorErr: errors.New("caller is not authorized"), + }, + log: funcr.NewJSON(func(entry string) { logs = append(logs, entry) }, funcr.Options{}), + } + + err := s.Run(t.Context()) + if err == nil || !strings.Contains(err.Error(), "lifi: validate executor: caller is not authorized") { + t.Fatalf("Run() error = %v", err) + } + logged := strings.Join(logs, "\n") + if !strings.Contains(logged, "executor validation failed") || + !strings.Contains(logged, "caller is not authorized") || + !strings.Contains(logged, executor.Hex()) || + !strings.Contains(logged, caller.Hex()) || + !strings.Contains(logged, `"error"`) { + t.Fatalf("executor failure was not logged with its reason: %s", logged) + } +} + +func (s *Solver) processOrder(ctx context.Context, routes []route, order *submittedOrder) { + s.processOrderWithPending(ctx, routes, order, nil) +} + +type fakeLifiTxSender struct { + reqs []txmanager.Request + results []chan txmanager.Result + result txmanager.Result + reject bool + hold bool + onSend func(int, chan<- txmanager.Result) +} + +func (f *fakeLifiTxSender) SendAsync( + _ context.Context, + req txmanager.Request, +) (<-chan txmanager.Result, bool) { + if f.reject { + return nil, false + } + f.reqs = append(f.reqs, req) + result := make(chan txmanager.Result, 1) + f.results = append(f.results, result) + if f.onSend != nil { + f.onSend(len(f.reqs), result) + } + if !f.hold { + result <- f.fillResult() + } + return result, true +} + +func (f *fakeLifiTxSender) fillResult() txmanager.Result { + if f.result.Err != nil || f.result.Receipt != nil || f.result.Hash != (common.Hash{}) { + return f.result + } + return txmanager.Result{ + Hash: common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + Receipt: ðtypes.Receipt{Status: ethtypes.ReceiptStatusSuccessful}, + } +} + +type fixedFillStrategy struct { + plan *types.FillPlan +} + +func (s fixedFillStrategy) DecideQuotes(context.Context, types.QuoteInput) (types.QuoteOutput, error) { + return types.QuoteOutput{}, nil +} + +func (s fixedFillStrategy) DecideFill(context.Context, types.FillInput) (*types.FillPlan, error) { + return s.plan, nil +} + +type reservationAwareFillStrategy struct { + plan *types.FillPlan + inputs chan types.FillInput +} + +func (s reservationAwareFillStrategy) DecideQuotes( + context.Context, + types.QuoteInput, +) (types.QuoteOutput, error) { + return types.QuoteOutput{}, nil +} + +func (s reservationAwareFillStrategy) DecideFill( + _ context.Context, + input types.FillInput, +) (*types.FillPlan, error) { + s.inputs <- input + if len(input.Reservations) != 0 { + return nil, nil + } + return s.plan, nil +} + +func TestProcessOrderSubmitsImmediateFill(t *testing.T) { + fixture := immediateTestSetup(t) + strategy, err := defaultstrategy.New(defaultstrategy.Config{}) + if err != nil { + t.Fatalf("New strategy: %v", err) + } + txm := &fakeLifiTxSender{} + s := newProcessTestSolver(fixture.cfg, fixture.caller, txm, strategy, fixture.tokenIn, fixture.tokenOut, fixture.adapter, lifiOrderStatusDeposited) + + s.processOrder(context.Background(), testResolvedRoutes(fixture.tokenIn, fixture.tokenOut, fixture.adapter), testSubmittedOrder(t, fixture.cfg, fixture.tokenIn, fixture.tokenOut)) + if len(txm.reqs) != 1 { + t.Fatalf("txmanager.Send calls = %d, want 1", len(txm.reqs)) + } + if txm.reqs[0].To != fixture.cfg.Executor || txm.reqs[0].Label != "lifi-fill" || len(txm.reqs[0].Data) == 0 { + t.Fatalf("bad fill request: %+v", txm.reqs[0]) + } + if txm.reqs[0].MaxFeePerGas == nil || txm.reqs[0].MaxFeePerGas.Cmp(big.NewInt(1)) != 0 { + t.Fatalf("fill max fee per gas = %v, want 1", txm.reqs[0].MaxFeePerGas) + } +} + +func TestProcessOrderSkipsInputTokenOutsideScopeBeforeChainReads(t *testing.T) { + fixture := immediateTestSetup(t) + otherToken := common.HexToAddress("0x9999999999999999999999999999999999999999") + fixture.cfg.TokenPolicy = testTokenPolicy(t, tokenpolicy.Permissioned, otherToken) + txm := &fakeLifiTxSender{} + s := newProcessTestSolver( + fixture.cfg, fixture.caller, txm, fixedFillStrategy{}, + fixture.tokenIn, fixture.tokenOut, fixture.adapter, lifiOrderStatusDeposited, + ) + orderIDReads := 0 + s.reader = fakeLifiReader{orderIDFn: func(inputsettler.StandardOrder) common.Hash { + orderIDReads++ + return common.Hash{} + }} + + s.processOrder( + context.Background(), testResolvedRoutes(fixture.tokenIn, fixture.tokenOut, fixture.adapter), + testSubmittedOrder(t, fixture.cfg, fixture.tokenIn, fixture.tokenOut), + ) + if orderIDReads != 0 || len(txm.reqs) != 0 { + t.Fatalf("out-of-scope order: orderID reads=%d txs=%d", orderIDReads, len(txm.reqs)) + } +} + +func TestProcessOrderSkipsWhenGovernanceFeeInvariantFails(t *testing.T) { + fixture := immediateTestSetup(t) + txm := &fakeLifiTxSender{} + s := newProcessTestSolver( + fixture.cfg, fixture.caller, txm, fixedFillStrategy{}, + fixture.tokenIn, fixture.tokenOut, fixture.adapter, lifiOrderStatusDeposited, + ) + orderIDReads := 0 + s.reader = fakeLifiReader{ + governanceFeeErr: errors.New("input settler governance fee is 1, expected zero"), + orderIDFn: func(inputsettler.StandardOrder) common.Hash { + orderIDReads++ + return common.Hash{} + }, + } + var logs []string + s.log = funcr.NewJSON(func(entry string) { logs = append(logs, entry) }, funcr.Options{}) + + s.processOrder( + context.Background(), testResolvedRoutes(fixture.tokenIn, fixture.tokenOut, fixture.adapter), + testSubmittedOrder(t, fixture.cfg, fixture.tokenIn, fixture.tokenOut), + ) + if orderIDReads != 0 || len(txm.reqs) != 0 { + t.Fatalf("fee-bearing order: orderID reads=%d txs=%d", orderIDReads, len(txm.reqs)) + } + logged := strings.Join(logs, "\n") + if !strings.Contains(logged, "governance fee invariant failed") || !strings.Contains(logged, `"error"`) { + t.Fatalf("governance fee failure was not logged as an error: %s", logged) + } +} + +func TestProcessOrderFillsThroughPrivateDiscountWithoutDirectAuthorization(t *testing.T) { + fixture := immediateTestSetup(t) + now := time.Unix(1_700_000_000, 0) + strategy, err := defaultstrategy.New(defaultstrategy.Config{}) + if err != nil { + t.Fatalf("New strategy: %v", err) + } + routeItem := testResolvedRoutes(fixture.tokenIn, fixture.tokenOut, fixture.adapter)[0] + baseInventory := liquidlane.DirectInventory( + routeItem, + big.NewInt(2_000_000), + big.NewInt(1_000_000_000_000_000_000), + ) + baseInventory.AdapterMinDiscount = big.NewInt(100_000) + base := liquidlane.FillQuote{ + Inventory: baseInventory, AmountIn: big.NewInt(1_000_000), GrossAmountOut: big.NewInt(1_100_100), + MaxAmountOut: big.NewInt(990_090), MinDiscount: big.NewInt(100_000), + } + discounts := &fakeDiscountClient{ + listed: &discounts.List{Discounts: []discounts.ListItem{ + testDiscountListItem(base.Inventory, 2_000_000, now.Add(time.Minute)), + }}, + resolved: testResolvedDiscount(base.Inventory, 100_000, now.Add(time.Minute)), + } + txm := &fakeLifiTxSender{} + s := newProcessTestSolver( + fixture.cfg, fixture.caller, txm, strategy, + fixture.tokenIn, fixture.tokenOut, fixture.adapter, lifiOrderStatusDeposited, + ) + s.discounts = discounts + fillReads := 0 + s.reader = fakeLifiReader{ + orderID: common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + status: lifiOrderStatusDeposited, + fillSetFn: func() fillSnapshotSet { + fillReads++ + return fillSnapshotSet{Physical: []liquidlane.FillQuote{base}} + }, + } + s.now = func(context.Context) (time.Time, error) { return now, nil } + + s.processOrder( + context.Background(), []route{routeItem}, + testSubmittedOrder(t, fixture.cfg, fixture.tokenIn, fixture.tokenOut), + ) + if len(txm.reqs) != 1 || discounts.listCalls != 1 || discounts.resolveCalls != 1 || fillReads != 2 { + t.Fatalf( + "txs=%d discount calls=%d/%d fill reads=%d", + len(txm.reqs), discounts.listCalls, discounts.resolveCalls, fillReads, + ) + } +} + +func TestProcessOrderRejectsMultiRoutePlanForPermissionedToken(t *testing.T) { + fixture := immediateTestSetup(t) + fixture.cfg.TokenPolicy = testTokenPolicy(t, tokenpolicy.Permissioned, fixture.tokenIn) + txm := &fakeLifiTxSender{} + plan := &types.FillPlan{Routes: []types.FillRoute{ + {RouteID: "route-1", Adapter: fixture.adapter, AmountIn: big.NewInt(500_000)}, + {RouteID: "route-2", Adapter: fixture.adapter, AmountIn: big.NewInt(500_000)}, + }} + s := newProcessTestSolver( + fixture.cfg, fixture.caller, txm, fixedFillStrategy{plan: plan}, + fixture.tokenIn, fixture.tokenOut, fixture.adapter, lifiOrderStatusDeposited, + ) + + s.processOrder( + context.Background(), testResolvedRoutes(fixture.tokenIn, fixture.tokenOut, fixture.adapter), + testSubmittedOrder(t, fixture.cfg, fixture.tokenIn, fixture.tokenOut), + ) + if len(txm.reqs) != 0 { + t.Fatalf("permissioned multi-route plan submitted %d transactions", len(txm.reqs)) + } +} + +func TestRoutesForPairUsesBothTokens(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + other := common.HexToAddress("0x3333333333333333333333333333333333333333") + routes := []route{ + {ID: "exact", TokenIn: tokenIn, TokenOut: tokenOut}, + {ID: "wrong-output", TokenIn: tokenIn, TokenOut: other}, + {ID: "wrong-input", TokenIn: other, TokenOut: tokenOut}, + } + + got := routesForPair(routes, tokenIn, tokenOut) + if len(got) != 1 || got[0].ID != "exact" { + t.Fatalf("routes = %+v", got) + } +} + +func TestProcessOrderChecksOnChainStatusBeforeSend(t *testing.T) { + fixture := immediateTestSetup(t) + strategy, err := defaultstrategy.New(defaultstrategy.Config{}) + if err != nil { + t.Fatalf("New strategy: %v", err) + } + txm := &fakeLifiTxSender{} + s := newProcessTestSolver(fixture.cfg, fixture.caller, txm, strategy, fixture.tokenIn, fixture.tokenOut, fixture.adapter, 2) + fillReads := 0 + s.reader = fakeLifiReader{ + orderID: common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + status: 2, + fillSnapshotsFn: func() []liquidlane.FillQuote { + fillReads++ + return profitableFillSnapshots(fixture.tokenIn, fixture.tokenOut, fixture.adapter, 1_000_000) + }, + } + + s.processOrder(context.Background(), testResolvedRoutes(fixture.tokenIn, fixture.tokenOut, fixture.adapter), testSubmittedOrder(t, fixture.cfg, fixture.tokenIn, fixture.tokenOut)) + if len(txm.reqs) != 0 || fillReads != 0 { + t.Fatalf("closed order txs = %d fillReads = %d", len(txm.reqs), fillReads) + } +} + +func TestProcessOrderDoesNotRetryFailedSend(t *testing.T) { + fixture := immediateTestSetup(t) + strategy, err := defaultstrategy.New(defaultstrategy.Config{}) + if err != nil { + t.Fatalf("New strategy: %v", err) + } + txm := &fakeLifiTxSender{result: txmanager.Result{Err: errors.New("send failed")}} + s := newProcessTestSolver(fixture.cfg, fixture.caller, txm, strategy, fixture.tokenIn, fixture.tokenOut, fixture.adapter, lifiOrderStatusDeposited) + + s.processOrder(context.Background(), testResolvedRoutes(fixture.tokenIn, fixture.tokenOut, fixture.adapter), testSubmittedOrder(t, fixture.cfg, fixture.tokenIn, fixture.tokenOut)) + if len(txm.reqs) != 1 { + t.Fatalf("failed send attempts = %d, want 1", len(txm.reqs)) + } +} + +func TestProcessOrderDropsWhenTransactionSubmissionIsRejected(t *testing.T) { + fixture := immediateTestSetup(t) + strategy, err := defaultstrategy.New(defaultstrategy.Config{}) + if err != nil { + t.Fatalf("New strategy: %v", err) + } + txm := &fakeLifiTxSender{reject: true} + s := newProcessTestSolver( + fixture.cfg, fixture.caller, txm, strategy, + fixture.tokenIn, fixture.tokenOut, fixture.adapter, lifiOrderStatusDeposited, + ) + + s.processOrder( + context.Background(), testResolvedRoutes(fixture.tokenIn, fixture.tokenOut, fixture.adapter), + testSubmittedOrder(t, fixture.cfg, fixture.tokenIn, fixture.tokenOut), + ) + if len(txm.reqs) != 0 { + t.Fatalf("busy sender accepted %d requests", len(txm.reqs)) + } +} + +func TestOrderWorkerReplansQueuedOrderBeforeSend(t *testing.T) { + fixture := immediateTestSetup(t) + strategy, err := defaultstrategy.New(defaultstrategy.Config{}) + if err != nil { + t.Fatalf("New strategy: %v", err) + } + availableOutput := int64(1_000_000) + maxFeePerGas := big.NewInt(1) + txm := &fakeLifiTxSender{onSend: func(attempt int, _ chan<- txmanager.Result) { + if attempt == 1 { + availableOutput = 980_000 + maxFeePerGas = big.NewInt(2) + } + }} + s := newProcessTestSolver( + fixture.cfg, fixture.caller, txm, strategy, + fixture.tokenIn, fixture.tokenOut, fixture.adapter, lifiOrderStatusDeposited, + ) + fillReads := 0 + feeReads := 0 + s.reader = fakeLifiReader{ + status: lifiOrderStatusDeposited, + orderIDFn: func(order inputsettler.StandardOrder) common.Hash { + return common.BigToHash(order.Nonce) + }, + fillSnapshotsFn: func() []liquidlane.FillQuote { + fillReads++ + return profitableFillSnapshots( + fixture.tokenIn, fixture.tokenOut, fixture.adapter, availableOutput, + ) + }, + } + s.maxFeePerGas = func(context.Context) (*big.Int, error) { + feeReads++ + return new(big.Int).Set(maxFeePerGas), nil + } + first := testSubmittedOrder(t, fixture.cfg, fixture.tokenIn, fixture.tokenOut) + secondValue := *first + secondValue.OrderID = "order-2" + secondValue.OnChainOrderID = "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + secondValue.Order.Nonce = new(big.Int).Add(first.Order.Nonce, big.NewInt(1)) + orders := make(chan *submittedOrder, 2) + orders <- first + orders <- &secondValue + close(orders) + + if err := s.runOrderWorker( + context.Background(), testResolvedRoutes(fixture.tokenIn, fixture.tokenOut, fixture.adapter), orders, + ); err != nil { + t.Fatalf("runOrderWorker: %v", err) + } + if fillReads != 2 || feeReads != 2 { + t.Fatalf("fresh state reads: fills=%d fees=%d, want 2/2", fillReads, feeReads) + } + if len(txm.reqs) != 1 { + t.Fatalf("fill attempts = %d, want 1 after second order becomes unprofitable", len(txm.reqs)) + } +} + +func TestOrderWorkerSubmitsAllFillsWithoutWaitingForReceipts(t *testing.T) { + fixture := immediateTestSetup(t) + strategy, err := defaultstrategy.New(defaultstrategy.Config{}) + if err != nil { + t.Fatalf("New strategy: %v", err) + } + submitted := make(chan chan<- txmanager.Result, 5) + txm := &fakeLifiTxSender{ + hold: true, + onSend: func(_ int, result chan<- txmanager.Result) { + submitted <- result + }, + } + s := newProcessTestSolver( + fixture.cfg, fixture.caller, txm, strategy, + fixture.tokenIn, fixture.tokenOut, fixture.adapter, lifiOrderStatusDeposited, + ) + fillReads := 0 + feeReads := 0 + s.reader = fakeLifiReader{ + status: lifiOrderStatusDeposited, + orderIDFn: func(order inputsettler.StandardOrder) common.Hash { + return common.BigToHash(order.Nonce) + }, + fillSnapshotsFn: func() []liquidlane.FillQuote { + fillReads++ + fills := profitableFillSnapshots(fixture.tokenIn, fixture.tokenOut, fixture.adapter, 1_000_000) + fills[0].MaxAssets = big.NewInt(10_000_000) + return fills + }, + } + s.maxFeePerGas = func(context.Context) (*big.Int, error) { + feeReads++ + return big.NewInt(1), nil + } + + base := testSubmittedOrder(t, fixture.cfg, fixture.tokenIn, fixture.tokenOut) + orders := make(chan *submittedOrder, 5) + for i := int64(0); i < 5; i++ { + order := *base + order.OrderID = "order-" + big.NewInt(i+1).String() + order.Order.Nonce = new(big.Int).Add(base.Order.Nonce, big.NewInt(i)) + orders <- &order + } + close(orders) + done := make(chan error, 1) + go func() { + done <- s.runOrderWorker( + context.Background(), testResolvedRoutes(fixture.tokenIn, fixture.tokenOut, fixture.adapter), orders, + ) + }() + + results := make([]chan<- txmanager.Result, 0, 5) + for range 5 { + results = append(results, receiveFillSubmission(t, submitted)) + } + for _, result := range results { + result <- txm.fillResult() + } + select { + case err := <-done: + if err != nil { + t.Fatalf("runOrderWorker: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("worker did not finish after receipts") + } + if fillReads != 5 || feeReads != 5 || len(txm.reqs) != 5 { + t.Fatalf("fills=%d fees=%d submissions=%d, want 5/5/5", fillReads, feeReads, len(txm.reqs)) + } + for i, req := range txm.reqs { + if req.Confirmations == nil || *req.Confirmations != 0 { + t.Fatalf("request %d confirmations = %v, want inclusion receipt", i, req.Confirmations) + } + } +} + +func TestOrderWorkerPassesPendingReservationsToNextFillDecision(t *testing.T) { + fixture := immediateTestSetup(t) + inputs := make(chan types.FillInput, 2) + plan := &types.FillPlan{Routes: []types.FillRoute{{ + RouteID: "route-1", + CapacityID: "capacity-1", + Adapter: fixture.adapter, + AmountIn: big.NewInt(1_000_000), + ExpectedAmountOut: big.NewInt(1_000_000), + MinAmountOut: big.NewInt(990_001), + ReservedAmountOut: big.NewInt(1_000_000), + }}} + txm := &fakeLifiTxSender{hold: true} + s := newProcessTestSolver( + fixture.cfg, + fixture.caller, + txm, + reservationAwareFillStrategy{plan: plan, inputs: inputs}, + fixture.tokenIn, + fixture.tokenOut, + fixture.adapter, + lifiOrderStatusDeposited, + ) + s.reader = fakeLifiReader{ + status: lifiOrderStatusDeposited, + orderIDFn: func(order inputsettler.StandardOrder) common.Hash { + return common.BigToHash(order.Nonce) + }, + fillSnapshotsFn: func() []liquidlane.FillQuote { + return profitableFillSnapshots(fixture.tokenIn, fixture.tokenOut, fixture.adapter, 1_000_000) + }, + } + + first := testSubmittedOrder(t, fixture.cfg, fixture.tokenIn, fixture.tokenOut) + secondValue := *first + secondValue.OrderID = "order-2" + secondValue.Order.Nonce = new(big.Int).Add(first.Order.Nonce, big.NewInt(1)) + orders := make(chan *submittedOrder, 2) + orders <- first + orders <- &secondValue + close(orders) + done := make(chan error, 1) + go func() { + done <- s.runOrderWorker( + t.Context(), + testResolvedRoutes(fixture.tokenIn, fixture.tokenOut, fixture.adapter), + orders, + ) + }() + + firstInput := receiveFillInput(t, inputs) + if firstInput.Solver != fixture.cfg.Executor { + t.Fatalf("solver = %s, want executor %s", firstInput.Solver.Hex(), fixture.cfg.Executor.Hex()) + } + if len(firstInput.Reservations) != 0 { + t.Fatalf("first fill reservations = %v, want none", firstInput.Reservations) + } + secondInput := receiveFillInput(t, inputs) + if got := secondInput.Reservations["capacity-1"]; got == nil || got.Cmp(big.NewInt(1_000_000)) != 0 { + t.Fatalf("second fill reservations = %v, want capacity-1=1000000", secondInput.Reservations) + } + if len(txm.reqs) != 1 { + t.Fatalf("submitted fills = %d, want 1", len(txm.reqs)) + } + txm.results[0] <- txm.fillResult() + select { + case err := <-done: + if err != nil { + t.Fatalf("runOrderWorker: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("worker did not finish after receipt") + } +} + +func receiveFillInput(t *testing.T, inputs <-chan types.FillInput) types.FillInput { + t.Helper() + select { + case input := <-inputs: + return input + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for fill decision") + return types.FillInput{} + } +} + +func receiveFillSubmission(t *testing.T, submitted <-chan chan<- txmanager.Result) chan<- txmanager.Result { + t.Helper() + select { + case result := <-submitted: + return result + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for fill submission") + return nil + } +} + +type processTestFixture struct { + cfg *Config + caller common.Address + tokenIn common.Address + tokenOut common.Address + adapter common.Address +} + +func immediateTestSetup(t *testing.T) processTestFixture { + t.Helper() + cfg := testLifiConfig() + caller := common.HexToAddress("0x5555555555555555555555555555555555555555") + tokenIn := common.HexToAddress("0x6666666666666666666666666666666666666666") + tokenOut := common.HexToAddress("0x7777777777777777777777777777777777777777") + adapter := common.HexToAddress("0x9999999999999999999999999999999999999999") + return processTestFixture{cfg: cfg, caller: caller, tokenIn: tokenIn, tokenOut: tokenOut, adapter: adapter} +} + +func newProcessTestSolver( + cfg *Config, + caller common.Address, + txm *fakeLifiTxSender, + strategy types.Strategy, + tokenIn, tokenOut, adapter common.Address, + status uint8, +) *Solver { + return &Solver{ + cfg: cfg, chainID: 11155111, + reader: fakeLifiReader{ + orderID: common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + status: status, fill: profitableFillSnapshots(tokenIn, tokenOut, adapter, 1_000_000), + }, + strategy: strategy, caller: caller, txm: txm, log: logr.Discard(), + now: func(context.Context) (time.Time, error) { return time.Unix(1_700_000_000, 0), nil }, + maxFeePerGas: func(context.Context) (*big.Int, error) { return big.NewInt(1), nil }, + } +} + +func profitableFillSnapshots(tokenIn, tokenOut, adapter common.Address, amountOut int64) []liquidlane.FillQuote { + return []liquidlane.FillQuote{{ + Inventory: liquidlane.Inventory{ + Route: liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", Adapter: adapter, TokenIn: tokenIn, TokenOut: tokenOut, + }, + MaxAssets: big.NewInt(2_000_000), + }, + AmountIn: big.NewInt(1_000_000), + MaxAmountOut: big.NewInt(amountOut), + }} +} + +func testResolvedRoutes(tokenIn, tokenOut, adapter common.Address) []route { + return []route{{ + ID: "route-1", CapacityID: "capacity-1", Adapter: adapter, TokenIn: tokenIn, TokenOut: tokenOut, + TokenInDecimals: 6, TokenOutDecimals: 6, + }} +} + +func testSubmittedOrder(t *testing.T, cfg *Config, tokenIn, tokenOut common.Address) *submittedOrder { + t.Helper() + order, err := parseSubmittedOrder(testOrderJSON(t, cfg, tokenIn, tokenOut), cfg, 11155111) + if err != nil { + t.Fatalf("parseSubmittedOrder: %v", err) + } + return order +} diff --git a/internal/solvers/lifi/strategies/default/fill.go b/internal/solvers/lifi/strategies/default/fill.go new file mode 100644 index 00000000..14e723b2 --- /dev/null +++ b/internal/solvers/lifi/strategies/default/fill.go @@ -0,0 +1,69 @@ +package defaultstrategy + +import ( + "context" + + "github.com/go-errors/errors" + + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" + liquidgreedy "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies/greedy" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" +) + +func (s *Strategy) DecideFill(_ context.Context, input types.FillInput) (*types.FillPlan, error) { + if input.AmountIn == nil || input.AmountIn.Sign() <= 0 { + return nil, errors.New("amountIn: must be positive") + } + if input.OutputAmount == nil || input.OutputAmount.Sign() <= 0 { + return nil, errors.New("outputAmount: must be positive") + } + if input.AmountIn.Cmp(s.minAmount) < 0 { + return nil, nil + } + validAfter := input.ChainTime.Add(s.executionBuffer) + deadlineCutoff := uint32Time(validAfter) + if input.Expires != 0 && input.Expires <= deadlineCutoff { + return nil, nil + } + if input.FillDeadline != 0 && input.FillDeadline <= deadlineCutoff { + return nil, nil + } + output, err := parseOutputContext(input.OutputAmount, input.OutputContext) + if err != nil { + return nil, err + } + maxRoutes := types.MaxRoutes + if input.RequireSingleRoute { + maxRoutes = 1 + } + gasPricing, err := liquidstrategies.NewGasPricing( + input.MaxFeePerGas, + input.TokenOut, + input.GasPrices, + input.GasSnapshot, + s.cfg.InventoryReserveBps, + types.LiquidLaneGasEnvelope(), + ) + if err != nil { + return nil, err + } + allocation, err := liquidgreedy.SolveFill(liquidgreedy.FillTask{ + TokenIn: input.TokenIn, TokenOut: input.TokenOut, AmountIn: input.AmountIn, + Quotes: input.Quotes, Reservations: input.Reservations, ValidAfter: validAfter, + MaxRoutes: maxRoutes, PriceBufferBps: s.cfg.PriceBufferBps, + InventoryReserveBps: s.cfg.InventoryReserveBps, + GasPricing: &gasPricing, + }) + if err != nil || allocation == nil { + return nil, err + } + requiredAmountOut, ok := output.fill(input.Solver, input.ChainTime, allocation.MaxAmountOut()) + if !ok { + return nil, nil + } + routes := allocation.Finalize(requiredAmountOut) + if len(routes) == 0 { + return nil, nil + } + return &types.FillPlan{Routes: routes}, nil +} diff --git a/internal/solvers/lifi/strategies/default/math.go b/internal/solvers/lifi/strategies/default/math.go new file mode 100644 index 00000000..3cdcff9d --- /dev/null +++ b/internal/solvers/lifi/strategies/default/math.go @@ -0,0 +1,31 @@ +package defaultstrategy + +import ( + "math/big" + "strings" +) + +func minBig(left, right *big.Int) *big.Int { + if left.Cmp(right) <= 0 { + return new(big.Int).Set(left) + } + return new(big.Int).Set(right) +} + +func fixedPointDecimal(n *big.Int, scale int) string { + if n.Sign() == 0 { + return "0" + } + unit := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scale)), nil) + intPart := new(big.Int).Div(new(big.Int).Set(n), unit) + fracPart := new(big.Int).Mod(new(big.Int).Set(n), unit) + if fracPart.Sign() == 0 { + return intPart.String() + } + frac := fracPart.String() + if len(frac) < scale { + frac = strings.Repeat("0", scale-len(frac)) + frac + } + frac = strings.TrimRight(frac, "0") + return intPart.String() + "." + frac +} diff --git a/internal/solvers/lifi/strategies/default/output.go b/internal/solvers/lifi/strategies/default/output.go new file mode 100644 index 00000000..b0cc207a --- /dev/null +++ b/internal/solvers/lifi/strategies/default/output.go @@ -0,0 +1,86 @@ +package defaultstrategy + +import ( + "encoding/binary" + "math" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" +) + +const ( + limitOrderContextType = 0x00 + dutchAuctionContextType = 0x01 + exclusiveLimitOrderContextType = 0xe0 + exclusiveDutchAuctionContextType = 0xe1 +) + +type outputPricing struct { + amount *big.Int + + startTime uint32 + + exclusive bool + exclusiveFor [32]byte +} + +func parseOutputContext(outputAmount *big.Int, outputContext []byte) (*outputPricing, error) { + out := &outputPricing{amount: new(big.Int).Set(outputAmount)} + if len(outputContext) == 0 { + return out, nil + } + switch outputContext[0] { + case limitOrderContextType: + if len(outputContext) != 1 { + return nil, errors.Errorf("outputContext: limit order length must be 1, got %d", len(outputContext)) + } + return out, nil + case dutchAuctionContextType: + return nil, errors.New("outputContext: Dutch auctions are not supported") + case exclusiveLimitOrderContextType: + if len(outputContext) != 37 { + return nil, errors.Errorf("outputContext: exclusive limit length must be 37, got %d", len(outputContext)) + } + out.exclusive = true + copy(out.exclusiveFor[:], outputContext[1:33]) + out.startTime = binary.BigEndian.Uint32(outputContext[33:37]) + return out, nil + case exclusiveDutchAuctionContextType: + return nil, errors.New("outputContext: Dutch auctions are not supported") + default: + return nil, errors.Errorf("outputContext: unsupported type 0x%02x", outputContext[0]) + } +} + +func (o *outputPricing) fill(solver common.Address, now time.Time, acceptableAmount *big.Int) (*big.Int, bool) { + currentTime := uint32Time(now) + if o.exclusive && currentTime < o.startTime { + solverID := solverIdentifier(solver) + if o.exclusiveFor != solverID { + return nil, false + } + } + if o.amount.Cmp(acceptableAmount) > 0 { + return nil, false + } + return new(big.Int).Set(o.amount), true +} + +func uint32Time(t time.Time) uint32 { + unix := t.Unix() + if unix <= 0 { + return 0 + } + if unix > math.MaxUint32 { + return math.MaxUint32 + } + return uint32(unix) +} + +func solverIdentifier(addr common.Address) [32]byte { + var out [32]byte + copy(out[12:], addr.Bytes()) + return out +} diff --git a/internal/solvers/lifi/strategies/default/quote.go b/internal/solvers/lifi/strategies/default/quote.go new file mode 100644 index 00000000..f1e2e8b4 --- /dev/null +++ b/internal/solvers/lifi/strategies/default/quote.go @@ -0,0 +1,101 @@ +package defaultstrategy + +import ( + "context" + "sort" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" + liquidgreedy "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies/greedy" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" +) + +type strategyPairKey struct { + tokenIn common.Address + tokenOut common.Address + inputDecimals int + outputDecimals int +} + +func (s *Strategy) DecideQuotes(_ context.Context, input types.QuoteInput) (types.QuoteOutput, error) { + if !input.QuoteExpiresAt.After(input.ServerTime) { + return types.QuoteOutput{}, errors.New("quoteExpiresAt must be after serverTime") + } + validAfter := input.ChainTime + if input.ServerTime.After(validAfter) { + validAfter = input.ServerTime + } + inventory := liquidgreedy.AllocateInventoryCapacity( + liquidgreedy.FilterLiveInventory(input.Inventory, validAfter.Add(s.executionBuffer)), + input.Reservations, + s.cfg.InventoryReserveBps, + ) + groups := make(map[strategyPairKey][]liquidlane.QuoteCandidate) + for _, item := range inventory { + candidate := liquidgreedy.NewQuoteCandidate( + item, + liquidgreedy.QuoteCapacity(item, s.cfg.PriceBufferBps), + ) + if candidate == nil { + continue + } + key := strategyPairKey{ + tokenIn: item.TokenIn, tokenOut: item.TokenOut, + inputDecimals: item.TokenInDecimals, outputDecimals: item.TokenOutDecimals, + } + groups[key] = append(groups[key], *candidate) + } + + keys := make([]strategyPairKey, 0, len(groups)) + for key := range groups { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { return pairLess(keys[i], keys[j]) }) + out := types.QuoteOutput{Quotes: make([]types.Quote, 0, len(keys))} + for _, key := range keys { + pricing, err := liquidstrategies.NewGasPricing( + input.MaxFeePerGas, key.tokenOut, input.GasPrices, input.GasSnapshot, s.cfg.InventoryReserveBps, + types.LiquidLaneGasEnvelope(), + ) + if err != nil { + return types.QuoteOutput{}, err + } + routeLimit := types.MaxRoutes + if input.SingleRouteTokens[key.tokenIn] { + routeLimit = 1 + } + ranges, used, err := s.buildQuoteRanges(groups[key], routeLimit, pricing) + if err != nil { + return types.QuoteOutput{}, err + } + if len(ranges) == 0 { + continue + } + expiry := quoteExpiry(input.QuoteExpiresAt, s.executionBuffer, used) + if expiry <= input.ServerTime.Unix() { + continue + } + out.Quotes = append(out.Quotes, types.Quote{ + FromAsset: key.tokenIn, ToAsset: key.tokenOut, + FromDecimals: key.inputDecimals, ToDecimals: key.outputDecimals, + Ranges: ranges, Expiry: expiry, ExclusiveFor: input.Solver, + }) + } + return out, nil +} + +func pairLess(left, right strategyPairKey) bool { + if cmp := left.tokenIn.Cmp(right.tokenIn); cmp != 0 { + return cmp < 0 + } + if cmp := left.tokenOut.Cmp(right.tokenOut); cmp != 0 { + return cmp < 0 + } + if left.inputDecimals != right.inputDecimals { + return left.inputDecimals < right.inputDecimals + } + return left.outputDecimals < right.outputDecimals +} diff --git a/internal/solvers/lifi/strategies/default/quote_ranges.go b/internal/solvers/lifi/strategies/default/quote_ranges.go new file mode 100644 index 00000000..c4634cb1 --- /dev/null +++ b/internal/solvers/lifi/strategies/default/quote_ranges.go @@ -0,0 +1,268 @@ +package defaultstrategy + +import ( + "math/big" + "sort" + "time" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" + liquidgreedy "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies/greedy" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" +) + +func (s *Strategy) buildQuoteRanges( + candidates []liquidlane.QuoteCandidate, + maxRoutes int, + pricing liquidstrategies.GasPricing, +) ([]types.QuoteRange, map[liquidlane.CandidateID]liquidlane.QuoteCandidate, error) { + candidates = liquidgreedy.BestRouteCandidates(candidates, maxRoutes) + if len(candidates) == 0 { + return nil, nil, nil + } + maximum, routeCount, privateRouteCount := quoteBounds(candidates) + if maximum.Sign() <= 0 { + return nil, nil, nil + } + maxGasCost := pricing.MaxCost(routeCount, privateRouteCount) + breakpoints := quoteBreakpoints(maximum, s.minAmount, s.rangeCount) + used := make(map[liquidlane.CandidateID]liquidlane.QuoteCandidate) + ranges := make([]types.QuoteRange, 0, len(breakpoints)) + lower := new(big.Int).Set(s.minAmount) + for _, upper := range breakpoints { + if upper.Cmp(lower) < 0 { + continue + } + quoteRange, err := s.priceQuoteRange( + candidates, maxRoutes, lower, upper, maxGasCost, routeCount, pricing, + ) + if err != nil { + return nil, nil, err + } + if quoteRange != nil { + ranges = append(ranges, *quoteRange) + } + lower = new(big.Int).Add(upper, big.NewInt(1)) + } + if len(ranges) > 0 { + for _, candidate := range candidates { + used[candidate.ID] = candidate + } + } + return ranges, used, nil +} + +func (s *Strategy) priceQuoteRange( + candidates []liquidlane.QuoteCandidate, + maxRoutes int, + lower *big.Int, + upper *big.Int, + maxGasCost *big.Int, + routeCount int, + pricing liquidstrategies.GasPricing, +) (*types.QuoteRange, error) { + quoteAt := func(amount *big.Int) (*liquidgreedy.QuoteSolution, error) { + return liquidgreedy.SolveQuote(liquidgreedy.QuoteTask{ + ExactInput: amount, + Candidates: candidates, + MaxRoutes: maxRoutes, + MinInput: s.minAmount, + OutputBufferBps: 2 * s.cfg.PriceBufferBps, + InputPolicy: liquidgreedy.RejectUncoveredInput, + GasPricing: &pricing, + }) + } + lowerQuote, err := quoteAt(lower) + if err != nil || lowerQuote == nil { + return nil, err + } + upperQuote, err := quoteAt(upper) + if err != nil || upperQuote == nil { + return nil, err + } + + inDecimals := candidates[0].Route.TokenInDecimals + outDecimals := candidates[0].Route.TokenOutDecimals + rate := liquidlane.RateForAmountOut(lowerQuote.AmountOut, lower, inDecimals, outDecimals) + upperRate := liquidlane.RateForAmountOut(upperQuote.AmountOut, upper, inDecimals, outDecimals) + if upperRate.Cmp(rate) < 0 { + rate = upperRate + } + floorRate := candidateFloorRate( + candidates, + lower, + upper, + maxGasCost, + routeCount, + 2*s.cfg.PriceBufferBps, + inDecimals, + outDecimals, + ) + if floorRate.Cmp(rate) < 0 { + rate = floorRate + } + if rate.Sign() <= 0 { + return nil, nil + } + return &types.QuoteRange{ + MinAmount: new(big.Int).Set(lower), + MaxAmount: new(big.Int).Set(upper), + Quote: fixedPointDecimal(rate, rateScaleDigits), + }, nil +} + +func quoteBounds( + candidates []liquidlane.QuoteCandidate, +) (maximum *big.Int, routeCount int, privateRouteCount int) { + type route struct { + maxInput *big.Int + private bool + } + byRoute := make(map[liquidlane.RouteID]route) + for _, candidate := range candidates { + item := byRoute[candidate.Route.ID] + if item.maxInput == nil || candidate.MaxAmountIn.Cmp(item.maxInput) > 0 { + item.maxInput = candidate.MaxAmountIn + } + item.private = item.private || candidate.DiscountID != nil + byRoute[candidate.Route.ID] = item + } + total := new(big.Int) + private := 0 + for _, item := range byRoute { + total.Add(total, item.maxInput) + if item.private { + private++ + } + } + return total, len(byRoute), private +} + +func candidateFloorRate( + candidates []liquidlane.QuoteCandidate, + minimumInput *big.Int, + maximumInput *big.Int, + gasCost *big.Int, + routeCount int, + outputBufferBps int, + inDecimals int, + outDecimals int, +) *big.Int { + if len(candidates) == 0 || routeCount <= 0 || + minimumInput == nil || minimumInput.Sign() <= 0 || + maximumInput == nil || maximumInput.Cmp(minimumInput) < 0 { + return new(big.Int) + } + byRoute := make(map[liquidlane.RouteID][]liquidlane.QuoteCandidate) + for _, candidate := range candidates { + byRoute[candidate.Route.ID] = append(byRoute[candidate.Route.ID], candidate) + } + var rate *big.Int + for _, alternatives := range byRoute { + maxInput := new(big.Int) + for _, candidate := range alternatives { + if candidate.MaxAmountIn.Cmp(maxInput) > 0 { + maxInput.Set(candidate.MaxAmountIn) + } + } + legLimit := minBig(maximumInput, maxInput) + var best *big.Int + for _, candidate := range alternatives { + if candidate.MaxAmountIn.Cmp(legLimit) >= 0 && (best == nil || candidate.Rate.Cmp(best) > 0) { + best = candidate.Rate + } + } + if best != nil && (rate == nil || best.Cmp(rate) < 0) { + rate = liquidlane.CloneBig(best) + } + } + if rate == nil || rate.Sign() <= 0 { + return new(big.Int) + } + rate.Mul(rate, big.NewInt(int64(bpsDenominator-outputBufferBps))) + rate.Div(rate, big.NewInt(bpsDenominator)) + + // Every complete greedy plan uses at most routeCount candidates whose + // effective rates are no lower than rate. Summing their floors loses at + // most routeCount-1 output units; a non-zero output buffer can lose one more. + loss := new(big.Int) + if gasCost != nil { + loss.Set(gasCost) + } + loss.Add(loss, big.NewInt(int64(routeCount-1))) + if outputBufferBps > 0 { + loss.Add(loss, big.NewInt(1)) + } + if loss.Sign() == 0 { + return rate + } + lossRate := liquidlane.RateForAmountOut(loss, minimumInput, inDecimals, outDecimals) + lossRate.Add(lossRate, big.NewInt(1)) + rate.Sub(rate, lossRate) + if rate.Sign() <= 0 { + return new(big.Int) + } + return rate +} + +func quoteBreakpoints(maximum, minimum *big.Int, targetCount int) []*big.Int { + if maximum == nil || maximum.Cmp(minimum) < 0 || targetCount <= 0 { + return nil + } + selected := map[string]*big.Int{maximum.String(): new(big.Int).Set(maximum)} + for len(selected) < targetCount { + points := sortedAmounts(selected) + var bestMid, bestLow, bestHigh *big.Int + low := new(big.Int).Set(minimum) + for _, high := range points { + mid := geometricMidpoint(low, high) + if mid != nil && (bestMid == nil || + new(big.Int).Mul(high, bestLow).Cmp(new(big.Int).Mul(bestHigh, low)) > 0) { + bestMid, bestLow, bestHigh = mid, new(big.Int).Set(low), new(big.Int).Set(high) + } + low = high + } + if bestMid == nil { + break + } + selected[bestMid.String()] = bestMid + } + return sortedAmounts(selected) +} + +func sortedAmounts(amounts map[string]*big.Int) []*big.Int { + out := make([]*big.Int, 0, len(amounts)) + for _, amount := range amounts { + out = append(out, amount) + } + sort.Slice(out, func(i, j int) bool { return out[i].Cmp(out[j]) < 0 }) + return out +} + +func geometricMidpoint(lower, upper *big.Int) *big.Int { + if lower.Sign() <= 0 || upper.Cmp(lower) <= 0 { + return nil + } + mid := new(big.Int).Sqrt(new(big.Int).Mul(lower, upper)) + if mid.Cmp(lower) <= 0 { + mid.Add(lower, big.NewInt(1)) + } + if mid.Cmp(upper) >= 0 { + return nil + } + return mid +} + +func quoteExpiry( + deadline time.Time, + buffer time.Duration, + used map[liquidlane.CandidateID]liquidlane.QuoteCandidate, +) int64 { + expiry := deadline.Unix() + for _, candidate := range used { + if !candidate.ValidUntil.IsZero() { + expiry = min(expiry, candidate.ValidUntil.Add(-buffer).Unix()) + } + } + return expiry +} diff --git a/internal/solvers/lifi/strategies/default/strategy.go b/internal/solvers/lifi/strategies/default/strategy.go new file mode 100644 index 00000000..12b60247 --- /dev/null +++ b/internal/solvers/lifi/strategies/default/strategy.go @@ -0,0 +1,100 @@ +package defaultstrategy + +import ( + "math/big" + "time" + + "github.com/go-errors/errors" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/parse" + "github.com/symbioticfi/vault-solver/internal/solver" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" +) + +const Name = "default" + +const ( + bpsDenominator = 10_000 + rateScaleDigits = 18 + defaultRangeCount = 8 + defaultExecutionBuffer = 12 * time.Second +) + +var defaultMinAmount = big.NewInt(1) + +type Config struct { + PriceBufferBps int `yaml:"priceBufferBps"` + MinAmount string `yaml:"minAmount"` + RangeCount int `yaml:"rangeCount"` + InventoryReserveBps int `yaml:"inventoryReserveBps"` + ExecutionDeadlineBuffer string `yaml:"executionDeadlineBuffer"` +} + +type Strategy struct { + cfg Config + + minAmount *big.Int + rangeCount int + executionBuffer time.Duration +} + +//nolint:gochecknoinits // solver-local strategy self-registration mirrors solver registration. +func init() { + strategies.Register(Name, NewFromConfig) +} + +func NewFromConfig(raw yaml.Node) (types.Strategy, error) { + var cfg Config + if err := decodeConfig(raw, &cfg); err != nil { + return nil, err + } + return New(cfg) +} + +func New(cfg Config) (*Strategy, error) { + if cfg.PriceBufferBps < 0 || cfg.PriceBufferBps >= bpsDenominator { + return nil, errors.Errorf("priceBufferBps: must be in [0,%d), got %d", bpsDenominator, cfg.PriceBufferBps) + } + if 2*cfg.PriceBufferBps >= bpsDenominator { + return nil, errors.Errorf("2 * priceBufferBps: must be < %d", bpsDenominator) + } + if cfg.InventoryReserveBps < 0 || cfg.InventoryReserveBps >= bpsDenominator { + return nil, errors.Errorf("inventoryReserveBps: must be in [0,%d), got %d", bpsDenominator, cfg.InventoryReserveBps) + } + rangeCount := cfg.RangeCount + if rangeCount == 0 { + rangeCount = defaultRangeCount + } + if rangeCount < 1 || rangeCount > types.MaxQuoteRanges { + return nil, errors.Errorf("rangeCount: must be in [1,%d], got %d", types.MaxQuoteRanges, cfg.RangeCount) + } + minAmount := new(big.Int).Set(defaultMinAmount) + if cfg.MinAmount != "" { + var err error + minAmount, err = parse.Big(cfg.MinAmount, "minAmount") + if err != nil { + return nil, err + } + if minAmount.Sign() <= 0 { + return nil, errors.New("minAmount: must be positive") + } + } + executionBuffer, err := parse.Duration( + cfg.ExecutionDeadlineBuffer, defaultExecutionBuffer, "executionDeadlineBuffer", + ) + if err != nil { + return nil, err + } + return &Strategy{ + cfg: cfg, minAmount: minAmount, rangeCount: rangeCount, executionBuffer: executionBuffer, + }, nil +} + +func decodeConfig(node yaml.Node, out any) error { + if node.Kind == 0 { + node = yaml.Node{Kind: yaml.MappingNode} + } + return solver.DecodeStrict(node, out) +} diff --git a/internal/solvers/lifi/strategies/default/strategy_test.go b/internal/solvers/lifi/strategies/default/strategy_test.go new file mode 100644 index 00000000..732021df --- /dev/null +++ b/internal/solvers/lifi/strategies/default/strategy_test.go @@ -0,0 +1,1587 @@ +package defaultstrategy + +import ( + "context" + "encoding/binary" + "math/big" + "strconv" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" + liquidgreedy "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies/greedy" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" +) + +func TestDecideQuotesRequiresSolverExpiry(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{})) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, err = strategy.DecideQuotes(context.Background(), types.QuoteInput{ + ChainTime: time.Unix(1_800_000_000, 0), + }); err == nil { + t.Fatal("expected missing quote expiry error") + } +} + +func TestDefaultExecutionBufferIsOneBlock(t *testing.T) { + strategy, err := New(Config{}) + if err != nil { + t.Fatalf("New: %v", err) + } + if strategy.executionBuffer != 12*time.Second { + t.Fatalf("execution buffer = %s", strategy.executionBuffer) + } + if strategy.rangeCount != 8 { + t.Fatalf("range count = %d", strategy.rangeCount) + } +} + +func TestRangeCountValidation(t *testing.T) { + for _, value := range []int{-1, types.MaxQuoteRanges + 1} { + if _, err := New(Config{RangeCount: value}); err == nil { + t.Fatalf("rangeCount %d: expected error", value) + } + } +} + +func TestDecideQuotesAppliesBuffersAndCapacity(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{PriceBufferBps: 100, MinAmount: "1000"})) + if err != nil { + t.Fatalf("New: %v", err) + } + + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Solver: common.HexToAddress("0x1111111111111111111111111111111111111111"), + ChainTime: time.Unix(1_800_000_000, 0), + QuoteExpiresAt: time.Unix(1_800_000_300, 0), + MaxFeePerGas: big.NewInt(0), + Inventory: []liquidlane.Inventory{{ + Route: liquidlane.Route{ + ID: "route-1", + Adapter: common.HexToAddress("0x2222222222222222222222222222222222222222"), + TokenIn: common.HexToAddress("0x3333333333333333333333333333333333333333"), + TokenOut: common.HexToAddress("0x4444444444444444444444444444444444444444"), + TokenInDecimals: 6, + TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(990_000_000), + MaxRate: big.NewInt(1_000_000_000_000_000_000), + }}, + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 { + t.Fatalf("quotes len = %d", len(out.Quotes)) + } + q := out.Quotes[0] + if q.Expiry != 1_800_000_300 { + t.Fatalf("expiry = %d", q.Expiry) + } + if got, ok := new(big.Rat).SetString(q.Ranges[0].Quote); !ok || + got.Sign() <= 0 || got.Cmp(big.NewRat(98, 100)) > 0 { + t.Fatalf("quote = %q, want positive rate no greater than buffered 0.98", q.Ranges[0].Quote) + } + if got := q.Ranges[0].MinAmount.String(); got != "1000" { + t.Fatalf("minAmount = %s", got) + } + if got := q.Ranges[len(q.Ranges)-1].MaxAmount.String(); got != "990000000" { + t.Fatalf("maxAmount = %s", got) + } +} + +func TestDecideQuotesChargesGasAfterBuildingRange(t *testing.T) { + cfg := testStrategyConfig(Config{MinAmount: "1000"}) + strategy, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Solver: common.HexToAddress("0x1111111111111111111111111111111111111111"), + ChainTime: time.Unix(1_800_000_000, 0), + QuoteExpiresAt: time.Unix(1_800_000_090, 0), + MaxFeePerGas: big.NewInt(100), + GasPrices: testGasPrices(common.HexToAddress("0x4444444444444444444444444444444444444444"), 1_000_000_000_000), + Inventory: []liquidlane.Inventory{{ + Route: liquidlane.Route{ + ID: "route-1", + Adapter: common.HexToAddress("0x2222222222222222222222222222222222222222"), + TokenIn: common.HexToAddress("0x3333333333333333333333333333333333333333"), + TokenOut: common.HexToAddress("0x4444444444444444444444444444444444444444"), + TokenInDecimals: 6, + TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(20_000), + MaxRate: big.NewInt(1_000_000_000_000_000_000), + }}, + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 { + t.Fatalf("quotes len = %d", len(out.Quotes)) + } + ranges := out.Quotes[0].Ranges + if len(ranges) <= 1 { + t.Fatalf("expected dynamic ranges, got %d", len(ranges)) + } + if got := ranges[0].MinAmount.String(); got != "1000" { + t.Fatalf("range[0].min = %s", got) + } + if got := ranges[len(ranges)-1].MaxAmount.String(); got != "20000" { + t.Fatalf("last range max = %s", got) + } + for _, quoteRange := range ranges { + rate, ok := new(big.Rat).SetString(quoteRange.Quote) + if !ok || rate.Sign() <= 0 || rate.Cmp(big.NewRat(1, 1)) >= 0 { + t.Fatalf("quote should deduct complete-plan gas: %#v", ranges) + } + } +} + +func TestDecideQuotesAllowsBreakEvenMinimum(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{MinAmount: "1"})) + if err != nil { + t.Fatalf("New: %v", err) + } + now := time.Unix(1_800_000_000, 0) + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: []liquidlane.Inventory{{ + Route: liquidlane.Route{ + ID: "route-1", + TokenIn: common.HexToAddress("0x1111111111111111111111111111111111111111"), TokenOut: common.HexToAddress("0x2222222222222222222222222222222222222222"), + TokenInDecimals: 6, TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(100), MaxRate: big.NewInt(1_000_000_000_000_000_000), + }}, + MaxFeePerGas: big.NewInt(0), ChainTime: now, ServerTime: now, QuoteExpiresAt: now.Add(time.Minute), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 || out.Quotes[0].Ranges[0].Quote != "1" { + t.Fatalf("quotes = %+v, want break-even range", out.Quotes) + } +} + +func TestDecideQuotesRaisesMinimumAboveGasBreakEven(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{MinAmount: "1"})) + if err != nil { + t.Fatalf("New: %v", err) + } + now := time.Unix(1_800_000_000, 0) + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: []liquidlane.Inventory{{ + Route: liquidlane.Route{ + ID: "route-1", + TokenIn: common.HexToAddress("0x1111111111111111111111111111111111111111"), TokenOut: tokenOut, + TokenInDecimals: 6, TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(10_000_000), MaxRate: big.NewInt(1_000_000_000_000_000_000), + }}, + GasPrices: testGasPrices(tokenOut, 1_000_000_000_000_000_000), MaxFeePerGas: big.NewInt(1), + ChainTime: now, ServerTime: now, QuoteExpiresAt: now.Add(time.Minute), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 || len(out.Quotes[0].Ranges) == 0 { + t.Fatalf("quotes = %+v, want gas-aware range", out.Quotes) + } + if out.Quotes[0].Ranges[0].MinAmount.Cmp(big.NewInt(1)) <= 0 { + t.Fatalf("minAmount = %s, want amount above gas break-even", out.Quotes[0].Ranges[0].MinAmount) + } +} + +func TestDecideQuotesBoundsGasTransitionInsideRange(t *testing.T) { + cfg := testStrategyConfig(Config{MinAmount: "900"}) + strategy, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + now := time.Unix(1_800_000_000, 0) + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x4444444444444444444444444444444444444444") + adapter := common.HexToAddress("0x2222222222222222222222222222222222222222") + vault := common.HexToAddress("0x3333333333333333333333333333333333333333") + route := liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", Adapter: adapter, Vault: vault, + TokenIn: tokenIn, TokenOut: tokenOut, TokenInDecimals: 6, TokenOutDecimals: 6, + } + gasSnapshot := &liquidlanegas.Snapshot{ + Adapters: map[common.Address]*liquidlanegas.AdapterState{ + adapter: {Vault: vault, Acquire: map[common.Address]*big.Int{tokenIn: big.NewInt(1_000)}}, + }, + Vaults: map[common.Address]*liquidlanegas.VaultState{ + vault: {FreeAssets: big.NewInt(10_000), Withdrawable: big.NewInt(10_000)}, + }, + } + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: []liquidlane.Inventory{{ + Route: route, MaxAssets: big.NewInt(2_000), MaxRate: big.NewInt(1_000_000_000_000_000_000), + }}, + GasSnapshot: gasSnapshot, GasPrices: testGasPrices(tokenOut, 1_000_000), MaxFeePerGas: big.NewInt(1_000_000_000), + ChainTime: now, ServerTime: now, QuoteExpiresAt: now.Add(time.Minute), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 || len(out.Quotes[0].Ranges) == 0 { + t.Fatalf("quotes = %+v", out.Quotes) + } + pricing, err := liquidstrategies.NewGasPricing( + big.NewInt(1_000_000_000), tokenOut, testGasPrices(tokenOut, 1_000_000), gasSnapshot, 0, + types.LiquidLaneGasEnvelope(), + ) + if err != nil { + t.Fatalf("pricing: %v", err) + } + for _, quoteRange := range out.Quotes[0].Ranges { + rate, ok := new(big.Rat).SetString(quoteRange.Quote) + if !ok { + t.Fatalf("invalid quote rate %q", quoteRange.Quote) + } + for amount := quoteRange.MinAmount.Int64(); amount <= quoteRange.MaxAmount.Int64(); amount++ { + actual := new(big.Int).Sub(big.NewInt(amount), pricing.Cost([]liquidstrategies.GasLeg{{ + Route: route, AmountOut: big.NewInt(amount), + }})) + quoted := new(big.Int).Mul(big.NewInt(amount), rate.Num()) + quoted.Div(quoted, rate.Denom()) + if quoted.Cmp(actual) > 0 { + t.Fatalf("amount %d quoted %s above executable %s in range %+v", amount, quoted, actual, quoteRange) + } + } + } +} + +func TestDecideQuotesUsesConfiguredRangeCount(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{MinAmount: "100", RangeCount: 4})) + if err != nil { + t.Fatalf("New: %v", err) + } + now := time.Unix(1_800_000_000, 0) + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: []liquidlane.Inventory{{ + Route: liquidlane.Route{ + ID: "route-1", TokenIn: tokenIn, TokenOut: tokenOut, + TokenInDecimals: 6, TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(1_000), MaxRate: big.NewInt(1_000_000_000_000_000_000), + }}, + MaxFeePerGas: big.NewInt(0), ChainTime: now, ServerTime: now, QuoteExpiresAt: now.Add(time.Minute), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 { + t.Fatalf("quotes = %+v", out.Quotes) + } + ranges := out.Quotes[0].Ranges + if len(ranges) != 4 { + t.Fatalf("ranges = %+v, want four configured ranges", ranges) + } + for i := range ranges { + if i > 0 { + wantMin := new(big.Int).Add(ranges[i-1].MaxAmount, big.NewInt(1)) + if ranges[i].MinAmount.Cmp(wantMin) != 0 { + t.Fatalf("range[%d].min = %s, want %s", i, ranges[i].MinAmount, wantMin) + } + } + } + if got := ranges[len(ranges)-1].MaxAmount.String(); got != "1000" { + t.Fatalf("last maxAmount = %s, want 1000", got) + } +} + +func TestDecideQuotesUsesAtMostThreePhysicalRoutes(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{MinAmount: "2"})) + if err != nil { + t.Fatalf("New: %v", err) + } + now := time.Unix(1_800_000_000, 0) + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + inventory := make([]liquidlane.Inventory, 4) + for i := range inventory { + inventory[i] = liquidlane.Inventory{ + Route: liquidlane.Route{ + ID: liquidlane.RouteID("route-" + strconv.Itoa(i+1)), + CapacityID: liquidlane.CapacityID("capacity-" + strconv.Itoa(i+1)), + Adapter: common.BytesToAddress([]byte{byte(i + 1)}), + TokenIn: tokenIn, TokenOut: tokenOut, TokenInDecimals: 6, TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(100), MaxRate: big.NewInt(1_000_000_000_000_000_000), + } + } + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: inventory, MaxFeePerGas: big.NewInt(0), + ChainTime: now, ServerTime: now, QuoteExpiresAt: now.Add(time.Minute), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 { + t.Fatalf("quotes = %+v", out.Quotes) + } + ranges := out.Quotes[0].Ranges + if got := ranges[len(ranges)-1].MaxAmount.String(); got != "300" { + t.Fatalf("maxAmount = %s, want three-route capacity 300", got) + } +} + +func TestDecideQuotesAggregatesIndependentRoutesIntoOnePairCurve(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{MinAmount: "2"})) + if err != nil { + t.Fatalf("New: %v", err) + } + now := time.Unix(1_800_000_000, 0) + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + inventory := []liquidlane.Inventory{ + { + Route: liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", TokenIn: tokenIn, TokenOut: tokenOut, + TokenInDecimals: 6, TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(500), MaxRate: big.NewInt(1_000_000_000_000_000_000), + }, + { + Route: liquidlane.Route{ + ID: "route-2", CapacityID: "capacity-2", TokenIn: tokenIn, TokenOut: tokenOut, + TokenInDecimals: 6, TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(500), MaxRate: big.NewInt(1_000_000_000_000_000_000), + }, + } + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: inventory, + MaxFeePerGas: big.NewInt(0), + ChainTime: now, ServerTime: now, QuoteExpiresAt: now.Add(90 * time.Second), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 { + t.Fatalf("quotes = %+v", out.Quotes) + } + ranges := out.Quotes[0].Ranges + if got := ranges[len(ranges)-1].MaxAmount.String(); got != "1000" { + t.Fatalf("aggregate maxAmount = %s, want 1000", got) + } +} + +func TestDecideQuotesPermissionedTokenUsesOneRoute(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + strategy, err := New(testStrategyConfig(Config{MinAmount: "2"})) + if err != nil { + t.Fatalf("New: %v", err) + } + now := time.Unix(1_800_000_000, 0) + inventory := []liquidlane.Inventory{ + { + Route: liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", TokenIn: tokenIn, TokenOut: tokenOut, + TokenInDecimals: 6, TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(500), MaxRate: big.NewInt(1_000_000_000_000_000_000), + }, + { + Route: liquidlane.Route{ + ID: "route-2", CapacityID: "capacity-2", TokenIn: tokenIn, TokenOut: tokenOut, + TokenInDecimals: 6, TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(500), MaxRate: big.NewInt(1_000_000_000_000_000_000), + }, + } + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: inventory, SingleRouteTokens: map[common.Address]bool{tokenIn: true}, + MaxFeePerGas: big.NewInt(0), + ChainTime: now, ServerTime: now, QuoteExpiresAt: now.Add(90 * time.Second), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 { + t.Fatalf("quotes = %+v", out.Quotes) + } + ranges := out.Quotes[0].Ranges + if got := ranges[len(ranges)-1].MaxAmount.String(); got != "500" { + t.Fatalf("permissioned maxAmount = %s, want 500", got) + } +} + +func TestDecideQuotesNeverOverquotesBlendedRouteRange(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{MinAmount: "2"})) + if err != nil { + t.Fatalf("New: %v", err) + } + now := time.Unix(1_800_000_000, 0) + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + inventory := []liquidlane.Inventory{ + { + Route: liquidlane.Route{ + ID: "route-fast", CapacityID: "capacity-fast", TokenIn: tokenIn, TokenOut: tokenOut, + TokenInDecimals: 6, TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(200), MaxRate: big.NewInt(2_000_000_000_000_000_000), + }, + { + Route: liquidlane.Route{ + ID: "route-slow", CapacityID: "capacity-slow", TokenIn: tokenIn, TokenOut: tokenOut, + TokenInDecimals: 6, TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(100), MaxRate: big.NewInt(1_000_000_000_000_000_000), + }, + } + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: inventory, MaxFeePerGas: big.NewInt(0), + ChainTime: now, ServerTime: now, QuoteExpiresAt: now.Add(time.Minute), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 { + t.Fatalf("quotes = %+v", out.Quotes) + } + for _, quoteRange := range out.Quotes[0].Ranges { + rate, ok := new(big.Rat).SetString(quoteRange.Quote) + if !ok { + t.Fatalf("invalid quote rate %q", quoteRange.Quote) + } + for amount := quoteRange.MinAmount.Int64(); amount <= quoteRange.MaxAmount.Int64(); amount++ { + fastInput := min(amount, int64(100)) + actualOut := 2*fastInput + max(amount-fastInput, int64(0)) + quoted := new(big.Int).Mul(big.NewInt(amount), rate.Num()) + quoted.Div(quoted, rate.Denom()) + if quoted.Cmp(big.NewInt(actualOut)) > 0 { + t.Fatalf("amount %d quoted %s above executable %d in range %+v", amount, quoted, actualOut, quoteRange) + } + } + } +} + +func TestPriceQuoteRangeNeverOverquotesInteriorRouteTransition(t *testing.T) { + strategy, err := New(Config{MinAmount: "1", RangeCount: 1}) + if err != nil { + t.Fatalf("New: %v", err) + } + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + rateUnit := new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil) + candidate := func( + id liquidlane.CandidateID, + routeID liquidlane.RouteID, + rate int64, + maxInput int64, + discountID *common.Hash, + ) liquidlane.QuoteCandidate { + scaledRate := new(big.Int).Mul(rateUnit, big.NewInt(rate)) + return liquidlane.QuoteCandidate{ + ID: id, + Route: liquidlane.Route{ + ID: routeID, CapacityID: liquidlane.CapacityID(routeID), + TokenIn: tokenIn, TokenOut: tokenOut, + }, + Rate: scaledRate, MaxAmountIn: big.NewInt(maxInput), + MaxAmountOut: big.NewInt(rate * maxInput), DiscountID: discountID, + } + } + discountA := common.HexToHash("0x01") + discountB := common.HexToHash("0x02") + candidates := []liquidlane.QuoteCandidate{ + candidate("a-private", "a", 2, 1, &discountA), + candidate("a-direct", "a", 1, 5, nil), + candidate("b-private", "b", 2, 4, &discountB), + candidate("b-direct", "b", 1, 5, nil), + candidate("c-direct", "c", 2, 2, nil), + } + pricing, err := liquidstrategies.NewGasPricing( + big.NewInt(0), tokenOut, nil, nil, 0, liquidstrategies.GasEnvelope{}, + ) + if err != nil { + t.Fatalf("NewGasPricing: %v", err) + } + quoteRange, err := strategy.priceQuoteRange( + candidates, 3, big.NewInt(6), big.NewInt(9), pricing.MaxCost(3, 2), 3, pricing, + ) + if err != nil { + t.Fatalf("priceQuoteRange: %v", err) + } + if quoteRange == nil { + t.Fatal("priceQuoteRange returned no range") + } + rate, ok := new(big.Rat).SetString(quoteRange.Quote) + if !ok { + t.Fatalf("invalid quote rate %q", quoteRange.Quote) + } + for amount := quoteRange.MinAmount.Int64(); amount <= quoteRange.MaxAmount.Int64(); amount++ { + actual, solveErr := liquidgreedy.SolveQuote(liquidgreedy.QuoteTask{ + ExactInput: big.NewInt(amount), Candidates: candidates, MaxRoutes: 3, + MinInput: strategy.minAmount, InputPolicy: liquidgreedy.RejectUncoveredInput, + }) + if solveErr != nil || actual == nil { + t.Fatalf("SolveQuote(%d) = %+v, %v", amount, actual, solveErr) + } + quoted := new(big.Int).Mul(big.NewInt(amount), rate.Num()) + quoted.Div(quoted, rate.Denom()) + if quoted.Cmp(actual.AmountOut) > 0 { + t.Fatalf( + "amount %d quoted %s above executable %s in range %+v", + amount, quoted, actual.AmountOut, quoteRange, + ) + } + } +} + +func TestBuildQuoteRangesScalesToManyRoutes(t *testing.T) { + strategy, err := New(Config{MinAmount: "100", RangeCount: 1}) + if err != nil { + t.Fatalf("New: %v", err) + } + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + candidates := make([]liquidlane.QuoteCandidate, 128) + for index := range candidates { + routeID := liquidlane.RouteID("route-" + strconv.Itoa(index)) + candidates[index] = liquidlane.QuoteCandidate{ + ID: liquidlane.CandidateID(routeID), + Route: liquidlane.Route{ + ID: routeID, CapacityID: liquidlane.CapacityID(routeID), + TokenIn: tokenIn, TokenOut: tokenOut, + }, + Rate: big.NewInt(2_000_000_000_000_000_000), + MaxAmountIn: big.NewInt(100), + MaxAmountOut: big.NewInt(200), + } + } + pricing, err := liquidstrategies.NewGasPricing( + big.NewInt(0), tokenOut, nil, nil, 0, liquidstrategies.GasEnvelope{}, + ) + if err != nil { + t.Fatalf("NewGasPricing: %v", err) + } + ranges, _, err := strategy.buildQuoteRanges(candidates, 64, pricing) + if err != nil { + t.Fatalf("buildQuoteRanges: %v", err) + } + if len(ranges) != 1 || ranges[0].MaxAmount.Cmp(big.NewInt(6_400)) != 0 { + t.Fatalf("ranges = %+v, want one range through 6400", ranges) + } +} + +func TestDecideQuotesUsesPrivateAlternativeBeforeDirectFallback(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{MinAmount: "100"})) + if err != nil { + t.Fatalf("New: %v", err) + } + now := time.Unix(1_800_000_000, 0) + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + route := liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", TokenIn: tokenIn, TokenOut: tokenOut, + TokenInDecimals: 6, TokenOutDecimals: 6, + } + discountID := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + inventory := []liquidlane.Inventory{ + liquidlane.DirectInventory(route, big.NewInt(1_000), big.NewInt(900_000_000_000_000_000)), + liquidlane.DiscountInventory( + route, big.NewInt(1_000), big.NewInt(1_000_000_000_000_000_000), + discountID, now.Add(time.Minute), + ), + } + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: inventory, MaxFeePerGas: big.NewInt(0), + ChainTime: now, ServerTime: now, QuoteExpiresAt: now.Add(90 * time.Second), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 || out.Quotes[0].Expiry != now.Add(48*time.Second).Unix() { + t.Fatalf("quotes = %+v", out.Quotes) + } + usedPrivateRate := false + for _, quoteRange := range out.Quotes[0].Ranges { + rate, ok := new(big.Rat).SetString(quoteRange.Quote) + if ok && rate.Cmp(big.NewRat(9, 10)) > 0 { + usedPrivateRate = true + break + } + } + if !usedPrivateRate { + t.Fatalf("private alternative did not improve any range: %+v", out.Quotes[0].Ranges) + } +} + +func TestDecideQuotesFiltersPrivateAlternativeExpiredByServerClock(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{MinAmount: "100"})) + if err != nil { + t.Fatalf("New: %v", err) + } + chainTime := time.Unix(1_800_000_000, 0) + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + route := liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", TokenIn: tokenIn, TokenOut: tokenOut, + TokenInDecimals: 6, TokenOutDecimals: 6, + } + discountID := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: []liquidlane.Inventory{ + liquidlane.DirectInventory(route, big.NewInt(1_000), big.NewInt(1_000_000_000_000_000_000)), + liquidlane.DiscountInventory( + route, big.NewInt(1_000), big.NewInt(500_000_000_000_000_000), + discountID, chainTime.Add(20*time.Second), + ), + }, + MaxFeePerGas: big.NewInt(0), ChainTime: chainTime, ServerTime: chainTime.Add(9 * time.Second), + QuoteExpiresAt: chainTime.Add(time.Minute), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 { + t.Fatalf("quotes = %+v, want direct quote", out.Quotes) + } + if out.Quotes[0].Expiry != chainTime.Add(time.Minute).Unix() { + t.Fatalf("expiry = %d, want %d", out.Quotes[0].Expiry, chainTime.Add(time.Minute).Unix()) + } +} + +func TestPriceBufferCoversQuoteToFillAndExecutionWindows(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{PriceBufferBps: 100, MinAmount: "10000"})) + if err != nil { + t.Fatalf("New: %v", err) + } + now := time.Unix(1_800_000_000, 0) + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + route := liquidlane.Route{ + ID: "route-1", Adapter: common.HexToAddress("0x3333333333333333333333333333333333333333"), + TokenIn: tokenIn, TokenOut: tokenOut, TokenInDecimals: 6, TokenOutDecimals: 6, + } + quotes, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: []liquidlane.Inventory{{ + Route: route, MaxAssets: big.NewInt(20_000), MaxRate: big.NewInt(1_000_000_000_000_000_000), + }}, + MaxFeePerGas: big.NewInt(0), ChainTime: now, ServerTime: now, QuoteExpiresAt: now.Add(time.Minute), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(quotes.Quotes) != 1 { + t.Fatalf("quote = %+v", quotes.Quotes) + } + quoteRate, ok := new(big.Rat).SetString(quotes.Quotes[0].Ranges[0].Quote) + if !ok || quoteRate.Sign() <= 0 || quoteRate.Cmp(big.NewRat(98, 100)) > 0 { + t.Fatalf("quote rate = %q, want positive rate no greater than 0.98", quotes.Quotes[0].Ranges[0].Quote) + } + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, + AmountIn: big.NewInt(10_000), OutputAmount: big.NewInt(9_800), ChainTime: now, + MaxFeePerGas: big.NewInt(0), + Quotes: []liquidlane.FillQuote{{ + Inventory: liquidlane.Inventory{Route: route, MaxAssets: big.NewInt(20_000)}, + AmountIn: big.NewInt(10_000), MaxAmountOut: big.NewInt(9_900), + }}, + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan == nil { + t.Fatal("expected fill after one price-buffer adverse move") + } +} + +func TestDecideFillBuildsMultiRoutePlan(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{})) + if err != nil { + t.Fatalf("New: %v", err) + } + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + quotes := make([]liquidlane.FillQuote, 0, 2) + for i, routeID := range []liquidlane.RouteID{"route-1", "route-2"} { + quotes = append(quotes, liquidlane.FillQuote{ + Inventory: liquidlane.Inventory{ + Route: liquidlane.Route{ + ID: routeID, CapacityID: liquidlane.CapacityID("capacity-" + strconv.Itoa(i+1)), + Adapter: common.BytesToAddress([]byte{byte(i + 1)}), TokenIn: tokenIn, TokenOut: tokenOut, + }, + MaxAssets: big.NewInt(500), + }, + AmountIn: big.NewInt(1_000), MaxAmountOut: big.NewInt(1_000), + }) + } + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, + AmountIn: big.NewInt(1_000), OutputAmount: big.NewInt(900), ChainTime: time.Unix(1_800_000_000, 0), + MaxFeePerGas: big.NewInt(0), Quotes: quotes, + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan == nil || len(plan.Routes) != 2 { + t.Fatalf("plan = %+v", plan) + } + amountIn := new(big.Int) + minimumOut := new(big.Int) + for _, route := range plan.Routes { + amountIn.Add(amountIn, route.AmountIn) + minimumOut.Add(minimumOut, route.MinAmountOut) + } + if amountIn.String() != "1000" || minimumOut.String() != "900" { + t.Fatalf("amountIn=%s minimumOut=%s", amountIn, minimumOut) + } +} + +func TestDecideFillDoesNotDoubleSpendSharedVaultCapacity(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{})) + if err != nil { + t.Fatalf("New: %v", err) + } + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + quotes := make([]liquidlane.FillQuote, 0, 2) + for i, routeID := range []liquidlane.RouteID{"route-1", "route-2"} { + quotes = append(quotes, liquidlane.FillQuote{ + Inventory: liquidlane.Inventory{ + Route: liquidlane.Route{ + ID: routeID, CapacityID: "shared-capacity", + Adapter: common.BytesToAddress([]byte{byte(i + 1)}), TokenIn: tokenIn, TokenOut: tokenOut, + }, + MaxAssets: big.NewInt(600), + }, + AmountIn: big.NewInt(1_000), MaxAmountOut: big.NewInt(1_000), + }) + } + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, + AmountIn: big.NewInt(1_000), OutputAmount: big.NewInt(900), ChainTime: time.Unix(1_800_000_000, 0), + MaxFeePerGas: big.NewInt(0), Quotes: quotes, + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan != nil { + t.Fatalf("shared capacity was double counted: %+v", plan) + } +} + +func TestDecideQuotesUsesPrivateDiscountWithoutDirectCandidateAndClipsExpiry(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{MinAmount: "1000"})) + if err != nil { + t.Fatalf("New: %v", err) + } + now := time.Unix(1_800_000_000, 0) + discountID := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + route := liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", + TokenIn: common.HexToAddress("0x1111111111111111111111111111111111111111"), + TokenOut: common.HexToAddress("0x2222222222222222222222222222222222222222"), + TokenInDecimals: 6, TokenOutDecimals: 6, + } + discount := liquidlane.DiscountInventory( + route, big.NewInt(900), big.NewInt(800_000_000_000_000_000), discountID, now.Add(time.Minute), + ) + + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: []liquidlane.Inventory{discount}, + MaxFeePerGas: big.NewInt(0), ChainTime: now, QuoteExpiresAt: now.Add(90 * time.Second), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 { + t.Fatalf("quotes = %+v", out.Quotes) + } + quote := out.Quotes[0] + last := quote.Ranges[len(quote.Ranges)-1] + lastRate, ok := new(big.Rat).SetString(last.Quote) + if quote.Expiry != now.Add(48*time.Second).Unix() || last.MaxAmount.String() != "1125" || + !ok || lastRate.Sign() <= 0 || lastRate.Cmp(big.NewRat(8, 10)) > 0 { + t.Fatalf("discount quote = %+v", quote) + } +} + +func TestDecideQuotesPublishesOnePairForDirectAndDiscount(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{})) + if err != nil { + t.Fatalf("New: %v", err) + } + now := time.Unix(1_800_000_000, 0) + route := liquidlane.Route{ + ID: "route-1", TokenIn: common.HexToAddress("0x1111111111111111111111111111111111111111"), + TokenOut: common.HexToAddress("0x2222222222222222222222222222222222222222"), + TokenInDecimals: 6, TokenOutDecimals: 6, + } + direct := liquidlane.DirectInventory( + route, big.NewInt(1_000), big.NewInt(900_000_000_000_000_000), + ) + discountID := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + discount := liquidlane.DiscountInventory( + route, big.NewInt(1_000), big.NewInt(800_000_000_000_000_000), discountID, now.Add(time.Minute), + ) + + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: []liquidlane.Inventory{direct, discount}, MaxFeePerGas: big.NewInt(0), + ChainTime: now, ServerTime: now, QuoteExpiresAt: now.Add(time.Minute), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 { + t.Fatalf("quotes = %+v", out.Quotes) + } +} + +func TestDecideQuotesSkipsBelowMinAmount(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{MinAmount: "1000001"})) + if err != nil { + t.Fatalf("New: %v", err) + } + + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Solver: common.HexToAddress("0x1111111111111111111111111111111111111111"), + ChainTime: time.Unix(1_800_000_000, 0), + QuoteExpiresAt: time.Unix(1_800_000_090, 0), + MaxFeePerGas: big.NewInt(0), + Inventory: []liquidlane.Inventory{{ + Route: liquidlane.Route{ + ID: "route-1", + TokenIn: common.HexToAddress("0x3333333333333333333333333333333333333333"), + TokenOut: common.HexToAddress("0x4444444444444444444444444444444444444444"), + TokenInDecimals: 6, + TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(1_000_000), + MaxRate: big.NewInt(1_000_000_000_000_000_000), + }}, + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 0 { + t.Fatalf("quotes len = %d", len(out.Quotes)) + } +} + +func TestDecideFillSelectsProfitableRoute(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{MinAmount: "1000"})) + if err != nil { + t.Fatalf("New: %v", err) + } + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + adapter := common.HexToAddress("0x3333333333333333333333333333333333333333") + + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, + TokenOut: tokenOut, + AmountIn: big.NewInt(1_000_000), + OutputAmount: big.NewInt(990_000), + ChainTime: time.Unix(1_800_000_000, 0), + MaxFeePerGas: big.NewInt(0), + Quotes: []liquidlane.FillQuote{{ + Inventory: liquidlane.Inventory{ + Route: liquidlane.Route{ID: "route-1", Adapter: adapter, TokenIn: tokenIn, TokenOut: tokenOut}, + MaxAssets: big.NewInt(2_000_000), + }, + AmountIn: big.NewInt(1_000_000), MaxAmountOut: big.NewInt(1_000_000), + }}, + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan == nil { + t.Fatal("expected fill plan") + } + if len(plan.Routes) != 1 || plan.Routes[0].Adapter != adapter { + t.Fatalf("routes = %+v", plan.Routes) + } + if plan.Routes[0].ExpectedAmountOut.String() != "1000000" { + t.Fatalf("plan = %+v", plan) + } +} + +func TestDecideFillPermissionedTokenNeverAggregatesRoutes(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + strategy, err := New(testStrategyConfig(Config{})) + if err != nil { + t.Fatalf("New: %v", err) + } + quotes := []liquidlane.FillQuote{ + { + Inventory: liquidlane.Inventory{ + Route: liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", + TokenIn: tokenIn, TokenOut: tokenOut, + }, + MaxAssets: big.NewInt(500), + }, + AmountIn: big.NewInt(1_000), MaxAmountOut: big.NewInt(1_000), + }, + { + Inventory: liquidlane.Inventory{ + Route: liquidlane.Route{ + ID: "route-2", CapacityID: "capacity-2", + TokenIn: tokenIn, TokenOut: tokenOut, + }, + MaxAssets: big.NewInt(500), + }, + AmountIn: big.NewInt(1_000), MaxAmountOut: big.NewInt(1_000), + }, + } + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(1_000), OutputAmount: big.NewInt(900), + RequireSingleRoute: true, + ChainTime: time.Unix(1_800_000_000, 0), MaxFeePerGas: big.NewInt(0), Quotes: quotes, + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan != nil { + t.Fatalf("permissioned token must not aggregate routes, got %+v", plan) + } +} + +func TestDecideFillSelectsBestRouteInsteadOfConfigOrder(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{})) + if err != nil { + t.Fatalf("New: %v", err) + } + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + firstAdapter := common.HexToAddress("0x3333333333333333333333333333333333333333") + bestAdapter := common.HexToAddress("0x4444444444444444444444444444444444444444") + + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, + AmountIn: big.NewInt(1_000_000), OutputAmount: big.NewInt(990_000), + ChainTime: time.Unix(1_800_000_000, 0), + MaxFeePerGas: big.NewInt(0), + Quotes: []liquidlane.FillQuote{ + { + Inventory: liquidlane.Inventory{ + Route: liquidlane.Route{ID: "route-1", Adapter: firstAdapter, TokenIn: tokenIn, TokenOut: tokenOut}, + MaxAssets: big.NewInt(2_000_000), + }, + AmountIn: big.NewInt(1_000_000), MaxAmountOut: big.NewInt(1_000_000), + }, + { + Inventory: liquidlane.Inventory{ + Route: liquidlane.Route{ID: "route-2", Adapter: bestAdapter, TokenIn: tokenIn, TokenOut: tokenOut}, + MaxAssets: big.NewInt(2_000_000), + }, + AmountIn: big.NewInt(1_000_000), MaxAmountOut: big.NewInt(1_100_000), + }, + }, + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan == nil || len(plan.Routes) != 1 || plan.Routes[0].Adapter != bestAdapter { + t.Fatalf("plan = %+v, want adapter %s", plan, bestAdapter) + } +} + +func TestDecideFillCommitsSelectedPrivateDiscount(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{})) + if err != nil { + t.Fatalf("New: %v", err) + } + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + adapter := common.HexToAddress("0x3333333333333333333333333333333333333333") + discountID := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + route := liquidlane.Route{ID: "route-1", Adapter: adapter, TokenIn: tokenIn, TokenOut: tokenOut} + + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, + AmountIn: big.NewInt(1_000), OutputAmount: big.NewInt(850), ChainTime: time.Unix(1_800_000_000, 0), + MaxFeePerGas: big.NewInt(0), + Quotes: []liquidlane.FillQuote{ + {Inventory: liquidlane.Inventory{Route: route, MaxAssets: big.NewInt(1_000)}, AmountIn: big.NewInt(1_000), MaxAmountOut: big.NewInt(900)}, + { + Inventory: liquidlane.Inventory{ + Route: route, MaxAssets: big.NewInt(1_000), + DiscountID: &discountID, + }, + AmountIn: big.NewInt(1_000), MaxAmountOut: big.NewInt(950), MinDiscount: big.NewInt(100_000), + }, + }, + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan == nil || len(plan.Routes) != 1 || plan.Routes[0].DiscountID == nil || + *plan.Routes[0].DiscountID != discountID { + t.Fatalf("plan = %+v", plan) + } +} + +func TestDecideFillChargesPrivateExecutionGasAfterGreedySelection(t *testing.T) { + cfg := testStrategyConfig(Config{}) + strategy, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + adapter := common.HexToAddress("0x3333333333333333333333333333333333333333") + vault := common.HexToAddress("0x4444444444444444444444444444444444444444") + discountID := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + route := liquidlane.Route{ID: "route-1", Adapter: adapter, Vault: vault, TokenIn: tokenIn, TokenOut: tokenOut} + + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, + AmountIn: big.NewInt(1_000), OutputAmount: big.NewInt(900_000), ChainTime: time.Unix(1_800_000_000, 0), + MaxFeePerGas: big.NewInt(1), + GasPrices: testGasPrices(tokenOut, 1_000_000_000_000_000_000), + GasSnapshot: &liquidlanegas.Snapshot{ + Adapters: map[common.Address]*liquidlanegas.AdapterState{ + adapter: {Vault: vault, Acquire: map[common.Address]*big.Int{tokenIn: big.NewInt(3_000_000)}}, + }, + Vaults: map[common.Address]*liquidlanegas.VaultState{ + vault: {FreeAssets: new(big.Int), Withdrawable: new(big.Int)}, + }, + }, + Quotes: []liquidlane.FillQuote{ + { + Inventory: liquidlane.Inventory{Route: route, MaxAssets: big.NewInt(3_000_000)}, + AmountIn: big.NewInt(1_000), MaxAmountOut: big.NewInt(1_800_000), + }, + { + Inventory: liquidlane.Inventory{ + Route: route, MaxAssets: big.NewInt(3_000_000), + DiscountID: &discountID, + }, + AmountIn: big.NewInt(1_000), MaxAmountOut: big.NewInt(1_800_050), + }, + }, + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan == nil || len(plan.Routes) != 1 || plan.Routes[0].DiscountID == nil { + t.Fatalf("plan = %+v, want higher-rate private route", plan) + } + if plan.Routes[0].MinAmountOut.Cmp(big.NewInt(900_000)) <= 0 { + t.Fatalf("minAmountOut = %s, want order output plus complete-plan gas", plan.Routes[0].MinAmountOut) + } +} + +func TestDecideFillPrivateCapacityIncludesUpwardPriceBuffer(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{PriceBufferBps: 100})) + if err != nil { + t.Fatalf("New: %v", err) + } + now := time.Unix(1_800_000_000, 0) + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + discountID := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + + for _, tt := range []struct { + name string + maxAmountOut int64 + wantFill bool + }{ + {name: "buffer fits", maxAmountOut: 9_900, wantFill: true}, + {name: "buffer exceeds capacity", maxAmountOut: 9_901, wantFill: false}, + } { + t.Run(tt.name, func(t *testing.T) { + plan, fillErr := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, + AmountIn: big.NewInt(10_000), OutputAmount: big.NewInt(9_500), ChainTime: now, + MaxFeePerGas: big.NewInt(0), + Quotes: []liquidlane.FillQuote{{ + Inventory: liquidlane.Inventory{ + Route: liquidlane.Route{ + ID: "route-1", Adapter: common.HexToAddress("0x3333333333333333333333333333333333333333"), + TokenIn: tokenIn, TokenOut: tokenOut, + }, + MaxAssets: big.NewInt(10_000), + DiscountID: &discountID, ValidUntil: now.Add(time.Minute), + }, + AmountIn: big.NewInt(10_000), + MaxAmountOut: big.NewInt(tt.maxAmountOut), + MinDiscount: big.NewInt(100_000), + }}, + }) + if fillErr != nil { + t.Fatalf("DecideFill: %v", fillErr) + } + if (plan != nil) != tt.wantFill { + t.Fatalf("plan = %+v, wantFill = %v", plan, tt.wantFill) + } + if tt.wantFill && plan.Routes[0].ReservedAmountOut.String() != "9999" { + t.Fatalf("private reservation = %s, want 9999", plan.Routes[0].ReservedAmountOut) + } + }) + } +} + +func TestDecideFillSubtractsPendingCapacityReservations(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{})) + if err != nil { + t.Fatalf("New: %v", err) + } + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + route := liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", + Adapter: common.HexToAddress("0x3333333333333333333333333333333333333333"), + TokenIn: tokenIn, + TokenOut: tokenOut, + } + + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, + TokenOut: tokenOut, + AmountIn: big.NewInt(100), + OutputAmount: big.NewInt(90), + MaxFeePerGas: big.NewInt(0), + Reservations: liquidlane.CapacityReservations{"capacity-1": big.NewInt(60)}, + Quotes: []liquidlane.FillQuote{{ + Inventory: liquidlane.Inventory{Route: route, MaxAssets: big.NewInt(100)}, + AmountIn: big.NewInt(100), + MaxAmountOut: big.NewInt(100), + }}, + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan != nil { + t.Fatalf("plan = %+v, want pending reservation to leave insufficient capacity", plan) + } +} + +func TestDecideFillRequiresExecutionDeadlineBuffer(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{ExecutionDeadlineBuffer: "30s"})) + if err != nil { + t.Fatalf("New: %v", err) + } + now := time.Unix(1_800_000_000, 0) + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, + AmountIn: big.NewInt(1_000), OutputAmount: big.NewInt(900), ChainTime: now, + Expires: uint32(now.Add(30 * time.Second).Unix()), FillDeadline: uint32(now.Add(time.Minute).Unix()), + MaxFeePerGas: big.NewInt(0), Quotes: profitableFillQuotes(tokenIn, tokenOut), + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan != nil { + t.Fatalf("expected near-expiry order to be skipped, got %+v", plan) + } +} + +func TestDecideQuotesKeepsInventoryReserve(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{InventoryReserveBps: 1_000, MinAmount: "2"})) + if err != nil { + t.Fatalf("New: %v", err) + } + routeItem := liquidlane.Inventory{ + Route: liquidlane.Route{ + ID: "route-1", TokenIn: common.HexToAddress("0x1111111111111111111111111111111111111111"), + TokenOut: common.HexToAddress("0x2222222222222222222222222222222222222222"), + TokenInDecimals: 6, TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(1_000), MaxRate: big.NewInt(1_000_000_000_000_000_000), + } + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: []liquidlane.Inventory{routeItem}, MaxFeePerGas: big.NewInt(0), + ChainTime: time.Unix(1_800_000_000, 0), QuoteExpiresAt: time.Unix(1_800_000_090, 0), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 { + t.Fatalf("quotes = %d", len(out.Quotes)) + } + ranges := out.Quotes[0].Ranges + if got := ranges[len(ranges)-1].MaxAmount.String(); got != "900" { + t.Fatalf("reserved maxAmount = %s, want 900", got) + } +} + +func TestDecideQuotesAppliesReserveBeforeInFlightReservations(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{InventoryReserveBps: 1_000, MinAmount: "2"})) + if err != nil { + t.Fatalf("New: %v", err) + } + routeItem := liquidlane.Inventory{ + Route: liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", + TokenIn: common.HexToAddress("0x1111111111111111111111111111111111111111"), + TokenOut: common.HexToAddress("0x2222222222222222222222222222222222222222"), + TokenInDecimals: 6, TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(1_000), MaxRate: big.NewInt(1_000_000_000_000_000_000), + } + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: []liquidlane.Inventory{routeItem}, + Reservations: liquidlane.CapacityReservations{"capacity-1": big.NewInt(800)}, + MaxFeePerGas: big.NewInt(0), + ChainTime: time.Unix(1_800_000_000, 0), + QuoteExpiresAt: time.Unix(1_800_000_090, 0), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 1 { + t.Fatalf("quotes = %d", len(out.Quotes)) + } + if got := out.Quotes[0].Ranges[len(out.Quotes[0].Ranges)-1].MaxAmount.String(); got != "100" { + t.Fatalf("maxAmount = %s, want reserve-first capacity 100", got) + } +} + +func TestDecideQuotesSharesOneCapacityDomainAcrossRoutes(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{MinAmount: "2"})) + if err != nil { + t.Fatalf("New: %v", err) + } + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + inventory := []liquidlane.Inventory{ + { + Route: liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", + TokenIn: common.HexToAddress("0x1111111111111111111111111111111111111111"), TokenOut: tokenOut, + TokenInDecimals: 6, TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(1_000), MaxRate: big.NewInt(1_000_000_000_000_000_000), + }, + { + Route: liquidlane.Route{ + ID: "route-2", CapacityID: "capacity-1", + TokenIn: common.HexToAddress("0x3333333333333333333333333333333333333333"), TokenOut: tokenOut, + TokenInDecimals: 6, TokenOutDecimals: 6, + }, + MaxAssets: big.NewInt(1_000), MaxRate: big.NewInt(1_000_000_000_000_000_000), + }, + } + out, err := strategy.DecideQuotes(context.Background(), types.QuoteInput{ + Inventory: inventory, + MaxFeePerGas: big.NewInt(0), + ChainTime: time.Unix(1_800_000_000, 0), + QuoteExpiresAt: time.Unix(1_800_000_090, 0), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(out.Quotes) != 2 { + t.Fatalf("quotes = %d", len(out.Quotes)) + } + total := new(big.Int) + for _, quote := range out.Quotes { + total.Add(total, quote.Ranges[len(quote.Ranges)-1].MaxAmount) + } + if total.String() != "1000" { + t.Fatalf("total quoted capacity = %s, want 1000", total) + } +} + +func TestDecideFillSeparatesBufferedTargetFromEconomicFloor(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{PriceBufferBps: 100})) + if err != nil { + t.Fatalf("New: %v", err) + } + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, + AmountIn: big.NewInt(10_000), OutputAmount: big.NewInt(9_600), + MaxFeePerGas: big.NewInt(100), + GasPrices: testGasPrices(tokenOut, 1), + ChainTime: time.Unix(1_800_000_000, 0), + Quotes: []liquidlane.FillQuote{{ + Inventory: liquidlane.Inventory{ + Route: liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", + Adapter: common.HexToAddress("0x3333333333333333333333333333333333333333"), + TokenIn: tokenIn, TokenOut: tokenOut, + }, + MaxAssets: big.NewInt(20_000), + }, + AmountIn: big.NewInt(10_000), MaxAmountOut: big.NewInt(10_000), + }}, + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan == nil || len(plan.Routes) != 1 { + t.Fatalf("plan = %+v", plan) + } + if plan.Routes[0].ExpectedAmountOut.String() != "9900" || + plan.Routes[0].MinAmountOut.String() != "9601" { + t.Fatalf("plan = %+v", plan) + } +} + +func TestDecideFillRejectsDutchAuctionContext(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{})) + if err != nil { + t.Fatalf("New: %v", err) + } + for _, outputContext := range [][]byte{{dutchAuctionContextType}, {exclusiveDutchAuctionContextType}} { + plan, decideErr := strategy.DecideFill(context.Background(), types.FillInput{ + AmountIn: big.NewInt(1_000_000), + OutputAmount: big.NewInt(990_000), + OutputContext: outputContext, + }) + if decideErr == nil || !strings.Contains(decideErr.Error(), "Dutch auctions are not supported") { + t.Fatalf("DecideFill(context=%x) error = %v", outputContext, decideErr) + } + if plan != nil { + t.Fatalf("DecideFill(context=%x) plan = %+v", outputContext, plan) + } + } +} + +func TestDecideFillRespectsExclusiveWindow(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{})) + if err != nil { + t.Fatalf("New: %v", err) + } + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + solver := common.HexToAddress("0x5555555555555555555555555555555555555555") + otherSolver := common.HexToAddress("0x6666666666666666666666666666666666666666") + + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + Solver: solver, + TokenIn: tokenIn, + TokenOut: tokenOut, + AmountIn: big.NewInt(1_000_000), + OutputAmount: big.NewInt(990_000), + OutputContext: exclusiveLimitContext(otherSolver), + ChainTime: time.Unix(1_800_000_000, 0), + MaxFeePerGas: big.NewInt(0), + Quotes: profitableFillQuotes(tokenIn, tokenOut), + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan != nil { + t.Fatalf("expected skip during another solver's exclusive window, got %+v", plan) + } + + plan, err = strategy.DecideFill(context.Background(), types.FillInput{ + Solver: solver, + TokenIn: tokenIn, + TokenOut: tokenOut, + AmountIn: big.NewInt(1_000_000), + OutputAmount: big.NewInt(990_000), + OutputContext: exclusiveLimitContext(otherSolver), + ChainTime: time.Unix(1_800_000_011, 0), + MaxFeePerGas: big.NewInt(0), + Quotes: profitableFillQuotes(tokenIn, tokenOut), + }) + if err != nil { + t.Fatalf("DecideFill after window: %v", err) + } + if plan == nil { + t.Fatal("expected fill after exclusive window") + } +} + +func TestOutputPricingSupportsOutputSettlerSimpleContexts(t *testing.T) { + solver := common.HexToAddress("0x5555555555555555555555555555555555555555") + base := big.NewInt(990_000) + now := time.Unix(1_800_000_005, 0) + + tests := map[string]struct { + context []byte + want string + fill bool + wantErr bool + }{ + "empty limit": { + context: nil, + want: "990000", + fill: true, + }, + "typed limit": { + context: []byte{limitOrderContextType}, + want: "990000", + fill: true, + }, + "dutch": { + context: []byte{dutchAuctionContextType}, + wantErr: true, + }, + "exclusive limit for solver": { + context: exclusiveLimitContext(solver), + want: "990000", + fill: true, + }, + "exclusive limit for another solver": { + context: exclusiveLimitContext(common.HexToAddress("0x6666666666666666666666666666666666666666")), + fill: false, + }, + "exclusive dutch": { + context: []byte{exclusiveDutchAuctionContextType}, + wantErr: true, + }, + "invalid type": { + context: []byte{0x02}, + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + pricing, err := parseOutputContext(base, tt.context) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("parseOutputContext: %v", err) + } + got, ok := pricing.fill(solver, now, big.NewInt(1_000_000)) + if ok != tt.fill { + t.Fatalf("fill = %v", ok) + } + if !ok { + return + } + if got.String() != tt.want { + t.Fatalf("amount = %s, want %s", got, tt.want) + } + }) + } +} + +func TestDecideFillDoesNotRequireProfitMargin(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{})) + if err != nil { + t.Fatalf("New: %v", err) + } + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, + TokenOut: tokenOut, + AmountIn: big.NewInt(1_000_000), + OutputAmount: big.NewInt(990_000), + MaxFeePerGas: big.NewInt(0), + Quotes: []liquidlane.FillQuote{{ + Inventory: liquidlane.Inventory{ + Route: liquidlane.Route{ID: "route-1", Adapter: common.HexToAddress("0x3333333333333333333333333333333333333333"), TokenIn: tokenIn, TokenOut: tokenOut}, + MaxAssets: big.NewInt(2_000_000), + }, + AmountIn: big.NewInt(1_000_000), MaxAmountOut: big.NewInt(999_000), + }}, + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan == nil { + t.Fatal("expected fill without an explicit profit margin") + } +} + +func TestDecideFillSkipsExpiredOrder(t *testing.T) { + strategy, err := New(testStrategyConfig(Config{})) + if err != nil { + t.Fatalf("New: %v", err) + } + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, + TokenOut: tokenOut, + AmountIn: big.NewInt(1_000_000), + OutputAmount: big.NewInt(990_000), + Expires: 1_700_000_000, + FillDeadline: 1_700_000_100, + ChainTime: time.Unix(1_700_000_000, 0), + MaxFeePerGas: big.NewInt(0), + Quotes: []liquidlane.FillQuote{{ + Inventory: liquidlane.Inventory{ + Route: liquidlane.Route{ID: "route-1", Adapter: common.HexToAddress("0x3333333333333333333333333333333333333333"), TokenIn: tokenIn, TokenOut: tokenOut}, + MaxAssets: big.NewInt(2_000_000), + }, + AmountIn: big.NewInt(1_000_000), MaxAmountOut: big.NewInt(1_000_000), + }}, + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan != nil { + t.Fatalf("expected expired skip, got %#v", plan) + } +} + +func exclusiveLimitContext(exclusiveFor common.Address) []byte { + out := make([]byte, 37) + out[0] = exclusiveLimitOrderContextType + solverID := solverIdentifier(exclusiveFor) + copy(out[1:33], solverID[:]) + binary.BigEndian.PutUint32(out[33:37], 1_800_000_010) + return out +} + +func profitableFillQuotes(tokenIn, tokenOut common.Address) []liquidlane.FillQuote { + return []liquidlane.FillQuote{{ + Inventory: liquidlane.Inventory{ + Route: liquidlane.Route{ + ID: "route-1", Adapter: common.HexToAddress("0x3333333333333333333333333333333333333333"), + TokenIn: tokenIn, TokenOut: tokenOut, + }, + MaxAssets: big.NewInt(2_000_000), + }, + AmountIn: big.NewInt(1_000_000), + MaxAmountOut: big.NewInt(1_000_000), + }} +} + +func testStrategyConfig(cfg Config) Config { + return cfg +} + +func testGasPrices(token common.Address, amount int64) *liquidlanegas.PriceSnapshot { + return liquidlanegas.NewPriceSnapshot(map[common.Address]*big.Int{token: big.NewInt(amount)}) +} + +func TestFixedPointDecimal(t *testing.T) { + tests := map[string]string{ + "0": "0", + "990000000000000000": "0.99", + "1000000000000000000": "1", + "1234500000000000000": "1.2345", + "1000000000000000000000": "1000", + } + for raw, want := range tests { + n, ok := new(big.Int).SetString(raw, 10) + if !ok { + t.Fatalf("bad test int %s", raw) + } + if got := fixedPointDecimal(n, 18); got != want { + t.Fatalf("fixedPointDecimal(%s) = %q, want %q", raw, got, want) + } + } +} diff --git a/internal/solvers/lifi/strategies/registry.go b/internal/solvers/lifi/strategies/registry.go new file mode 100644 index 00000000..6393e6f6 --- /dev/null +++ b/internal/solvers/lifi/strategies/registry.go @@ -0,0 +1,54 @@ +package strategies + +import ( + "sort" + "sync" + + "github.com/go-errors/errors" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" +) + +type Factory func(raw yaml.Node) (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("lifi strategy: Register called with empty name") + } + if f == nil { + panic("lifi strategy: Register called with nil factory for " + name) + } + if _, dup := registry[name]; dup { + panic("lifi strategy: duplicate registration for " + name) + } + registry[name] = f +} + +func New(name string, raw yaml.Node) (types.Strategy, error) { + mu.RLock() + f, ok := registry[name] + mu.RUnlock() + if !ok { + return nil, errors.Errorf("unknown LI.FI strategy %q (registered: %v)", name, Registered()) + } + return f(raw) +} + +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/lifi/strategies/types/types.go b/internal/solvers/lifi/strategies/types/types.go new file mode 100644 index 00000000..6df85d58 --- /dev/null +++ b/internal/solvers/lifi/strategies/types/types.go @@ -0,0 +1,98 @@ +// Package types defines the LI.FI same-chain solver strategy contract. +package types + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" +) + +const ( + // MaxRoutes bounds the physical LiquidLane routes used by one quote or fill. + MaxRoutes = 3 + // MaxQuoteRanges bounds the amount ranges published for one token pair. + MaxQuoteRanges = 16 + settlementGasUnits = 250_000 + privateRouteGasUnits = 75_000 +) + +// LiquidLaneGasEnvelope returns the fixed LI.FI executor overhead around route execution. +func LiquidLaneGasEnvelope() liquidstrategies.GasEnvelope { + return liquidstrategies.GasEnvelope{ + SettlementUnits: settlementGasUnits, PrivateRouteUnits: privateRouteGasUnits, + } +} + +type Strategy interface { + DecideQuotes(ctx context.Context, input QuoteInput) (QuoteOutput, error) + DecideFill(ctx context.Context, input FillInput) (*FillPlan, error) +} + +type QuoteInput struct { + Solver common.Address `json:"solver"` + Inventory []liquidlane.Inventory `json:"inventory"` + Reservations liquidlane.CapacityReservations `json:"reservations"` + SingleRouteTokens map[common.Address]bool `json:"singleRouteTokens"` + GasSnapshot *liquidlanegas.Snapshot `json:"gasSnapshot"` + GasPrices *liquidlanegas.PriceSnapshot `json:"gasPrices"` + MaxFeePerGas *big.Int `json:"maxFeePerGas"` + ChainTime time.Time `json:"chainTime"` + ServerTime time.Time `json:"serverTime"` + QuoteExpiresAt time.Time `json:"quoteExpiresAt"` +} + +type QuoteOutput struct { + Quotes []Quote `json:"quotes"` +} + +type Quote struct { + FromAsset common.Address `json:"fromAsset"` + ToAsset common.Address `json:"toAsset"` + + FromDecimals int `json:"fromDecimals"` + ToDecimals int `json:"toDecimals"` + + Ranges []QuoteRange `json:"ranges"` + Expiry int64 `json:"expiry"` + ExclusiveFor common.Address `json:"exclusiveFor"` +} + +type QuoteRange struct { + MinAmount *big.Int `json:"minAmount"` + MaxAmount *big.Int `json:"maxAmount"` + Quote string `json:"quote"` +} + +type FillInput struct { + OrderID string `json:"orderId"` + QuoteID string `json:"quoteId"` + Solver common.Address `json:"solver"` + + TokenIn common.Address `json:"tokenIn"` + TokenOut common.Address `json:"tokenOut"` + AmountIn *big.Int `json:"amountIn"` + OutputAmount *big.Int `json:"outputAmount"` + OutputContext []byte `json:"outputContext"` + Expires uint32 `json:"expires"` + FillDeadline uint32 `json:"fillDeadline"` + RequireSingleRoute bool `json:"requireSingleRoute"` + + Quotes []liquidlane.FillQuote `json:"quotes"` + Reservations liquidlane.CapacityReservations `json:"reservations"` + GasSnapshot *liquidlanegas.Snapshot `json:"gasSnapshot"` + GasPrices *liquidlanegas.PriceSnapshot `json:"gasPrices"` + MaxFeePerGas *big.Int `json:"maxFeePerGas"` + ChainTime time.Time `json:"chainTime"` +} + +type FillPlan struct { + Routes []FillRoute `json:"routes"` +} + +type FillRoute = liquidstrategies.FillRoute diff --git a/internal/solvers/lifi/strategies/webhook/strategy.go b/internal/solvers/lifi/strategies/webhook/strategy.go new file mode 100644 index 00000000..a365e3d0 --- /dev/null +++ b/internal/solvers/lifi/strategies/webhook/strategy.go @@ -0,0 +1,122 @@ +package webhookstrategy + +import ( + "context" + "math/big" + "net/http" + "sort" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" + "github.com/symbioticfi/vault-solver/internal/webhook" +) + +const ( + Name = "webhook" + decideQuotesRoute = "/decide-quotes" + decideFillRoute = "/decide-fill" +) + +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) (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) DecideQuotes(ctx context.Context, input types.QuoteInput) (types.QuoteOutput, error) { + var out types.QuoteOutput + if err := s.client.DoJSON(ctx, http.MethodPost, decideQuotesRoute, input, &out); err != nil { + return types.QuoteOutput{}, err + } + if err := validateQuotes(input, &out); err != nil { + return types.QuoteOutput{}, err + } + return out, nil +} + +func (s *Strategy) DecideFill(ctx context.Context, input types.FillInput) (*types.FillPlan, error) { + var out *types.FillPlan + if err := s.client.DoJSON(ctx, http.MethodPost, decideFillRoute, input, &out); err != nil { + return nil, err + } + if out == nil { + return nil, nil + } + return out, nil +} + +type quotePair struct { + from, to common.Address + fromDec, toDec int +} + +func validateQuotes(input types.QuoteInput, out *types.QuoteOutput) error { + pairs := make(map[quotePair]bool) + seen := make(map[quotePair]bool) + for _, candidate := range input.Inventory { + pairs[quotePair{ + from: candidate.TokenIn, to: candidate.TokenOut, + fromDec: candidate.TokenInDecimals, toDec: candidate.TokenOutDecimals, + }] = true + } + for i := range out.Quotes { + quote := &out.Quotes[i] + pair := quotePair{quote.FromAsset, quote.ToAsset, quote.FromDecimals, quote.ToDecimals} + if !pairs[pair] { + return errors.Errorf("webhook quote %d uses unknown token pair", i) + } + if seen[pair] { + return errors.Errorf("webhook quote %d repeats token pair", i) + } + seen[pair] = true + if quote.Expiry <= input.ServerTime.Unix() || quote.Expiry > input.QuoteExpiresAt.Unix() { + return errors.Errorf("webhook quote %d expiry is outside the solver window", i) + } + if len(quote.Ranges) == 0 || len(quote.Ranges) > types.MaxQuoteRanges { + return errors.Errorf("webhook quote %d has %d ranges, allowed [1,%d]", i, len(quote.Ranges), types.MaxQuoteRanges) + } + for j, priceRange := range quote.Ranges { + rate, rateOK := new(big.Rat).SetString(priceRange.Quote) + if priceRange.MinAmount == nil || priceRange.MaxAmount == nil || priceRange.MinAmount.Sign() <= 0 || + priceRange.MinAmount.Cmp(priceRange.MaxAmount) > 0 || priceRange.Quote == "" { + return errors.Errorf("webhook quote %d range %d is invalid", i, j) + } + if !rateOK || rate.Sign() <= 0 { + return errors.Errorf("webhook quote %d range %d rate is invalid", i, j) + } + } + sort.Slice(quote.Ranges, func(i, j int) bool { return quote.Ranges[i].MinAmount.Cmp(quote.Ranges[j].MinAmount) < 0 }) + for j, priceRange := range quote.Ranges { + if j > 0 && quote.Ranges[j-1].MaxAmount.Cmp(priceRange.MinAmount) >= 0 { + return errors.Errorf("webhook quote %d ranges overlap", i) + } + } + quote.ExclusiveFor = input.Solver + } + return nil +} + +var _ types.Strategy = (*Strategy)(nil) diff --git a/internal/solvers/lifi/strategies/webhook/strategy_test.go b/internal/solvers/lifi/strategies/webhook/strategy_test.go new file mode 100644 index 00000000..e0e978e2 --- /dev/null +++ b/internal/solvers/lifi/strategies/webhook/strategy_test.go @@ -0,0 +1,75 @@ +package webhookstrategy + +import ( + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" + "github.com/symbioticfi/vault-solver/internal/webhook" +) + +func TestWebhookStrategyDelegatesQuotesAndFill(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + solver := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenIn := common.HexToAddress("0x2222222222222222222222222222222222222222") + tokenOut := common.HexToAddress("0x3333333333333333333333333333333333333333") + adapter := common.HexToAddress("0x4444444444444444444444444444444444444444") + route := liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", Adapter: adapter, + TokenIn: tokenIn, TokenOut: tokenOut, TokenInDecimals: 6, TokenOutDecimals: 6, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case decideQuotesRoute: + _ = json.NewEncoder(w).Encode(types.QuoteOutput{Quotes: []types.Quote{{ + FromAsset: tokenIn, ToAsset: tokenOut, FromDecimals: 6, ToDecimals: 6, + Ranges: []types.QuoteRange{{MinAmount: big.NewInt(1), MaxAmount: big.NewInt(100), Quote: "1"}}, + Expiry: now.Add(30 * time.Second).Unix(), + }}}) + case decideFillRoute: + _ = json.NewEncoder(w).Encode(types.FillPlan{Routes: []types.FillRoute{{ + RouteID: "route-1", AmountIn: big.NewInt(100), ExpectedAmountOut: big.NewInt(100), + MinAmountOut: big.NewInt(90), ReservedAmountOut: big.NewInt(100), + }}}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + client, err := webhook.NewClient(webhook.Config{URL: server.URL, Timeout: time.Second}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + strategy := New(client) + inventory := liquidlane.Inventory{Route: route, MaxAssets: big.NewInt(100), MaxRate: big.NewInt(1)} + quotes, err := strategy.DecideQuotes(t.Context(), types.QuoteInput{ + Solver: solver, Inventory: []liquidlane.Inventory{inventory}, ServerTime: now, QuoteExpiresAt: now.Add(time.Minute), + }) + if err != nil { + t.Fatalf("DecideQuotes: %v", err) + } + if len(quotes.Quotes) != 1 || quotes.Quotes[0].ExclusiveFor != solver { + t.Fatalf("quotes = %+v", quotes.Quotes) + } + plan, err := strategy.DecideFill(t.Context(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(100), OutputAmount: big.NewInt(90), + Quotes: []liquidlane.FillQuote{{ + Inventory: inventory, AmountIn: big.NewInt(100), MaxAmountOut: big.NewInt(100), + }}, + }) + if err != nil { + t.Fatalf("DecideFill: %v", err) + } + if plan == nil || len(plan.Routes) != 1 || plan.Routes[0].RouteID != route.ID { + t.Fatalf("plan = %+v", plan) + } +} diff --git a/internal/solvers/lifi/strategy.go b/internal/solvers/lifi/strategy.go new file mode 100644 index 00000000..a96bf411 --- /dev/null +++ b/internal/solvers/lifi/strategy.go @@ -0,0 +1,12 @@ +package lifi + +import ( + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies" + _ "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/default" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" + _ "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/webhook" +) + +func newStrategy(spec StrategyConfig) (types.Strategy, error) { + return strategies.New(spec.Name, spec.Config) +} diff --git a/internal/solvers/lifi/submission.go b/internal/solvers/lifi/submission.go new file mode 100644 index 00000000..db235b4d --- /dev/null +++ b/internal/solvers/lifi/submission.go @@ -0,0 +1,101 @@ +package lifi + +import ( + "context" + "math/big" + + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" + "github.com/symbioticfi/vault-solver/internal/solvers/lifi/strategies/types" + "github.com/symbioticfi/vault-solver/internal/txmanager" +) + +func (s *Solver) submitFill( + ctx context.Context, + order *submittedOrder, + plan *types.FillPlan, + calldata *fillCalldata, + maxFeePerGas *big.Int, +) *pendingFill { + reservations, ok := fillPlanReservations(plan) + if !ok { + s.log.Error(errors.New("strategy returned invalid capacity reservations"), + "order fill: reject strategy plan", "orderId", order.OrderID, "quoteId", order.QuoteID) + return nil + } + status, err := s.reader.orderStatus(ctx, s.cfg.InputSettler, calldata.OrderID) + if err != nil { + s.log.Error(err, "order fill: read order status", "orderId", order.OrderID, + "onChainOrderId", calldata.OrderID.Hex(), "quoteId", order.QuoteID) + return nil + } + if status != lifiOrderStatusDeposited { + s.log.Info("order skipped: on-chain order is not deposited", "orderId", order.OrderID, + "onChainOrderId", calldata.OrderID.Hex(), "quoteId", order.QuoteID, "status", status) + return nil + } + reservationKey := calldata.OrderID.Hex() + confirmations := uint64(0) + result, accepted := s.txm.SendAsync(ctx, txmanager.Request{ + To: s.cfg.Executor, Data: calldata.Finalise, MaxFeePerGas: new(big.Int).Set(maxFeePerGas), + Confirmations: &confirmations, Label: "lifi-fill", + }) + if !accepted { + s.log.Info("order skipped: transaction submission canceled", "orderId", order.OrderID, + "onChainOrderId", calldata.OrderID.Hex(), "quoteId", order.QuoteID) + return nil + } + s.reserve(reservationKey, reservations) + return &pendingFill{ + order: order, orderID: calldata.OrderID, reservationKey: reservationKey, + result: result, + } +} + +func (s *Solver) completeFill(pending *pendingFillState, completion fillCompletion) { + fill := completion.fill + pending.remove(fill.reservationKey) + s.releaseReservation(fill.reservationKey) + if completion.result.Err == nil { + s.log.Info("order filled", "orderId", fill.order.OrderID, "onChainOrderId", fill.orderID.Hex(), + "quoteId", fill.order.QuoteID, "tx", completion.result.Hash.Hex()) + return + } + s.log.Error(completion.result.Err, "order fill failed", + "orderId", fill.order.OrderID, + "onChainOrderId", fill.orderID.Hex(), + "quoteId", fill.order.QuoteID, + "tx", completion.result.Hash.Hex(), + ) +} + +func fillPlanReservations(plan *types.FillPlan) (liquidlane.CapacityReservations, bool) { + if plan == nil || len(plan.Routes) == 0 { + return nil, false + } + return liquidstrategies.FillRouteReservations(plan.Routes) +} + +func (s *Solver) reserve(orderKey string, reservations liquidlane.CapacityReservations) { + if s.capacity.Set(orderKey, reservations) { + s.requestQuoteRefresh() + } +} + +func (s *Solver) releaseReservation(orderKey string) { + if s.capacity.Delete(orderKey) { + s.requestQuoteRefresh() + } +} + +func (s *Solver) requestQuoteRefresh() { + if s.quoteRefresh == nil { + return + } + select { + case s.quoteRefresh <- struct{}{}: + default: + } +} diff --git a/internal/solvers/lifi/wsclient.go b/internal/solvers/lifi/wsclient.go new file mode 100644 index 00000000..083e99c5 --- /dev/null +++ b/internal/solvers/lifi/wsclient.go @@ -0,0 +1,135 @@ +package lifi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/go-errors/errors" + "github.com/go-logr/logr" + "github.com/gorilla/websocket" +) + +const ( + orderSubmitEvent = "user:vm-order-submit" + initialWSBackoff = time.Second + maxWSBackoff = 30 * time.Second +) + +type orderMessage struct { + Event string `json:"event"` + Data json.RawMessage `json:"data"` +} + +type orderFeed struct { + url string + apiKey string + log logr.Logger +} + +func newOrderFeed(url, apiKey string, log logr.Logger) *orderFeed { + return &orderFeed{url: url, apiKey: apiKey, log: log} +} + +func (f *orderFeed) run(ctx context.Context, handle func(context.Context, orderMessage)) error { + backoff := initialWSBackoff + for { + connected, err := f.watchOnce(ctx, handle) + if ctx.Err() != nil { + return ctx.Err() + } + if connected { + backoff = initialWSBackoff + } + f.log.Error(err, "order feed disconnected; reconnecting", "backoff", backoff.String()) + timer := time.NewTimer(backoff) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + backoff *= 2 + if backoff > maxWSBackoff { + backoff = maxWSBackoff + } + } +} + +func (f *orderFeed) watchOnce( + ctx context.Context, + handle func(context.Context, orderMessage), +) (bool, error) { + headers := http.Header{} + if f.apiKey != "" { + headers.Set("x-api-key", f.apiKey) + } + conn, resp, err := websocket.DefaultDialer.DialContext(ctx, f.url, headers) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if err != nil { + if resp != nil { + return false, errors.Errorf("dial websocket: %w (status %s)", err, resp.Status) + } + return false, errors.Errorf("dial websocket: %w", err) + } + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = conn.Close() + case <-done: + } + }() + defer close(done) + defer conn.Close() + + f.log.Info("order feed connected", "url", f.url) + for { + messageType, msg, err := conn.ReadMessage() + if err != nil { + return true, errors.Errorf("read websocket: %w", err) + } + if messageType != websocket.TextMessage { + continue + } + if pong, ok := pongFor(msg); ok { + if err := conn.WriteMessage(websocket.TextMessage, pong); err != nil { + return true, errors.Errorf("write websocket pong: %w", err) + } + continue + } + + var envelope orderMessage + if err := json.Unmarshal(msg, &envelope); err != nil { + f.log.V(1).Info("order feed: non-json message ignored") + continue + } + if envelope.Event != orderSubmitEvent { + f.log.V(1).Info("order feed event ignored", "event", envelope.Event) + continue + } + handle(ctx, envelope) + } +} + +func pongFor(msg []byte) ([]byte, bool) { + trimmed := bytes.TrimSpace(msg) + if strings.EqualFold(string(trimmed), "ping") { + return []byte("pong"), true + } + var envelope struct { + Event string `json:"event"` + } + if err := json.Unmarshal(trimmed, &envelope); err != nil { + return nil, false + } + if strings.EqualFold(envelope.Event, "ping") { + return []byte(`{"event":"pong"}`), true + } + return nil, false +} diff --git a/internal/solvers/lifi/wsclient_test.go b/internal/solvers/lifi/wsclient_test.go new file mode 100644 index 00000000..4ef6a1b2 --- /dev/null +++ b/internal/solvers/lifi/wsclient_test.go @@ -0,0 +1,58 @@ +package lifi + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-logr/logr" + "github.com/gorilla/websocket" +) + +func TestPongFor(t *testing.T) { + tests := []struct { + name string + in string + want string + ok bool + }{ + {name: "plain", in: "ping", want: "pong", ok: true}, + {name: "json", in: `{"event":"ping"}`, want: `{"event":"pong"}`, ok: true}, + {name: "other", in: `{"event":"user:vm-order-submit"}`, ok: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := pongFor([]byte(tt.in)) + if ok != tt.ok { + t.Fatalf("ok = %v", ok) + } + if string(got) != tt.want { + t.Fatalf("pong = %q", got) + } + }) + } +} + +func TestWatchOnceReportsEstablishedConnection(t *testing.T) { + upgrader := websocket.Upgrader{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade: %v", err) + return + } + _ = conn.Close() + })) + defer server.Close() + + feed := newOrderFeed("ws"+strings.TrimPrefix(server.URL, "http"), "", logr.Discard()) + connected, err := feed.watchOnce(context.Background(), func(context.Context, orderMessage) {}) + if !connected { + t.Fatal("connection was not reported as established") + } + if err == nil { + t.Fatal("expected read error after server closed the connection") + } +} diff --git a/internal/solvers/redstoneoev/chainreader.go b/internal/solvers/redstoneoev/chainreader.go index 4bb09658..a46de81a 100644 --- a/internal/solvers/redstoneoev/chainreader.go +++ b/internal/solvers/redstoneoev/chainreader.go @@ -3,44 +3,27 @@ package redstoneoev import ( "context" "math/big" - "slices" - "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/liquidlane/adapter" "github.com/symbioticfi/vault-solver/api/bindings/oev/executor" - "github.com/symbioticfi/vault-solver/api/bindings/vaultv2" "github.com/symbioticfi/vault-solver/internal/chain" + "github.com/symbioticfi/vault-solver/internal/liquidlane" "github.com/symbioticfi/vault-solver/internal/solvers/redstoneoev/strategies/types" ) -var ( - executorB = executor.NewRedStoneExecutor() - liquidLaneRead = adapter.NewLiquidLaneAdapter() - erc4626Read = erc4626.NewIERC4626() - vaultV2Read = vaultv2.NewIVaultV2() -) +var executorB = executor.NewRedStoneExecutor() -// reader performs solver-owned on-chain reads. Strategy-owned reads live in the strategy package. +// reader owns RedStone Executor reads and maps shared LiquidLane facts into OEV strategy input. type reader struct { - chain *chain.Client - log logr.Logger - decimals *chain.Decimals - mu sync.Mutex - redeemColl map[common.Address][]common.Address + chain *chain.Client + ll *liquidlane.Reader } -func newReader(c *chain.Client, log logr.Logger) *reader { - return &reader{ - chain: c, - log: log, - decimals: chain.NewDecimals(c), - redeemColl: map[common.Address][]common.Address{}, - } +func newReader(c *chain.Client, log logr.Logger, liquidityLens common.Address) *reader { + return &reader{chain: c, ll: liquidlane.NewReader(c, log, liquidityLens)} } // ExecutorState is the signer's accounting on the RedStone Executor. @@ -63,309 +46,38 @@ func (r *reader) ReadExecutorState(ctx context.Context, executorAddr, signer com 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 { + nonce, nonceErr := executorB.UnpackNonces(res[0].ReturnData) + deposit, depositErr := executorB.UnpackDeposits(res[1].ReturnData) + locked, lockedErr := executorB.UnpackLocked(res[2].ReturnData) + if nonceErr != nil || depositErr != nil || lockedErr != nil { return ExecutorState{}, errors.New("executor state decode failed") } return ExecutorState{Nonce: nonce, Deposit: deposit, Locked: locked}, nil } -// ReadAdapterSnapshot reads the configured LiquidLane adapter context passed to every strategy. -func (r *reader) ReadAdapterSnapshot(ctx context.Context, adapterAddr, callback common.Address) (types.AdapterSnapshot, error) { - head, err := r.readAdapterHead(ctx, adapterAddr) - if err != nil { - return types.AdapterSnapshot{}, err - } - state, err := r.readAdapterVaultState(ctx, head.Vault) - if err != nil { - return types.AdapterSnapshot{}, err - } - if state.Loan == (common.Address{}) { - return types.AdapterSnapshot{}, errors.New("adapter loan token unresolved") - } - redeemable, err := r.readRedeemable(ctx, adapterAddr, head.Owner, head.MarketMaker) - if err != nil { - return types.AdapterSnapshot{}, err - } - if len(redeemable) == 0 { - return types.AdapterSnapshot{}, errors.New("adapter redeemable collateral unresolved") - } - loanDecimals, err := r.fillRedeemableDecimals(ctx, state.Loan, redeemable) +// ReadAdapterSnapshot maps the shared LiquidLane snapshot to the stable OEV strategy contract. +func (r *reader) ReadAdapterSnapshot( + ctx context.Context, + adapterAddress common.Address, + callback common.Address, +) (types.AdapterSnapshot, error) { + snapshot, err := r.ll.ReadAdapterSnapshot(ctx, adapterAddress, callback) if err != nil { return types.AdapterSnapshot{}, err } - filler, err := r.readCallbackAuthorization(ctx, adapterAddr, head, callback) - if err != nil { - return types.AdapterSnapshot{}, err + redeemable := make([]types.RedeemableSnapshot, 0, len(snapshot.Routes)) + for _, route := range snapshot.Routes { + redeemable = append(redeemable, types.RedeemableSnapshot{ + Asset: route.TokenIn, Decimals: route.TokenInDecimals, + MaxRate: liquidlane.CloneBig(route.MaxRate), MaxAssets: liquidlane.CloneBig(route.MaxAssets), + AcquireBalance: liquidlane.CloneBig(route.AcquireBalance), + }) } return types.AdapterSnapshot{ - Address: adapterAddr, - Vault: head.Vault, - Loan: state.Loan, - LoanDecimals: loanDecimals, - Paused: head.Paused, - FreeAssets: state.FreeAssets, - Withdrawable: state.Withdrawable, - Redeemable: redeemable, - Filler: filler, - }, nil -} - -type adapterHead struct { - Vault common.Address - Owner common.Address - MarketMaker common.Address - Paused bool -} - -type adapterVaultState struct { - Loan common.Address - FreeAssets *big.Int - Withdrawable *big.Int -} - -func (r *reader) readAdapterHead(ctx context.Context, adapterAddr common.Address) (adapterHead, error) { - res, err := r.chain.Multicall(ctx, []chain.Call{ - {Target: adapterAddr, AllowFailure: true, Data: liquidLaneRead.PackVault()}, - {Target: adapterAddr, AllowFailure: true, Data: liquidLaneRead.PackOwner()}, - {Target: adapterAddr, AllowFailure: true, Data: liquidLaneRead.PackMarketMaker()}, - {Target: adapterAddr, AllowFailure: true, Data: liquidLaneRead.PackPaused()}, - }) - if err != nil { - return adapterHead{}, err - } - if !allSuccess(res, 4) { - return adapterHead{}, errors.New("adapter head read reverted") - } - vault, e1 := liquidLaneRead.UnpackVault(res[0].ReturnData) - owner, e2 := liquidLaneRead.UnpackOwner(res[1].ReturnData) - marketMaker, e3 := liquidLaneRead.UnpackMarketMaker(res[2].ReturnData) - paused, e4 := liquidLaneRead.UnpackPaused(res[3].ReturnData) - if e1 != nil || e2 != nil || e3 != nil || e4 != nil || vault == (common.Address{}) { - return adapterHead{}, errors.New("adapter head decode failed") - } - return adapterHead{ - Vault: vault, - Owner: owner, - MarketMaker: marketMaker, - Paused: paused, - }, nil -} - -func (r *reader) readAdapterVaultState(ctx context.Context, vault common.Address) (adapterVaultState, error) { - res, err := r.chain.Multicall(ctx, []chain.Call{ - {Target: vault, AllowFailure: true, Data: erc4626Read.PackAsset()}, - {Target: vault, AllowFailure: true, Data: vaultV2Read.PackFreeAssets()}, - {Target: vault, AllowFailure: true, Data: vaultV2Read.PackWithdrawable()}, - }) - if err != nil { - return adapterVaultState{}, err - } - if !allSuccess(res, 3) { - return adapterVaultState{}, errors.New("adapter vault state read reverted") - } - loan, e1 := erc4626Read.UnpackAsset(res[0].ReturnData) - free, e2 := vaultV2Read.UnpackFreeAssets(res[1].ReturnData) - withdrawable, e3 := vaultV2Read.UnpackWithdrawable(res[2].ReturnData) - if e1 != nil || e2 != nil || e3 != nil || loan == (common.Address{}) || free == nil || withdrawable == nil { - return adapterVaultState{}, errors.New("adapter vault state decode failed") - } - return adapterVaultState{ - Loan: loan, - FreeAssets: free, - Withdrawable: withdrawable, + Address: snapshot.Adapter.Adapter, Vault: snapshot.Vault, + Loan: snapshot.TokenOut, LoanDecimals: snapshot.TokenOutDecimals, + Paused: snapshot.Paused, + FreeAssets: liquidlane.CloneBig(snapshot.FreeAssets), Withdrawable: liquidlane.CloneBig(snapshot.Withdrawable), + Redeemable: redeemable, Filler: snapshot.Authorized, }, nil } - -func (r *reader) readRedeemable(ctx context.Context, adapterAddr, owner, marketMaker common.Address) ([]types.RedeemableSnapshot, error) { - collaterals, err := r.readRedeemableCollaterals(ctx, adapterAddr) - if err != nil { - return nil, err - } - collaterals = dedupeNonZeroAddresses(collaterals) - out := make([]types.RedeemableSnapshot, 0, len(collaterals)) - for _, coll := range collaterals { - snap, err := r.readRedeemableSnapshot(ctx, adapterAddr, coll, owner, marketMaker) - if err != nil { - return nil, err - } - out = append(out, snap) - } - return out, nil -} - -func (r *reader) readRedeemableSnapshot(ctx context.Context, adapterAddr, coll, owner, marketMaker common.Address) (types.RedeemableSnapshot, error) { - calls := []chain.Call{ - {Target: adapterAddr, AllowFailure: true, Data: liquidLaneRead.PackGetMaxRate(coll)}, - {Target: adapterAddr, AllowFailure: true, Data: liquidLaneRead.PackGetMaxAssets(coll)}, - {Target: adapterAddr, AllowFailure: true, Data: liquidLaneRead.PackAcquireBalance(coll, owner)}, - } - readMarketMakerAcquire := marketMaker != (common.Address{}) && marketMaker != owner - if readMarketMakerAcquire { - calls = append(calls, chain.Call{Target: adapterAddr, AllowFailure: true, Data: liquidLaneRead.PackAcquireBalance(coll, marketMaker)}) - } - res, err := r.chain.Multicall(ctx, calls) - if err != nil { - return types.RedeemableSnapshot{}, err - } - if !allSuccess(res, len(calls)) { - return types.RedeemableSnapshot{}, errors.Errorf("redeemable %s read reverted", coll.Hex()) - } - maxRate, e1 := liquidLaneRead.UnpackGetMaxRate(res[0].ReturnData) - maxAssets, e2 := liquidLaneRead.UnpackGetMaxAssets(res[1].ReturnData) - acquire, e3 := liquidLaneRead.UnpackAcquireBalance(res[2].ReturnData) - if e1 != nil || e2 != nil || e3 != nil || maxRate == nil || maxAssets == nil || acquire == nil { - return types.RedeemableSnapshot{}, errors.Errorf("redeemable %s decode failed", coll.Hex()) - } - if readMarketMakerAcquire { - mmAcquire, merr := liquidLaneRead.UnpackAcquireBalance(res[3].ReturnData) - if merr != nil || mmAcquire == nil { - return types.RedeemableSnapshot{}, errors.Errorf("redeemable %s market-maker acquire decode failed", coll.Hex()) - } - acquire = new(big.Int).Add(acquire, mmAcquire) - } - return types.RedeemableSnapshot{ - Asset: coll, - MaxRate: maxRate, - MaxAssets: maxAssets, - AcquireBalance: acquire, - }, nil -} - -func (r *reader) fillRedeemableDecimals(ctx context.Context, loan common.Address, redeemable []types.RedeemableSnapshot) (int, error) { - tokens := make([]common.Address, 0, 1+len(redeemable)) - tokens = append(tokens, loan) - for _, item := range redeemable { - tokens = append(tokens, item.Asset) - } - decimals, err := r.decimals.GetMany(ctx, tokens) - if err != nil { - return 0, err - } - loanDecimals, ok := decimals[loan] - if !ok { - return 0, errors.Errorf("erc20.decimals() missing for loan token %s", loan.Hex()) - } - for i := range redeemable { - dec, hasDecimals := decimals[redeemable[i].Asset] - if !hasDecimals { - return 0, errors.Errorf("erc20.decimals() missing for redeemable token %s", redeemable[i].Asset.Hex()) - } - redeemable[i].Decimals = dec - } - return loanDecimals, nil -} - -func (r *reader) readRedeemableCollaterals(ctx context.Context, adapterAddr common.Address) ([]common.Address, error) { - r.mu.Lock() - c, ok := r.redeemColl[adapterAddr] - r.mu.Unlock() - if ok { - return slices.Clone(c), nil - } - lenRes, err := r.chain.Multicall(ctx, []chain.Call{ - {Target: adapterAddr, AllowFailure: true, Data: liquidLaneRead.PackGetTokensToRedeemLength()}, - }) - if err != nil { - return nil, err - } - count, ok := decodeRedeemCount(lenRes) - if !ok { - return nil, nil - } - if count == 0 { - r.mu.Lock() - r.redeemColl[adapterAddr] = nil - r.mu.Unlock() - return nil, nil - } - calls := make([]chain.Call, count) - for i := range count { - calls[i] = chain.Call{Target: adapterAddr, AllowFailure: true, Data: liquidLaneRead.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 - } - r.mu.Lock() - r.redeemColl[adapterAddr] = slices.Clone(toks) - r.mu.Unlock() - return toks, nil -} - -func (r *reader) readCallbackAuthorization(ctx context.Context, adapterAddr common.Address, head adapterHead, callback common.Address) (bool, error) { - if callback == (common.Address{}) { - return false, nil - } - if callback == head.Owner || callback == head.MarketMaker { - return true, nil - } - if head.MarketMaker == (common.Address{}) { - return false, nil - } - res, err := r.chain.Multicall(ctx, []chain.Call{ - {Target: adapterAddr, AllowFailure: true, Data: liquidLaneRead.PackIsFiller(head.MarketMaker, callback)}, - }) - if err != nil { - return false, err - } - if len(res) != 1 || !res[0].Success { - return false, nil - } - filler, err := liquidLaneRead.UnpackIsFiller(res[0].ReturnData) - if err != nil { - return false, errors.Errorf("adapter filler status decode failed: %w", err) - } - return filler, nil -} - -func decodeRedeemCount(res []chain.CallResult) (int, bool) { - if len(res) != 1 || !res[0].Success { - return 0, false - } - n, err := liquidLaneRead.UnpackGetTokensToRedeemLength(res[0].ReturnData) - if err != nil || n == nil || n.Sign() < 0 || !n.IsInt64() { - return 0, false - } - return int(n.Int64()), true -} - -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 := liquidLaneRead.UnpackTokensToRedeem(res[i].ReturnData) - if err != nil || tok == (common.Address{}) { - return nil, false - } - out = append(out, tok) - } - return out, true -} - -func dedupeNonZeroAddresses(in []common.Address) []common.Address { - seen := make(map[common.Address]struct{}, len(in)) - out := make([]common.Address, 0, len(in)) - for _, addr := range in { - if addr == (common.Address{}) { - continue - } - if _, ok := seen[addr]; ok { - continue - } - seen[addr] = struct{}{} - out = append(out, addr) - } - return out -} diff --git a/internal/solvers/redstoneoev/config.go b/internal/solvers/redstoneoev/config.go index 5239f084..5c4f789f 100644 --- a/internal/solvers/redstoneoev/config.go +++ b/internal/solvers/redstoneoev/config.go @@ -18,6 +18,7 @@ type rawConfig struct { Executor string `yaml:"executor"` Adapter string `yaml:"adapter"` Callback string `yaml:"callback"` + LiquidityLens string `yaml:"liquidityLens"` Strategy rawStrategyConfig `yaml:"strategy"` MaxTxGasPriceWei string `yaml:"maxTxGasPriceWei"` MaxBidWei string `yaml:"maxBidWei"` @@ -55,6 +56,10 @@ type Config struct { Executor common.Address Adapter common.Address Callback common.Address + // LiquidityLens is the optional FrontendLiquidityLens address. When set, LiquidLane swappable headroom + // is read from the lens's cross-adapter deallocation-cascade estimate instead of the adapter's own + // getMaxAssets(tokenToRedeem); zero falls back to the adapter getter. + LiquidityLens common.Address Strategy StrategyConfig @@ -108,6 +113,12 @@ func parseConfig(node yaml.Node) (*Config, error) { if err != nil { return nil, err } + var liquidityLens common.Address + if raw.LiquidityLens != "" { + if liquidityLens, err = parse.NonZeroAddress(raw.LiquidityLens, "liquidityLens"); err != nil { + return nil, err + } + } breakerWindow, err := parse.MsDuration(raw.Breaker.WindowMs, defaultBreakerWindow, "breaker.windowMs") if err != nil { @@ -126,11 +137,12 @@ func parseConfig(node yaml.Node) (*Config, error) { } cfg := &Config{ - WSURL: raw.WS.URL, - APIKeyEnv: raw.WS.APIKeyEnv, - Executor: executor, - Adapter: adapter, - Callback: callback, + WSURL: raw.WS.URL, + APIKeyEnv: raw.WS.APIKeyEnv, + Executor: executor, + Adapter: adapter, + Callback: callback, + LiquidityLens: liquidityLens, Strategy: StrategyConfig{ Name: parse.OrDefault(raw.Strategy.Name, defaultStrategyName), Config: raw.Strategy.Config, diff --git a/internal/solvers/redstoneoev/factory.go b/internal/solvers/redstoneoev/factory.go index 8dade370..491983ba 100644 --- a/internal/solvers/redstoneoev/factory.go +++ b/internal/solvers/redstoneoev/factory.go @@ -45,7 +45,7 @@ func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { chainID: chainID, dryRun: dryRun, strategyName: cfg.Strategy.Name, - reader: newReader(deps.Chain, log), + reader: newReader(deps.Chain, log, cfg.LiquidityLens), nonces: &nonceStore{}, breaker: newBreaker(cfg.BreakerMaxFailures, cfg.BreakerWindow), metrics: mx, diff --git a/internal/solvers/redstoneoev/strategies/default/chainreader.go b/internal/solvers/redstoneoev/strategies/default/chainreader.go index 98ed7a0a..e16c51ee 100644 --- a/internal/solvers/redstoneoev/strategies/default/chainreader.go +++ b/internal/solvers/redstoneoev/strategies/default/chainreader.go @@ -10,7 +10,7 @@ import ( "github.com/go-errors/errors" "github.com/go-logr/logr" - "github.com/symbioticfi/vault-solver/api/bindings/oev/aggregator" + "github.com/symbioticfi/vault-solver/api/bindings/chainlink/aggregator" "github.com/symbioticfi/vault-solver/api/bindings/oev/callback" morphobinding "github.com/symbioticfi/vault-solver/api/bindings/oev/morpho" "github.com/symbioticfi/vault-solver/api/bindings/oev/oracle" diff --git a/internal/solvers/redstoneoev/strategies/default/chainreader_test.go b/internal/solvers/redstoneoev/strategies/default/chainreader_test.go index dfcd235b..73e358d9 100644 --- a/internal/solvers/redstoneoev/strategies/default/chainreader_test.go +++ b/internal/solvers/redstoneoev/strategies/default/chainreader_test.go @@ -8,7 +8,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" - "github.com/symbioticfi/vault-solver/api/bindings/oev/aggregator" + "github.com/symbioticfi/vault-solver/api/bindings/chainlink/aggregator" ) func mustParseABI(j string) abi.ABI { diff --git a/internal/solvers/rfq/apitypes.go b/internal/solvers/rfq/apitypes.go index 6419301d..dfeca818 100644 --- a/internal/solvers/rfq/apitypes.go +++ b/internal/solvers/rfq/apitypes.go @@ -3,10 +3,12 @@ package rfq import ( "math/big" "strconv" + "time" "github.com/ethereum/go-ethereum/common" "github.com/go-errors/errors" + "github.com/symbioticfi/vault-solver/internal/liquidlane" "github.com/symbioticfi/vault-solver/internal/parse" ) @@ -96,7 +98,7 @@ func (q *quoteRequest) toStrategy(chainID int64) (*parsedQuote, error) { } inv := make([]solverInventory, 0, len(q.Adapters)) for i := range q.Adapters { - entry, perr := q.Adapters[i].parse(i) + entry, perr := q.Adapters[i].parse(i, q.TokenInChainID, tokenIn) if perr != nil { return nil, perr } @@ -119,7 +121,7 @@ func (q *quoteRequest) toStrategy(chainID int64) (*parsedQuote, error) { }, nil } -func (v *quoteAdapter) parse(index int) (solverInventory, error) { +func (v *quoteAdapter) parse(index int, chainID int64, tokenIn common.Address) (solverInventory, error) { adapter, err := parse.Address(v.Adapter, idxField(index, "adapter")) if err != nil { return solverInventory{}, err @@ -144,14 +146,11 @@ func (v *quoteAdapter) parse(index int) (solverInventory, error) { h := common.HexToHash(*v.DiscountID) discountID = &h } - return solverInventory{ - Adapter: adapter, - Asset: asset, - AssetDecimals: v.AssetDecimals, - MaxAssets: maxAssets, - MaxRate: maxRate, - DiscountID: discountID, - }, nil + route := liquidlane.NewRoute(chainID, adapter, common.Address{}, tokenIn, asset, 0, v.AssetDecimals) + if discountID != nil { + return liquidlane.DiscountInventory(route, maxAssets, maxRate, *discountID, time.Time{}), nil + } + return liquidlane.DirectInventory(route, maxAssets, maxRate), nil } // parseUint256 parses a base-10 non-negative integer string into a big.Int. diff --git a/internal/solvers/rfq/backend.go b/internal/solvers/rfq/backend.go index 94a837fb..adf1a595 100644 --- a/internal/solvers/rfq/backend.go +++ b/internal/solvers/rfq/backend.go @@ -9,6 +9,7 @@ import ( "github.com/go-errors/errors" "github.com/symbioticfi/vault-solver/api/rfqbackend" + "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" ) // backendOrder is one order row from the RFQ backend (GET /orders), projected from the generated @@ -43,11 +44,11 @@ type backendOut struct { Recipient string } -// backendClient is a thin adapter over the generated rfqbackend client for the filler-facing order -// and discount endpoints. It owns no transport state of its own beyond the generated APIClient, whose -// HTTPClient carries the request timeout. Used from the single execution goroutine. +// backendClient is a thin adapter over the generated rfqbackend client for filler-facing orders plus +// the shared private-discounts client. Used from the single execution goroutine. type backendClient struct { - api *rfqbackend.APIClient + api *rfqbackend.APIClient + discounts *discounts.Client } // newBackendClient builds a backend client rooted at baseURL. The generated client carries the @@ -58,30 +59,9 @@ func newBackendClient(baseURL string) *backendClient { cfg := rfqbackend.NewConfiguration() cfg.Servers = rfqbackend.ServerConfigurations{{URL: strings.TrimRight(baseURL, "/")}} cfg.HTTPClient = &http.Client{ - Timeout: 10 * time.Second, - Transport: internalDiscountTransport{base: http.DefaultTransport}, + Timeout: 10 * time.Second, } - return &backendClient{api: rfqbackend.NewAPIClient(cfg)} -} - -const ( - publicAPIPrefix = "/api/v1" // the spec's prefix, baked into the generated client - internalAPIPrefix = "/api-internal/v1" // where the backend serves the internal-only discounts API -) - -// internalDiscountTransport routes discount requests to the backend's internal API prefix. The discounts -// API is internal-only, but the generated client emits the public /api/v1/discount(s) paths from the -// spec; rather than regenerate the client for a deployment routing detail, we rewrite just those paths to -// /api-internal/v1/... at the transport layer. Orders and everything else pass through unchanged. -type internalDiscountTransport struct{ base http.RoundTripper } - -func (t internalDiscountTransport) RoundTrip(req *http.Request) (*http.Response, error) { - if strings.HasPrefix(req.URL.Path, publicAPIPrefix+"/discount") { - req = req.Clone(req.Context()) // RoundTrippers must not mutate the caller's request - req.URL.Path = internalAPIPrefix + strings.TrimPrefix(req.URL.Path, publicAPIPrefix) - req.URL.RawPath = "" // drop any cached encoding so the URL re-encodes from Path - } - return t.base.RoundTrip(req) + return &backendClient{api: rfqbackend.NewAPIClient(cfg), discounts: discounts.NewClient(baseURL)} } // closeResp drains and closes the HTTP response body. The generated client already reads the body @@ -200,50 +180,10 @@ func first(orders []backendOrder) *backendOrder { return &orders[0] } -/* ───────── discounts (P3) ───────── */ - -// discountTerms is the signed discount the adapter's discount-swap verifies. Amounts/nonce are -// numeric/hex strings on the wire. -type discountTerms struct { - Adapter string - TokenToRedeem string - Discount string - Signer string - Protocol string - Nonce string - Deadline int64 -} - -// resolveDiscountResponse is the fresh, signed discount the backend issues at fill time (the single -// shape of the backend's ResolveDiscountResponse anyOf union; see resolveDiscount). -type resolveDiscountResponse struct { - RequestID string - DiscountID string - Discount discountTerms - SignerSignature string - ProtocolDeadline int64 - ProtocolSignature string -} - -// discountListItem is one offered discount (GET /discounts), used during strategy recovery. -type discountListItem struct { - DiscountID string - Adapter string - TokenToRedeem string - Collateral string - CollateralDecimals int - Discount string - Signer string - Deadline int64 - MaxRate string - MaxAssets string -} - -type discountsResponse struct { - RequestID string - Protocol string - Discounts []discountListItem -} +type discountTerms = discounts.Terms +type resolveDiscountResponse = discounts.Resolved +type discountListItem = discounts.ListItem +type discountsResponse = discounts.List // resolveDiscount fetches the fresh signed discount for a discountId (POST /discounts). // @@ -253,92 +193,10 @@ type discountsResponse struct { // accepted (it carries the same signed fields); anything else (neither shape, or a batch with ≠1 // entries) is rejected so we never fill on an ambiguous resolution. func (c *backendClient) resolveDiscount(ctx context.Context, discountID string) (*resolveDiscountResponse, error) { - body := rfqbackend.NewApiV1DiscountsPostRequest() - body.SetDiscountId(discountID) - resp, httpResp, err := c.api.RFQAPI.ApiV1DiscountsPost(ctx).ApiV1DiscountsPostRequest(*body).Execute() - closeResp(httpResp) - if err != nil { - return nil, errors.Errorf("backend: resolve discount: %w", err) - } - if resp == nil { - return nil, errors.New("backend: resolve discount: empty response") - } - if single := resp.ResolveDiscountResponseAnyOf; single != nil { - return resolvedFromSingle(single), nil - } - if batch := resp.ResolveDiscountResponseAnyOf1; batch != nil { - items := batch.GetDiscounts() - if len(items) != 1 { - return nil, errors.Errorf("backend: resolve discount: expected a single discount, got %d", len(items)) - } - return resolvedFromBatchItem(batch.GetRequestId(), &items[0]), nil - } - return nil, errors.New("backend: resolve discount: response matched neither discount shape") -} - -func resolvedFromSingle(s *rfqbackend.ResolveDiscountResponseAnyOf) *resolveDiscountResponse { - return &resolveDiscountResponse{ - RequestID: s.GetRequestId(), - DiscountID: s.GetDiscountId(), - Discount: termsFromModel(s.GetDiscount()), - SignerSignature: s.GetSignerSignature(), - ProtocolDeadline: int64(s.GetProtocolDeadline()), - ProtocolSignature: s.GetProtocolSignature(), - } -} - -func resolvedFromBatchItem(requestID string, it *rfqbackend.ResolveDiscountResponseAnyOf1DiscountsInner) *resolveDiscountResponse { - return &resolveDiscountResponse{ - RequestID: requestID, - DiscountID: it.GetDiscountId(), - Discount: termsFromModel(it.GetDiscount()), - SignerSignature: it.GetSignerSignature(), - ProtocolDeadline: int64(it.GetProtocolDeadline()), - ProtocolSignature: it.GetProtocolSignature(), - } -} - -func termsFromModel(d rfqbackend.PublishDiscountRequestDiscount) discountTerms { - return discountTerms{ - Adapter: d.GetAdapter(), - TokenToRedeem: d.GetTokenToRedeem(), - Discount: d.GetDiscount(), - Signer: d.GetSigner(), - Protocol: d.GetProtocol(), - Nonce: d.GetNonce(), - Deadline: int64(d.GetDeadline()), - } + return c.discounts.Resolve(ctx, discountID) } // listDiscounts lists currently-offered discounts (GET /discounts). func (c *backendClient) listDiscounts(ctx context.Context) (*discountsResponse, error) { - resp, httpResp, err := c.api.RFQAPI.ApiV1DiscountsGet(ctx).Execute() - closeResp(httpResp) - if err != nil { - return nil, errors.Errorf("backend: list discounts: %w", err) - } - out := &discountsResponse{} - if resp == nil { - return out, nil - } - out.RequestID = resp.GetRequestId() - out.Protocol = resp.GetProtocol() - gen := resp.GetDiscounts() - out.Discounts = make([]discountListItem, 0, len(gen)) - for i := range gen { - d := &gen[i] - out.Discounts = append(out.Discounts, discountListItem{ - DiscountID: d.GetDiscountId(), - Adapter: d.GetAdapter(), - TokenToRedeem: d.GetTokenToRedeem(), - Collateral: d.GetCollateral(), - CollateralDecimals: int(d.GetCollateralDecimals()), - Discount: d.GetDiscount(), - Signer: d.GetSigner(), - Deadline: int64(d.GetDeadline()), - MaxRate: d.GetMaxRate(), - MaxAssets: d.GetMaxAssets(), - }) - } - return out, nil + return c.discounts.ListDiscounts(ctx) } diff --git a/internal/solvers/rfq/backend_test.go b/internal/solvers/rfq/backend_test.go index 05df6dce..cfddf238 100644 --- a/internal/solvers/rfq/backend_test.go +++ b/internal/solvers/rfq/backend_test.go @@ -10,7 +10,7 @@ import ( ) // The generated rfqbackend client carries the spec's `/api/v1` prefix, so the backend client rooted at -// the httptest server URL hits `/api/v1/orders`; the discount transport rewrite (internalDiscountTransport) +// the httptest server URL hits `/api/v1/orders`; the shared private-discounts client transport rewrite // sends discount calls to `/api-internal/v1/discounts` instead (orders unchanged). func TestBackendClient_ListOpenOrders(t *testing.T) { diff --git a/internal/solvers/rfq/chainreader.go b/internal/solvers/rfq/chainreader.go index dab40009..c2ebb273 100644 --- a/internal/solvers/rfq/chainreader.go +++ b/internal/solvers/rfq/chainreader.go @@ -2,110 +2,167 @@ package rfq import ( "context" + "maps" + "math/big" "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/liquidlane/adapter" "github.com/symbioticfi/vault-solver/internal/chain" + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidgreedy "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies/greedy" ) -// 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"). -var ( - llAdapter = adapter.NewLiquidLaneAdapter() - // 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() -) - -// readsPerAdapter is the number of Multicall3 sub-calls readVaultInventories issues per adapter -// (paused, getMaxAssets, getMaxRate). vault() and the vault's asset() are resolved once at startup -// (see resolveVaults), not re-read here. -const readsPerAdapter = 3 - -// 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. +// reader is the RFQ adapter over the shared LiquidLane read surface. type reader struct { - chain *chain.Client - log logr.Logger - dec *chain.Decimals + ll *liquidlane.Reader + chainID int64 + quoteAdapters map[common.Address]recoveryVault // assigned once before the quote server starts } -func newReader(c *chain.Client, log logr.Logger) *reader { - return &reader{chain: c, log: log, dec: chain.NewDecimals(c)} +func newReader(c *chain.Client, log logr.Logger, liquidityLens common.Address) *reader { + return &reader{ll: liquidlane.NewReader(c, log, liquidityLens), chainID: c.ChainID().Int64()} } // 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 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 (cached). Delegates to the shared chain.Decimals. -func (r *reader) tokenDecimals(ctx context.Context, token common.Address) (int, error) { - return r.dec.Get(ctx, token) -} +type recoveryVault = liquidlane.Adapter // 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 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. +// to build each fill plan from current state. 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, ) ([]solverInventory, error) { - vaults = dedupeVaultsByAdapter(vaults) if len(vaults) == 0 { return nil, nil } - calls := make([]chain.Call, 0, len(vaults)*readsPerAdapter) - for _, v := range vaults { - calls = append(calls, - chain.Call{Target: v.Adapter, AllowFailure: true, Data: llAdapter.PackPaused()}, - chain.Call{Target: v.Adapter, AllowFailure: true, Data: llAdapter.PackGetMaxAssets(tokenIn)}, - chain.Call{Target: v.Adapter, AllowFailure: true, Data: llAdapter.PackGetMaxRate(tokenIn)}, - ) + return r.ll.ReadInventory(ctx, r.ll.RoutesForToken(ctx, vaults, tokenIn)) +} + +// readQuoteCandidates turns amount-independent inventory into current, +// amount-normalized LiquidLane candidates. This protocol/on-chain adaptation +// belongs to the solver; strategies receive only the completed decision input. +func (r *reader) readQuoteCandidates( + ctx context.Context, + inventory []solverInventory, + tokenIn common.Address, + tokenOut common.Address, + amountIn *big.Int, +) ([]liquidlane.QuoteCandidate, error) { + matching := make([]liquidlane.Inventory, 0, len(inventory)) + for _, item := range inventory { + if item.TokenIn == tokenIn && item.TokenOut == tokenOut { + matching = append(matching, item) + } + } + if len(matching) == 0 { + return nil, nil + } + metadata := r.quoteAdapters + unknown := unresolvedQuoteAdapters(matching, metadata) + if len(unknown) > 0 { + resolved, err := r.ll.ResolveAdapters(ctx, unknown) + if err != nil { + return nil, errors.Errorf("resolve quote adapters: %w", err) + } + metadata = make(map[common.Address]recoveryVault, len(r.quoteAdapters)+len(resolved)) + maps.Copy(metadata, r.quoteAdapters) + for _, adapter := range resolved { + metadata[adapter.Adapter] = adapter + } } - res, err := r.chain.Multicall(ctx, calls) + matching, err := applyResolvedQuoteAdapters(r.chainID, matching, metadata) if err != nil { return nil, err } - if len(res) != len(calls) { - return nil, errors.Errorf("inventory multicall: got %d results, want %d", len(res), len(calls)) + inputDecimals, err := r.ll.TokenDecimals(ctx, tokenIn) + if err != nil { + return nil, errors.Errorf("tokenIn decimals: %w", err) } - - out := make([]solverInventory, 0, len(vaults)) - for i, v := range vaults { - base := i * readsPerAdapter - paused, maxA, mr := res[base], res[base+1], res[base+2] - if !maxA.Success || !mr.Success { - continue - } - if p, perr := llAdapter.UnpackPaused(paused.ReturnData); paused.Success && perr == nil && p { - continue - } - maxAssets, e1 := llAdapter.UnpackGetMaxAssets(maxA.ReturnData) - maxRate, e2 := llAdapter.UnpackGetMaxRate(mr.ReturnData) - if e1 != nil || e2 != nil { - continue + for index := range matching { + matching[index].TokenInDecimals = inputDecimals + } + allocated := liquidgreedy.AllocateInventoryCapacity(matching, nil, 0) + if len(allocated) == 0 { + return nil, nil + } + routes := make([]liquidlane.Route, 0, len(allocated)) + seen := make(map[liquidlane.RouteID]bool, len(allocated)) + for _, item := range allocated { + if !seen[item.ID] { + routes = append(routes, item.Route) + seen[item.ID] = true } - if maxAssets.Sign() <= 0 || maxRate.Sign() <= 0 { + } + quotes, err := r.ll.ReadFillQuotes(ctx, routes, tokenIn, amountIn) + if err != nil { + return nil, err + } + return liquidgreedy.NormalizeOracleInventory(amountIn, allocated, quotes), nil +} + +func (r *reader) setQuoteAdapters(resolved []recoveryVault) { + r.quoteAdapters = resolvedQuoteAdapters(resolved) +} + +func unresolvedQuoteAdapters( + inventory []solverInventory, + resolved map[common.Address]recoveryVault, +) []common.Address { + seen := make(map[common.Address]bool, len(inventory)) + out := make([]common.Address, 0, len(inventory)) + for _, item := range inventory { + if _, ok := resolved[item.Adapter]; ok || seen[item.Adapter] { continue } - decimals, derr := r.tokenDecimals(ctx, v.Asset) - if derr != nil { - continue + seen[item.Adapter] = true + out = append(out, item.Adapter) + } + return out +} + +func resolvedQuoteAdapters(resolved []recoveryVault) map[common.Address]recoveryVault { + out := make(map[common.Address]recoveryVault, len(resolved)) + for _, adapter := range resolved { + if adapter.Adapter != (common.Address{}) && adapter.Vault != (common.Address{}) && + adapter.TokenOut != (common.Address{}) { + out[adapter.Adapter] = adapter } - out = append(out, solverInventory{ - Adapter: v.Adapter, Asset: v.Asset, AssetDecimals: decimals, - MaxAssets: maxAssets, MaxRate: maxRate, DiscountID: nil, - }) + } + return out +} + +func applyResolvedQuoteAdapters( + chainID int64, + inventory []solverInventory, + byAdapter map[common.Address]recoveryVault, +) ([]solverInventory, error) { + out := make([]solverInventory, len(inventory)) + for index, item := range inventory { + adapter, ok := byAdapter[item.Adapter] + if !ok { + return nil, errors.Errorf("resolve quote adapter %s: metadata unavailable", item.Adapter.Hex()) + } + if adapter.TokenOut != item.TokenOut { + return nil, errors.Errorf( + "resolve quote adapter %s: backend asset %s does not match on-chain asset %s", + item.Adapter.Hex(), item.TokenOut.Hex(), adapter.TokenOut.Hex(), + ) + } + if adapter.TokenOutDecimals != item.TokenOutDecimals { + return nil, errors.Errorf( + "resolve quote adapter %s: backend asset decimals %d do not match on-chain decimals %d", + item.Adapter.Hex(), item.TokenOutDecimals, adapter.TokenOutDecimals, + ) + } + item.Vault = adapter.Vault + item.CapacityID = liquidlane.NewCapacityID(chainID, adapter.Vault, item.TokenOut) + out[index] = item } return out, nil } @@ -117,54 +174,40 @@ func (r *reader) readVaultInventories( // vault(), then those vaults' asset()); an entry whose reads revert is left zero and skipped by // 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)) + adapters := make([]common.Address, len(vaults)) for i := range vaults { - out[i].Adapter = vaults[i].Adapter - } - if len(out) == 0 { - return out, nil - } - vcalls := make([]chain.Call, len(out)) - for i := range out { - vcalls[i] = chain.Call{Target: out[i].Adapter, AllowFailure: true, Data: llAdapter.PackVault()} + adapters[i] = vaults[i].Adapter } - vres, err := r.chain.Multicall(ctx, vcalls) - if err != nil { - return nil, err - } - acalls := make([]chain.Call, len(out)) - for i := range out { - if i < len(vres) && vres[i].Success { - if vault, verr := llAdapter.UnpackVault(vres[i].ReturnData); verr == nil { - out[i].Vault = vault - } - } - // asset() reads the resolved vault; a zero target reverts (AllowFailure) and is skipped. - acalls[i] = chain.Call{Target: out[i].Vault, AllowFailure: true, Data: erc4626b.PackAsset()} + return r.ll.ResolveAdapters(ctx, adapters) +} + +func (r *reader) validateDirectAuthorization( + ctx context.Context, + executor common.Address, + vaults []recoveryVault, +) error { + routes := make([]liquidlane.Route, len(vaults)) + for i := range vaults { + routes[i].Adapter = vaults[i].Adapter } - ares, err := r.chain.Multicall(ctx, acalls) + authorized, err := r.ll.FilterAuthorizedRoutes(ctx, routes, executor) if err != nil { - return nil, err + return err } - for i := range out { - if i < len(ares) && ares[i].Success { - if asset, aerr := erc4626b.UnpackAsset(ares[i].ReturnData); aerr == nil { - out[i].Asset = asset - } - } - if out[i].Vault == (common.Address{}) || out[i].Asset == (common.Address{}) { - r.log.Error(errors.New("adapter vault/asset unresolved"), "recovery entry skipped until restart", - "adapter", out[i].Adapter.Hex()) - } + if missing := liquidlane.UnauthorizedAdapters(routes, authorized); len(missing) > 0 { + return errors.Errorf( + "executor %s is not authorized as direct filler for configured adapters: %v", + executor.Hex(), missing, + ) } - return out, nil + return nil } // 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 at fill time so we never -// build inputs for an unauthorized adapter. Mirrors readPermissionedAdapterInventories in -// inventories.ts (marketMaker / owner / isFiller). +// current marketMaker value (including zero) 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, ) ([]solverInventory, error) { @@ -172,86 +215,5 @@ func (r *reader) readPermissionedVaultInventories( if err != nil || len(base) == 0 { return base, err } - - // 1) marketMaker() + owner() for each candidate adapter, in one multicall. - calls := make([]chain.Call, 0, len(base)*2) - for _, inv := range base { - calls = append(calls, - chain.Call{Target: inv.Adapter, AllowFailure: true, Data: llAdapter.PackMarketMaker()}, - chain.Call{Target: inv.Adapter, AllowFailure: true, Data: llAdapter.PackOwner()}, - ) - } - res, err := r.chain.Multicall(ctx, calls) - if err != nil { - return nil, err - } - if len(res) != len(calls) { - return nil, errors.Errorf("authorization multicall: got %d results, want %d", len(res), len(calls)) - } - - type authz struct { - marketMaker, owner common.Address - resolved bool - } - auths := make([]authz, len(base)) - var fillerChecks []int // base indices needing an isFiller delegation check - for i := range base { - mm, ow := res[i*2], res[i*2+1] - if !mm.Success || !ow.Success { - continue - } - marketMaker, e1 := llAdapter.UnpackMarketMaker(mm.ReturnData) - owner, e2 := llAdapter.UnpackOwner(ow.ReturnData) - if e1 != nil || e2 != nil { - continue - } - auths[i] = authz{marketMaker: marketMaker, owner: owner, resolved: true} - if marketMaker != executor && owner != executor { - fillerChecks = append(fillerChecks, i) - } - } - - // 2) isFiller(marketMaker, executor) for the adapters not directly owned, in one multicall. - delegated := make(map[int]bool, len(fillerChecks)) - if len(fillerChecks) > 0 { - fcalls := make([]chain.Call, len(fillerChecks)) - for j, i := range fillerChecks { - fcalls[j] = chain.Call{Target: base[i].Adapter, AllowFailure: true, Data: llAdapter.PackIsFiller(auths[i].marketMaker, executor)} - } - fres, ferr := r.chain.Multicall(ctx, fcalls) - if ferr != nil { - return nil, ferr - } - for j, i := range fillerChecks { - if j < len(fres) && fres[j].Success { - if ok, derr := llAdapter.UnpackIsFiller(fres[j].ReturnData); derr == nil && ok { - delegated[i] = true - } - } - } - } - - out := make([]solverInventory, 0, len(base)) - for i, inv := range base { - a := auths[i] - if a.resolved && (a.marketMaker == executor || a.owner == executor || delegated[i]) { - out = append(out, inv) - } - } - return out, nil -} - -// dedupeByAdapter keeps the first recovery vault per distinct adapter, matching the de-dup in -// readAdapterInventories (keyed by adapter). -func dedupeVaultsByAdapter(in []recoveryVault) []recoveryVault { - seen := make(map[common.Address]bool, len(in)) - out := make([]recoveryVault, 0, len(in)) - for _, v := range in { - if seen[v.Adapter] { - continue - } - seen[v.Adapter] = true - out = append(out, v) - } - return out + return r.ll.FilterAuthorized(ctx, base, executor) } diff --git a/internal/solvers/rfq/chainreader_test.go b/internal/solvers/rfq/chainreader_test.go new file mode 100644 index 00000000..241603c1 --- /dev/null +++ b/internal/solvers/rfq/chainreader_test.go @@ -0,0 +1,119 @@ +package rfq + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidgreedy "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies/greedy" +) + +func TestApplyResolvedQuoteAdaptersPreservesIndependentCapacity(t *testing.T) { + adapterA := common.HexToAddress("0x00000000000000000000000000000000000000a1") + adapterB := common.HexToAddress("0x00000000000000000000000000000000000000b2") + vaultA := common.HexToAddress("0x0000000000000000000000000000000000000011") + vaultB := common.HexToAddress("0x0000000000000000000000000000000000000022") + inventory := []solverInventory{ + testInventory(adapterA, tIn, tOut, big.NewInt(100), big.NewInt(1)), + testInventory(adapterB, tIn, tOut, big.NewInt(100), big.NewInt(1)), + } + + resolved, err := applyResolvedQuoteAdapters(1, inventory, resolvedQuoteAdapters([]recoveryVault{ + {Adapter: adapterA, Vault: vaultA, TokenOut: tOut, TokenOutDecimals: 6}, + {Adapter: adapterB, Vault: vaultB, TokenOut: tOut, TokenOutDecimals: 6}, + })) + if err != nil { + t.Fatalf("applyResolvedQuoteAdapters: %v", err) + } + if resolved[0].CapacityID == resolved[1].CapacityID { + t.Fatalf("independent vaults share capacity ID %q", resolved[0].CapacityID) + } + allocated := liquidgreedy.AllocateInventoryCapacity(resolved, nil, 0) + total := new(big.Int) + for _, item := range allocated { + total.Add(total, item.MaxAssets) + } + if total.Cmp(big.NewInt(200)) != 0 { + t.Fatalf("allocated capacity = %s, want 200", total) + } +} + +func TestApplyResolvedQuoteAdaptersSharesVaultCapacity(t *testing.T) { + adapterA := common.HexToAddress("0x00000000000000000000000000000000000000a1") + adapterB := common.HexToAddress("0x00000000000000000000000000000000000000b2") + vault := common.HexToAddress("0x0000000000000000000000000000000000000011") + inventory := []solverInventory{ + testInventory(adapterA, tIn, tOut, big.NewInt(100), big.NewInt(1)), + testInventory(adapterB, tIn, tOut, big.NewInt(100), big.NewInt(1)), + } + + resolved, err := applyResolvedQuoteAdapters(1, inventory, resolvedQuoteAdapters([]recoveryVault{ + {Adapter: adapterA, Vault: vault, TokenOut: tOut, TokenOutDecimals: 6}, + {Adapter: adapterB, Vault: vault, TokenOut: tOut, TokenOutDecimals: 6}, + })) + if err != nil { + t.Fatalf("applyResolvedQuoteAdapters: %v", err) + } + if resolved[0].CapacityID != resolved[1].CapacityID { + t.Fatalf("shared vault capacity IDs = %q, %q", resolved[0].CapacityID, resolved[1].CapacityID) + } + allocated := liquidgreedy.AllocateInventoryCapacity(resolved, nil, 0) + total := new(big.Int) + for _, item := range allocated { + total.Add(total, item.MaxAssets) + } + if total.Cmp(big.NewInt(100)) != 0 { + t.Fatalf("allocated capacity = %s, want shared limit 100", total) + } +} + +func TestApplyResolvedQuoteAdaptersFailsClosed(t *testing.T) { + adapter := common.HexToAddress("0x00000000000000000000000000000000000000a1") + otherAsset := common.HexToAddress("0x0000000000000000000000000000000000000099") + vault := common.HexToAddress("0x0000000000000000000000000000000000000011") + inventory := []solverInventory{ + testInventory(adapter, tIn, tOut, big.NewInt(100), big.NewInt(1)), + } + + for name, resolved := range map[string][]liquidlane.Adapter{ + "missing adapter": nil, + "asset mismatch": {{ + Adapter: adapter, + Vault: vault, + TokenOut: otherAsset, + }}, + "decimals mismatch": {{ + Adapter: adapter, Vault: vault, TokenOut: tOut, TokenOutDecimals: 18, + }}, + } { + t.Run(name, func(t *testing.T) { + if _, err := applyResolvedQuoteAdapters(1, inventory, resolvedQuoteAdapters(resolved)); err == nil { + t.Fatal("expected unresolved quote adapter error") + } + }) + } +} + +func TestUnresolvedQuoteAdaptersSkipsStartupMetadata(t *testing.T) { + known := common.HexToAddress("0x00000000000000000000000000000000000000a1") + unknown := common.HexToAddress("0x00000000000000000000000000000000000000b2") + inventory := []solverInventory{ + testInventory(known, tIn, tOut, big.NewInt(100), big.NewInt(1)), + testInventory(unknown, tIn, tOut, big.NewInt(100), big.NewInt(1)), + testInventory(unknown, tIn, tOut, big.NewInt(100), big.NewInt(1)), + } + resolved := map[common.Address]recoveryVault{ + known: { + Adapter: known, + Vault: common.HexToAddress("0x0000000000000000000000000000000000000011"), + TokenOut: tOut, + }, + } + + got := unresolvedQuoteAdapters(inventory, resolved) + if len(got) != 1 || got[0] != unknown { + t.Fatalf("unresolved adapters = %v, want only %s", got, unknown.Hex()) + } +} diff --git a/internal/solvers/rfq/config.go b/internal/solvers/rfq/config.go index 1e125abb..2dd1a8cc 100644 --- a/internal/solvers/rfq/config.go +++ b/internal/solvers/rfq/config.go @@ -1,6 +1,7 @@ package rfq import ( + "math/big" "strconv" "time" @@ -10,6 +11,7 @@ import ( "github.com/symbioticfi/vault-solver/internal/parse" "github.com/symbioticfi/vault-solver/internal/solver" + "github.com/symbioticfi/vault-solver/internal/tokenpolicy" ) // rawConfig mirrors the YAML shape; strings are parsed into typed values in parseConfig. @@ -19,11 +21,13 @@ type rawConfig struct { ListenAddr string `yaml:"listenAddr"` Executor string `yaml:"executor"` Reactor string `yaml:"reactor"` + LiquidityLens string `yaml:"liquidityLens"` PollIntervalMs int `yaml:"pollIntervalMs"` OrderLimit int `yaml:"orderLimit"` SolverMode string `yaml:"solverMode"` TokensToQuote string `yaml:"tokensToQuote"` PermissionedTokens []string `yaml:"permissionedTokens"` + MinAmountsIn map[string]string `yaml:"minAmountsIn"` Adapters []string `yaml:"adapters"` Strategy rawStrategyConfig `yaml:"strategy"` } @@ -47,6 +51,10 @@ type Config struct { Executor common.Address // Reactor is the RFQ Reactor (used at execution time); optional. Reactor common.Address + // LiquidityLens is the optional FrontendLiquidityLens address. When set, LiquidLane swappable headroom + // is read from the lens's cross-adapter deallocation-cascade estimate instead of each adapter's own + // getMaxAssets(tokenToRedeem); zero falls back to the adapter getter. + LiquidityLens common.Address // PollInterval is how often the backend is polled for open orders. PollInterval time.Duration // OrderLimit caps how many open orders are fetched per poll. @@ -57,18 +65,18 @@ type Config struct { // - internal: uses public discounts; adapters (optional) scope the QUOTE path only, while filling stays // unrestricted so discount-driven recovery legs through any advertised adapter still execute. SolverMode string - // TokensToQuote scopes which input tokens this filler quotes by class: "all" (default) quotes any, - // "permissioned" quotes only tokens in PermissionedTokens, "permissionless" quotes only tokens NOT in - // PermissionedTokens. Typically set per instance via env (e.g. tokensToQuote: ${TOKENS_TO_QUOTE}). - TokensToQuote string - // PermissionedTokens is the local set of input-token addresses treated as permissioned; the - // TokensToQuote scope is evaluated against it. Empty means no input token is permissioned. - PermissionedTokens map[common.Address]bool + // TokenPolicy scopes quoted input tokens and enforces single-route fills in permissioned mode. + TokenPolicy tokenpolicy.Policy + // MinAmountsIn is the per-input-token minimum request size, keyed by input-token address (config + // values are decimal strings in the token's BASE UNITS, e.g. "1000000000000000000" for 1e18). + // A request whose amount is strictly below its token's minimum gets no quote (HTTP 204); an amount + // equal to the minimum still quotes. A token absent from the map has no minimum. Address keys make + // the lookup checksum/case-insensitive. + MinAmountsIn map[common.Address]*big.Int // 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 fill plan when the quote-time plan isn't - // cached (e.g. after a restart). Config carries only adapter addresses; + // scoped to, and the candidate universe used to build each fresh fill plan. 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 fill-plan recovery. + // (see reader.resolveVaults) and are fixed for the adapter's lifetime. Empty disables direct fill planning. Adapters []recoveryVault Strategy StrategyConfig } @@ -84,14 +92,6 @@ const ( solverModeInternal = "internal" // public discounts API on top of all advertised adapters ) -// Input-token quote scopes (see Config.TokensToQuote): "all" quotes any input token, "permissioned" -// quotes only tokens in PermissionedTokens, "permissionless" quotes only tokens not in it. -const ( - tokensToQuoteAll = "all" - tokensToQuotePermissioned = "permissioned" - tokensToQuotePermissionless = "permissionless" -) - // Defaults applied when a field is unset. const ( defaultListenAddr = ":42073" @@ -121,10 +121,9 @@ func parseConfig(node yaml.Node) (*Config, error) { if mode != solverModeExternal && mode != solverModeInternal { return nil, errors.Errorf("solverMode: must be %q or %q, got %q", solverModeExternal, solverModeInternal, mode) } - 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) + tokenPolicy, err := tokenpolicy.Parse(raw.TokensToQuote, raw.PermissionedTokens) + if err != nil { + return nil, err } cfg := &Config{ @@ -135,22 +134,12 @@ func parseConfig(node yaml.Node) (*Config, error) { PollInterval: defaultPollInterval, OrderLimit: defaultOrderLimit, SolverMode: mode, - TokensToQuote: scope, + TokenPolicy: tokenPolicy, Strategy: StrategyConfig{ Name: parse.OrDefault(raw.Strategy.Name, defaultStrategyName), Config: raw.Strategy.Config, }, } - for i, t := range raw.PermissionedTokens { - addr, terr := parse.NonZeroAddress(t, "permissionedTokens["+strconv.Itoa(i)+"]") - if terr != nil { - return nil, terr - } - if cfg.PermissionedTokens == nil { - cfg.PermissionedTokens = make(map[common.Address]bool, len(raw.PermissionedTokens)) - } - cfg.PermissionedTokens[addr] = true - } if raw.PollIntervalMs > 0 { cfg.PollInterval = time.Duration(raw.PollIntervalMs) * time.Millisecond } @@ -162,6 +151,34 @@ func parseConfig(node yaml.Node) (*Config, error) { return nil, err } } + if raw.LiquidityLens != "" { + if cfg.LiquidityLens, err = parse.NonZeroAddress(raw.LiquidityLens, "liquidityLens"); err != nil { + return nil, err + } + } + for token, amount := range raw.MinAmountsIn { + field := `minAmountsIn["` + token + `"]` + addr, aerr := parse.NonZeroAddress(token, field) + if aerr != nil { + return nil, aerr + } + minIn, berr := parse.Big(amount, field) + if berr != nil { + return nil, berr + } + if minIn.Sign() <= 0 { // parse.Big accepts negatives; a non-positive floor is a misconfiguration + return nil, errors.Errorf("%s: must be > 0, got %q", field, amount) + } + if cfg.MinAmountsIn == nil { + cfg.MinAmountsIn = make(map[common.Address]*big.Int, len(raw.MinAmountsIn)) + } + // Keys differing only in checksum case collide into one address; reject rather than silently + // letting map order pick a winner. + if _, dup := cfg.MinAmountsIn[addr]; dup { + return nil, errors.Errorf("%s: duplicate entry for token %s", field, addr.Hex()) + } + cfg.MinAmountsIn[addr] = minIn + } for i, a := range raw.Adapters { adapter, err := parse.NonZeroAddress(a, "adapters["+strconv.Itoa(i)+"]") if err != nil { diff --git a/internal/solvers/rfq/config_test.go b/internal/solvers/rfq/config_test.go index 36c33fb4..ec6f165b 100644 --- a/internal/solvers/rfq/config_test.go +++ b/internal/solvers/rfq/config_test.go @@ -186,7 +186,7 @@ func TestParseConfig_Adapters(t *testing.T) { if v.Adapter != common.HexToAddress("0x0000000000000000000000000000000000000042") { t.Fatalf("adapter entry not parsed: %+v", v) } - if v.Vault != (common.Address{}) || v.Asset != (common.Address{}) { + if v.Vault != (common.Address{}) || v.TokenOut != (common.Address{}) { t.Fatalf("vault/asset should be unresolved before startup: %+v", v) } } diff --git a/internal/solvers/rfq/discounts_disabled_test.go b/internal/solvers/rfq/discounts_disabled_test.go index e2c423f3..13f1ac82 100644 --- a/internal/solvers/rfq/discounts_disabled_test.go +++ b/internal/solvers/rfq/discounts_disabled_test.go @@ -13,15 +13,15 @@ import ( // External-solver path (discountsEnabled false, the default): the solver never touches the discounts API. // The internal path is covered by the TestExecution_Discount* tests via newExec. -// External recovery never calls GET /discounts; with no vaults + discounts off there's no inventory to -// rebuild from, so the order fails. +// External fill planning never calls GET /discounts; with no vaults + discounts off there's no inventory, +// so the order fails. func TestExecution_DiscountsDisabled_RecoverySkipsListDiscounts(t *testing.T) { _, be := fillFixtures(t) st := newStore(func() time.Time { return time.Unix(0, 0) }) // empty store → forces recovery be.discounts = &discountsResponse{Discounts: []discountListItem{{ DiscountID: "0x00000000000000000000000000000000000000000000000000000000000000ab", Adapter: vlt.Hex(), TokenToRedeem: tIn.Hex(), Collateral: tOut.Hex(), CollateralDecimals: 6, - MaxAssets: "10000000", MaxRate: "1000000000000000000", + Discount: "500", MaxAssets: "10000000", MaxRate: "1000000000000000000", }}} txm := &fakeTxm{result: txmanager.Result{Hash: common.HexToHash("0xdead")}} e := newExec(t, st, be, txm) @@ -44,7 +44,7 @@ func TestExecution_DiscountsDisabled_RecoverySkipsListDiscounts(t *testing.T) { } } -// A cached discount leg with discounts off fails closed (terminal, no tx) and never calls POST /discounts. +// A discount leg with discounts off fails closed (terminal, no tx) and never calls POST /discounts. func TestExecution_DiscountsDisabled_FillFailsClosed(t *testing.T) { st, be := fillFixtures(t) h := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000ab") diff --git a/internal/solvers/rfq/execution.go b/internal/solvers/rfq/execution.go index 77aff716..35188293 100644 --- a/internal/solvers/rfq/execution.go +++ b/internal/solvers/rfq/execution.go @@ -10,7 +10,10 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" "github.com/go-errors/errors" "github.com/go-logr/logr" + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" + "github.com/symbioticfi/vault-solver/internal/tokenpolicy" "github.com/symbioticfi/vault-solver/api/bindings/rfq/executor" "github.com/symbioticfi/vault-solver/internal/txmanager" @@ -50,10 +53,11 @@ type executionService struct { orderLimit int vaults []recoveryVault whitelist adapterWhitelist // nil disables adapter filtering - discountsEnabled bool // false (external solver) skips the backend discounts API entirely + tokenPolicy tokenpolicy.Policy + discountsEnabled bool // false (external solver) skips the backend discounts API entirely backend orderBackend store *store - reader recoveryReader + reader fillReader strategy types.Strategy txm txSender log logr.Logger @@ -63,14 +67,17 @@ type executionService struct { inflight map[string]bool } -// recoveryReader is the on-chain surface used to assemble fill-time strategy inputs. -type recoveryReader interface { +// fillReader is the on-chain surface used to assemble fill-time strategy inputs. +type fillReader interface { + quoteCandidateReader readPermissionedVaultInventories( ctx context.Context, executor, tokenIn common.Address, vaults []recoveryVault, ) ([]solverInventory, error) // resolveVaults returns the config entries with Vault/Asset resolved from the adapter at startup // (config carries only adapter addresses). resolveVaults(ctx context.Context, vaults []recoveryVault) ([]recoveryVault, error) + setQuoteAdapters(resolved []recoveryVault) + validateDirectAuthorization(ctx context.Context, executor common.Address, vaults []recoveryVault) error } func (e *executionService) run(ctx context.Context, interval time.Duration) { @@ -145,31 +152,21 @@ func (e *executionService) submitOrder(ctx context.Context, orderID string) { e.reconcileTerminalStatus(ctx, orderID) return } - if exec.filler != e.executor { - e.fail(orderID, "backend assigned a different filler") - return - } - order, err := decodeOrder(exec.encodedOrder) if err != nil { e.fail(orderID, "decode order: "+err.Error()) return } + outputToken, required, err := executableOrderTerms(exec, order, e.executor) + if err != nil { + e.fail(orderID, "validate order: "+err.Error()) + return + } if dl := order.Request.Deadline; dl == nil || dl.Int64() <= e.now().Unix() { // Skip an already-expired order rather than spend gas on a fill the Reactor will revert. e.fail(orderID, "order deadline has passed") return } - outputToken, ok := singleOutputToken(exec.outputs) - if !ok { - e.fail(orderID, "only single output-token orders are supported") - return - } - required, err := sumOutputs(exec.outputs) - if err != nil { - e.fail(orderID, "sum outputs: "+err.Error()) - return - } selected, err := e.buildFillPlan(ctx, exec, order, outputToken, required) if err != nil || selected == nil { @@ -252,8 +249,8 @@ func (e *executionService) reconcileTerminalStatus(ctx context.Context, orderID } // 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. +// strategy owns route economics; the solver assembles the fresh snapshot and enforces solver-owned +// structural constraints on the returned plan. func (e *executionService) buildFillPlan( ctx context.Context, exec *executable, @@ -279,8 +276,24 @@ func (e *executionService) buildFillPlan( RequestID: exec.quoteID, QuoteID: exec.quoteID, TokenIn: order.Request.TokenIn, TokenOut: outputToken, Amount: order.Request.AmountIn, } - input := newFillInput(e.chainID, e.executor, req, inv, required, e.now()) - return e.strategy.BuildFillPlan(ctx, input) + requireSingleRoute := e.tokenPolicy.RequiresSingleRoute(req.TokenIn) + var candidates []liquidlane.QuoteCandidate + if len(inv) > 0 { + var err error + candidates, err = e.reader.readQuoteCandidates(ctx, inv, req.TokenIn, req.TokenOut, req.Amount) + if err != nil { + return nil, errors.Errorf("fill: read LiquidLane candidates: %w", err) + } + } + input := newFillInput(e.chainID, e.executor, req, candidates, required, requireSingleRoute, e.now()) + plan, err := e.strategy.BuildFillPlan(ctx, input) + if err != nil || plan == nil { + return plan, err + } + if err := validateSingleRoute(input.RequireSingleRoute, len(plan.Legs)); err != nil { + return nil, errors.Errorf("fill: strategy: %w", err) + } + return plan, nil } // buildDiscountSwapInputs resolves each discount leg's fresh signed discount from the backend and @@ -301,7 +314,22 @@ func (e *executionService) buildDiscountSwapInputs( if err != nil { return nil, errors.Errorf("resolve discount %s: %w", leg.DiscountID.Hex(), err) } - dsi, err := toDiscountSwapInput(resolved, leg, e.executor) + parsed, err := discounts.ParseSigned(resolved) + if err != nil { + return nil, errors.Errorf("discount: %w", err) + } + if parsed.Adapter != leg.Adapter { + return nil, errors.Errorf( + "%w: resolved %s, leg %s", errDiscountAdapterMismatch, parsed.Adapter.Hex(), leg.Adapter.Hex(), + ) + } + if err := discounts.ValidateSelection(parsed, discounts.Selection{ + DiscountID: *leg.DiscountID, + Adapter: leg.Adapter, TokenIn: selected.TokenIn, + }, e.now()); err != nil { + return nil, errors.Errorf("discount: %w", err) + } + dsi, err := toDiscountSwapInput(parsed, leg, e.executor) if err != nil { return nil, err } @@ -310,47 +338,50 @@ func (e *executionService) buildDiscountSwapInputs( return out, nil } -// discountInventories fetches offered discounts and, for strategy recovery, turns those redeemable +// discountInventories fetches offered discounts and turns those redeemable // against tokenIn into discount-leg candidates: keep discounts whose adapter is whitelisted, whose -// tokenToRedeem == tokenIn, and whose adapter is not already permissioned (the asset==tokenOut check -// is left to the evaluator). +// tokenToRedeem == tokenIn, and whose adapter is not already permissioned. Solver-side candidate +// normalization later filters collateral to the order's tokenOut. func (e *executionService) discountInventories( ctx context.Context, tokenIn common.Address, direct []solverInventory, ) []solverInventory { resp, err := e.backend.listDiscounts(ctx) if err != nil { - e.log.Error(err, "recover: list discounts") + e.log.Error(err, "fill: list discounts") return nil } seen := make(map[common.Address]bool, len(direct)) for _, d := range direct { seen[d.Adapter] = true } + now := e.now() var out []solverInventory - for _, d := range resp.Discounts { - if !common.IsHexAddress(d.Adapter) || !common.IsHexAddress(d.TokenToRedeem) || !common.IsHexAddress(d.Collateral) { - continue - } - if common.HexToAddress(d.TokenToRedeem) != tokenIn { + offers, issues := discounts.LiveOffers(resp, now) + for _, issue := range issues { + e.log.V(1).Info( + "recover: skip invalid discount", "discountId", issue.DiscountID, "error", issue.Err.Error(), + ) + } + for _, offer := range offers { + if offer.TokenToRedeem != tokenIn { continue } - adapter := common.HexToAddress(d.Adapter) + adapter := offer.Adapter if !e.whitelist.allows(adapter) { continue } if seen[adapter] { continue } - maxOut, ok1 := new(big.Int).SetString(d.MaxAssets, 10) - maxRate, ok2 := new(big.Int).SetString(d.MaxRate, 10) - if !ok1 || !ok2 { - continue - } - h := common.HexToHash(d.DiscountID) - out = append(out, solverInventory{ - Adapter: adapter, Asset: common.HexToAddress(d.Collateral), AssetDecimals: d.CollateralDecimals, - MaxAssets: maxOut, MaxRate: maxRate, DiscountID: &h, - }) + route := liquidlane.NewRoute( + e.chainID, adapter, common.Address{}, tokenIn, offer.Collateral, 0, offer.CollateralDecimals, + ) + // The discounts API does not expose the backing vault. Keep unknown adapters in independent + // capacity domains instead of making address(0) look like one shared vault. + route.CapacityID = liquidlane.CapacityID(route.ID) + out = append(out, liquidlane.DiscountInventory( + route, offer.MaxAssets, offer.MaxRate, offer.DiscountID, time.Unix(offer.Deadline, 0), + )) } return out } @@ -361,54 +392,31 @@ func (e *executionService) discountInventories( var errDiscountAdapterMismatch = errors.New("resolved discount adapter does not match the strategy leg adapter") // errDiscountsDisabled marks a discount leg seen while discounts are off (external solver). Terminal — -// fail the order, no tx. Defensive: a restart (needed to change config) wipes the cache, so it's unreachable. +// fail the order, no tx. Defensive: the external profile never advertises discount candidates. var errDiscountsDisabled = errors.New("discount leg present but discounts are disabled") // toDiscountSwapInput converts a resolved signed discount + its strategy leg into the Executor input. func toDiscountSwapInput( - r *resolveDiscountResponse, leg fillLeg, recipient common.Address, + parsed *discounts.Signed, leg fillLeg, recipient common.Address, ) (executor.IReactorDiscountSwapInput, error) { - d := r.Discount - for _, a := range []string{d.Adapter, d.TokenToRedeem, d.Signer, d.Protocol} { - if !common.IsHexAddress(a) { - return executor.IReactorDiscountSwapInput{}, errors.Errorf("discount: invalid address %q", a) - } - } - if common.HexToAddress(d.Adapter) != leg.Adapter { - return executor.IReactorDiscountSwapInput{}, errors.Errorf( - "%w: resolved %s, leg %s", errDiscountAdapterMismatch, d.Adapter, leg.Adapter.Hex()) - } - discount, ok := new(big.Int).SetString(d.Discount, 10) - if !ok { - return executor.IReactorDiscountSwapInput{}, errors.Errorf("discount: invalid amount %q", d.Discount) - } - nonce, err := hexutil.DecodeBig(d.Nonce) - if err != nil { - return executor.IReactorDiscountSwapInput{}, errors.Errorf("discount: invalid nonce %q: %w", d.Nonce, err) - } - signerSig, err := hexutil.Decode(r.SignerSignature) - if err != nil { - return executor.IReactorDiscountSwapInput{}, errors.Errorf("discount: signerSignature: %w", err) - } - protocolSig, err := hexutil.Decode(r.ProtocolSignature) - if err != nil { - return executor.IReactorDiscountSwapInput{}, errors.Errorf("discount: protocolSignature: %w", err) + if parsed == nil { + return executor.IReactorDiscountSwapInput{}, errors.New("discount: resolved discount is nil") } // Mirrors buildDiscountSwapInputs in discounts.ts: the outer adapter comes from the resolved // discount's adapter, the inner Discount no longer carries the vault field, and the input drops // amountOut. return executor.IReactorDiscountSwapInput{ - Adapter: common.HexToAddress(d.Adapter), + Adapter: parsed.Adapter, DiscountSwap: executor.ILiquidLaneAdapterDiscountSwap{ Discount: executor.ILiquidLaneAdapterDiscount{ - TokenToRedeem: common.HexToAddress(d.TokenToRedeem), - Discount: discount, Signer: common.HexToAddress(d.Signer), Protocol: common.HexToAddress(d.Protocol), - Nonce: nonce, Deadline: big.NewInt(d.Deadline), + TokenToRedeem: parsed.Terms.TokenToRedeem, + Discount: parsed.Terms.Discount, Signer: parsed.Terms.Signer, Protocol: parsed.Terms.Protocol, + Nonce: parsed.Terms.Nonce, Deadline: parsed.Terms.Deadline, }, - SignerSignature: signerSig, - ProtocolDeadline: big.NewInt(r.ProtocolDeadline), + SignerSignature: parsed.SignerSignature, + ProtocolDeadline: parsed.ProtocolDeadline, }, - ProtocolSignature: protocolSig, + ProtocolSignature: parsed.ProtocolSignature, Recipient: recipient, AmountIn: new(big.Int).Set(leg.AmountIn), }, nil @@ -475,7 +483,42 @@ func isHash32(s string) bool { return err == nil && len(b) == 32 } -func singleOutputToken(outputs []backendOut) (common.Address, bool) { +func executableOrderTerms( + exec *executable, + order executor.IReactorOrder, + expectedFiller common.Address, +) (common.Address, *big.Int, error) { + if order.Filler != expectedFiller { + return common.Address{}, nil, errors.New("signed order assigns a different filler") + } + if exec.filler != order.Filler { + return common.Address{}, nil, errors.New("backend filler does not match signed order") + } + if order.Request.TokenIn == (common.Address{}) || order.Request.AmountIn == nil || order.Request.AmountIn.Sign() <= 0 { + return common.Address{}, nil, errors.New("signed order has invalid input") + } + if order.Request.Deadline == nil || !order.Request.Deadline.IsInt64() || + order.Request.Deadline.Sign() <= 0 { + return common.Address{}, nil, errors.New("signed order has invalid deadline") + } + if exec.deadline != order.Request.Deadline.Int64() { + return common.Address{}, nil, errors.New("backend deadline does not match signed order") + } + token, ok := singleOrderOutputToken(order.Outputs) + if !ok { + return common.Address{}, nil, errors.New("only single output-token orders are supported") + } + required, err := sumOrderOutputs(order.Outputs) + if err != nil { + return common.Address{}, nil, err + } + if err := matchBackendOutputs(exec.outputs, order.Outputs); err != nil { + return common.Address{}, nil, err + } + return token, required, nil +} + +func singleOrderOutputToken(outputs []executor.IReactorOutput) (common.Address, bool) { if len(outputs) == 0 { return common.Address{}, false } @@ -485,20 +528,43 @@ func singleOutputToken(outputs []backendOut) (common.Address, bool) { return common.Address{}, false } } - if !common.IsHexAddress(token) { + if token == (common.Address{}) { return common.Address{}, false } - return common.HexToAddress(token), true + return token, true } -func sumOutputs(outputs []backendOut) (*big.Int, error) { +func sumOrderOutputs(outputs []executor.IReactorOutput) (*big.Int, error) { total := new(big.Int) - for _, o := range outputs { - amt, ok := new(big.Int).SetString(o.Amount, 10) - if !ok { - return nil, errors.Errorf("invalid output amount %q", o.Amount) + for i, output := range outputs { + if output.Amount == nil || output.Amount.Sign() <= 0 { + return nil, errors.Errorf("signed order output %d has invalid amount", i) + } + if output.Recipient == (common.Address{}) { + return nil, errors.Errorf("signed order output %d has invalid recipient", i) } - total.Add(total, amt) + total.Add(total, output.Amount) } return total, nil } + +func matchBackendOutputs(backend []backendOut, signed []executor.IReactorOutput) error { + if len(backend) != len(signed) { + return errors.New("backend outputs do not match signed order") + } + for i, output := range backend { + if !common.IsHexAddress(output.Token) || !common.IsHexAddress(output.Recipient) { + return errors.Errorf("backend output %d has invalid address", i) + } + amount, ok := new(big.Int).SetString(output.Amount, 10) + if !ok || amount.Sign() <= 0 { + return errors.Errorf("backend output %d has invalid amount", i) + } + if common.HexToAddress(output.Token) != signed[i].Token || + common.HexToAddress(output.Recipient) != signed[i].Recipient || + amount.Cmp(signed[i].Amount) != 0 { + return errors.Errorf("backend output %d does not match signed order", i) + } + } + return nil +} diff --git a/internal/solvers/rfq/execution_test.go b/internal/solvers/rfq/execution_test.go index 0257f6e6..5c24c0b3 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/liquidlane" "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" "github.com/symbioticfi/vault-solver/internal/txmanager" @@ -50,8 +51,24 @@ func (f *fakeBackend) listDiscounts(context.Context) (*discountsResponse, error) // 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 { - permInv []solverInventory - permErr error + permInv []solverInventory + permErr error + authErr error + authCalls int + setCalls int + quoteOut map[common.Address]*big.Int +} + +func (f *fakeRecoveryReader) readQuoteCandidates( + ctx context.Context, + inventory []solverInventory, + tokenIn common.Address, + tokenOut common.Address, + amountIn *big.Int, +) ([]liquidlane.QuoteCandidate, error) { + return (&fakeQuoteCandidateReader{out: f.quoteOut}).readQuoteCandidates( + ctx, inventory, tokenIn, tokenOut, amountIn, + ) } func (f *fakeRecoveryReader) readPermissionedVaultInventories( @@ -64,6 +81,15 @@ func (f *fakeRecoveryReader) resolveVaults(_ context.Context, vaults []recoveryV return vaults, nil } +func (f *fakeRecoveryReader) setQuoteAdapters([]recoveryVault) { f.setCalls++ } + +func (f *fakeRecoveryReader) validateDirectAuthorization( + context.Context, common.Address, []recoveryVault, +) error { + f.authCalls++ + return f.authErr +} + type fakeTxm struct { lastData []byte result txmanager.Result @@ -168,6 +194,22 @@ func TestExecution_DirectFillHappyPath(t *testing.T) { } } +func TestExecution_RejectsBackendOutputMismatch(t *testing.T) { + st, be := fillFixtures(t) + be.executable.Outputs[0].Amount = "899999" + txm := &fakeTxm{result: txmanager.Result{Hash: common.HexToHash("0xdead")}} + e := newExec(t, st, be, txm) + + e.syncOnce(context.Background()) + + if rec := st.order("o1"); rec == nil || rec.Status != statusFailed { + t.Fatalf("status = %v, want failed", rec) + } + if len(txm.lastData) != 0 { + t.Fatal("fill transaction was sent for inconsistent backend metadata") + } +} + func TestExecution_RevertMarksFailed(t *testing.T) { st, be := fillFixtures(t) txm := &fakeTxm{result: txmanager.Result{Hash: common.HexToHash("0xdead"), Err: errors.New("tx reverted on-chain")}} @@ -184,6 +226,7 @@ func TestExecution_DiscountFill(t *testing.T) { st, be := fillFixtures(t) h := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000ab") be.discount = &resolveDiscountResponse{ + DiscountID: h.Hex(), Discount: discountTerms{ Adapter: vlt.Hex(), TokenToRedeem: tIn.Hex(), Discount: "500", Signer: "0x00000000000000000000000000000000000000a1", @@ -222,9 +265,11 @@ func TestExecution_DiscountOnlyRecovery_EmptyVaults(t *testing.T) { be.discounts = &discountsResponse{Discounts: []discountListItem{{ DiscountID: h.Hex(), Adapter: vlt.Hex(), TokenToRedeem: tIn.Hex(), Collateral: tOut.Hex(), CollateralDecimals: 6, + Discount: "500", Deadline: 4_102_444_800, MaxAssets: "10000000", MaxRate: "1000000000000000000", // 1e7 liquidity, rate 1.0 → 1000000 out ≥ 900000 required }}} be.discount = &resolveDiscountResponse{ + DiscountID: h.Hex(), Discount: discountTerms{ Adapter: vlt.Hex(), TokenToRedeem: tIn.Hex(), Discount: "500", Signer: "0x00000000000000000000000000000000000000a1", @@ -237,8 +282,8 @@ func TestExecution_DiscountOnlyRecovery_EmptyVaults(t *testing.T) { e := newExec(t, st, be, txm) // 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.reader = &fakeRecoveryReader{quoteOut: map[common.Address]*big.Int{tOut: big.NewInt(500000)}} + e.strategy = newDefaultTestStrategy() e.syncOnce(context.Background()) @@ -259,6 +304,7 @@ func TestExecution_DiscountAdapterMismatchFails(t *testing.T) { // different adapter — the fill must be aborted without a tx. h := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000ab") be.discount = &resolveDiscountResponse{ + DiscountID: h.Hex(), Discount: discountTerms{ Adapter: "0x00000000000000000000000000000000000000aa", // not the quoted leg's adapter TokenToRedeem: tIn.Hex(), Discount: "500", @@ -305,9 +351,11 @@ func TestExecution_DiscountInventoriesWhitelist(t *testing.T) { rogue := common.HexToAddress("0x00000000000000000000000000000000000000aa") be := &fakeBackend{discounts: &discountsResponse{Discounts: []discountListItem{ {DiscountID: listedID, Adapter: vlt.Hex(), TokenToRedeem: tIn.Hex(), Collateral: tOut.Hex(), - CollateralDecimals: 6, MaxRate: "1000000000000000000", MaxAssets: "10000000"}, + CollateralDecimals: 6, Discount: "500", Deadline: 4_102_444_800, + MaxRate: "1000000000000000000", MaxAssets: "10000000"}, {DiscountID: rogueID, Adapter: rogue.Hex(), TokenToRedeem: tIn.Hex(), Collateral: tOut.Hex(), - CollateralDecimals: 6, MaxRate: "2000000000000000000", MaxAssets: "10000000"}, + CollateralDecimals: 6, Discount: "500", Deadline: 4_102_444_800, + MaxRate: "2000000000000000000", MaxAssets: "10000000"}, }}} st := newStore(func() time.Time { return time.Unix(0, 0) }) @@ -324,6 +372,25 @@ func TestExecution_DiscountInventoriesWhitelist(t *testing.T) { if len(out) != 2 { t.Fatalf("unfiltered discountInventories = %d entries, want 2", len(out)) } + if out[0].CapacityID == out[1].CapacityID { + t.Fatalf("unknown discount vaults share capacity id %q", out[0].CapacityID) + } +} + +func TestExecution_DiscountInventoriesSkipsExpired(t *testing.T) { + be := &fakeBackend{discounts: &discountsResponse{Discounts: []discountListItem{{ + DiscountID: "0x00000000000000000000000000000000000000000000000000000000000000a1", + Adapter: vlt.Hex(), TokenToRedeem: tIn.Hex(), Collateral: tOut.Hex(), + CollateralDecimals: 6, Discount: "500", Deadline: 1, + MaxRate: "1000000000000000000", MaxAssets: "10000000", + }}}} + st := newStore(func() time.Time { return time.Unix(2, 0) }) + e := newExec(t, st, be, &fakeTxm{}) + e.now = func() time.Time { return time.Unix(2, 0) } + e.whitelist = buildAdapterWhitelist(true, []recoveryVault{{Adapter: vlt}}) + if out := e.discountInventories(context.Background(), tIn, nil); len(out) != 0 { + t.Fatalf("expired discount inventories = %+v", out) + } } func TestExecution_MissingFillPlanFails(t *testing.T) { @@ -342,3 +409,51 @@ func TestExecution_MissingFillPlanFails(t *testing.T) { t.Fatalf("should not have sent a tx without a fill plan") } } + +func TestExecutionRecoveryMarksPermissionedScopeAsSingleRoute(t *testing.T) { + strategy := &inputRecordingStrategy{fillPlan: baseFillPlan()} + e := newExec(t, newStore(func() time.Time { return time.Unix(0, 0) }), &fakeBackend{}, &fakeTxm{}) + e.discountsEnabled = false + e.tokenPolicy = testPermissionedPolicy(t, tIn) + e.strategy = strategy + + plan, err := e.buildFillPlan( + t.Context(), &executable{quoteID: "q1"}, sampleOrder(), tOut, big.NewInt(900000), + ) + if err != nil { + t.Fatalf("buildFillPlan: %v", err) + } + if plan == nil { + t.Fatal("buildFillPlan returned nil") + } + if !strategy.fillInput.RequireSingleRoute { + t.Fatal("permissioned fill recovery input did not require a single route") + } +} + +func TestExecutionRejectsPermissionedScopeMultiLegFillPlan(t *testing.T) { + plan := baseFillPlan() + plan.Legs = []types.FillLeg{ + { + Adapter: vlt, AmountIn: big.NewInt(500000000000000000), AmountOut: big.NewInt(450000), + }, + { + Adapter: common.HexToAddress("0x0000000000000000000000000000000000000004"), + AmountIn: big.NewInt(500000000000000000), AmountOut: big.NewInt(450000), + }, + } + e := newExec(t, newStore(func() time.Time { return time.Unix(0, 0) }), &fakeBackend{}, &fakeTxm{}) + e.discountsEnabled = false + e.tokenPolicy = testPermissionedPolicy(t, tIn) + e.strategy = fixedFillStrategy{plan: plan} + + got, err := e.buildFillPlan( + t.Context(), &executable{quoteID: "q1"}, sampleOrder(), tOut, big.NewInt(900000), + ) + if err == nil || !strings.Contains(err.Error(), "single-route input requires exactly one leg") { + t.Fatalf("buildFillPlan error = %v, want single-route rejection", err) + } + if got != nil { + t.Fatalf("buildFillPlan = %+v, want nil", got) + } +} diff --git a/internal/solvers/rfq/gating_test.go b/internal/solvers/rfq/gating_test.go index 61def08e..dbc6bc58 100644 --- a/internal/solvers/rfq/gating_test.go +++ b/internal/solvers/rfq/gating_test.go @@ -1,40 +1,18 @@ package rfq import ( + "context" + "math/big" "testing" "github.com/ethereum/go-ethereum/common" + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" + "github.com/symbioticfi/vault-solver/internal/tokenpolicy" ) -// mGLOBAL (permissioned) and mF-ONE (permissionless) Hoodi addresses, used as token fixtures. -var ( - permissionedToken = common.HexToAddress("0x2Ee6f1A395Bce7a7c5bF1D07bAaF9F8A0828A8d3") - permissionlessToken = common.HexToAddress("0xA684911e92b8E4Dd27046331B849Bbd6dbca0fA2") -) - -func TestQuotesTokenInScope(t *testing.T) { - perm := map[common.Address]bool{permissionedToken: true} - - cases := []struct { - scope string - token common.Address - want bool - }{ - {tokensToQuoteAll, permissionedToken, true}, - {tokensToQuoteAll, permissionlessToken, true}, - {tokensToQuotePermissioned, permissionedToken, true}, - {tokensToQuotePermissioned, permissionlessToken, false}, - {tokensToQuotePermissionless, permissionedToken, false}, - {tokensToQuotePermissionless, permissionlessToken, true}, - {"", permissionlessToken, true}, // unset scope behaves like "all" - } - for _, c := range cases { - qs := "eService{tokensToQuote: c.scope, permissionedTokens: perm} - if got := qs.quotesTokenIn(c.token); got != c.want { - t.Errorf("scope=%q token=%s: got %v, want %v", c.scope, c.token.Hex(), got, c.want) - } - } -} +// mGLOBAL Hoodi address, used as a permissioned-token fixture. +var permissionedToken = common.HexToAddress("0x2Ee6f1A395Bce7a7c5bF1D07bAaF9F8A0828A8d3") func TestParseConfigTokenScope(t *testing.T) { const base = ` @@ -52,22 +30,252 @@ permissionedTokens: if err != nil { t.Fatalf("parse: %v", err) } - if cfg.TokensToQuote != tokensToQuotePermissioned { - t.Errorf("TokensToQuote = %q, want %q", cfg.TokensToQuote, tokensToQuotePermissioned) + if cfg.TokenPolicy.Scope() != tokenpolicy.Permissioned { + t.Errorf("TokenPolicy.Scope() = %q, want %q", cfg.TokenPolicy.Scope(), tokenpolicy.Permissioned) } - if !cfg.PermissionedTokens[permissionedToken] { - t.Errorf("expected mGLOBAL in PermissionedTokens") + if !cfg.TokenPolicy.RequiresSingleRoute(permissionedToken) { + t.Errorf("expected mGLOBAL to require one route") } def, err := parseCfg(t, base) if err != nil { t.Fatalf("parse default: %v", err) } - if def.TokensToQuote != tokensToQuoteAll { - t.Errorf("default TokensToQuote = %q, want %q", def.TokensToQuote, tokensToQuoteAll) + if def.TokenPolicy.Scope() != tokenpolicy.All { + t.Errorf("default token scope = %q, want %q", def.TokenPolicy.Scope(), tokenpolicy.All) } if _, err := parseCfg(t, base+"tokensToQuote: bogus\n"); err == nil { t.Errorf("expected error for invalid tokensToQuote") } } + +func TestParseConfigMinAmountsIn(t *testing.T) { + cfg, err := parseCfg(t, minimalConfig+oneAdapter+` +minAmountsIn: + "0x1204371AC0e5176f4B8c5B2F16C2Bec551b6FC1a": "100000000000000000000" + "0xaaa0008c8cf3a7dca931adaf04336a5d808c82cc": "1000000000000000000000" +`) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(cfg.MinAmountsIn) != 2 { + t.Fatalf("minAmountsIn = %d entries, want 2", len(cfg.MinAmountsIn)) + } + // Keys are addresses, so the configured checksum casing does not matter at lookup time. + got := cfg.MinAmountsIn[common.HexToAddress("0x1204371ac0e5176f4b8c5b2f16c2bec551b6fc1a")] + if got == nil || got.Cmp(mustBig(t, "100000000000000000000")) != 0 { + t.Fatalf("HYBOND minimum = %v, want 100e18", got) + } + got = cfg.MinAmountsIn[common.HexToAddress("0xAAA0008C8CF3A7Dca931adaF04336A5D808C82Cc")] + if got == nil || got.Cmp(mustBig(t, "1000000000000000000000")) != 0 { + t.Fatalf("deJAAA minimum = %v, want 1000e18", got) + } + + def, err := parseCfg(t, minimalConfig+oneAdapter) + if err != nil { + t.Fatalf("parse default: %v", err) + } + if def.MinAmountsIn != nil { + t.Fatalf("default minAmountsIn = %v, want nil (no minimums)", def.MinAmountsIn) + } +} + +func TestParseConfigMinAmountsInErrors(t *testing.T) { + cases := map[string]string{ //nolint:gosec // G101 false positive: YAML test fixtures, not credentials. + "zero address key": ` +minAmountsIn: + "0x0000000000000000000000000000000000000000": "1" +`, + "invalid address key": ` +minAmountsIn: + "not-an-address": "1" +`, + "non-numeric value": ` +minAmountsIn: + "0x1204371AC0e5176f4B8c5B2F16C2Bec551b6FC1a": "lots" +`, + "zero value": ` +minAmountsIn: + "0x1204371AC0e5176f4B8c5B2F16C2Bec551b6FC1a": "0" +`, + "negative value": ` +minAmountsIn: + "0x1204371AC0e5176f4B8c5B2F16C2Bec551b6FC1a": "-1" +`, + "same token twice in different casing": ` +minAmountsIn: + "0x1204371AC0e5176f4B8c5B2F16C2Bec551b6FC1a": "1" + "0x1204371ac0e5176f4b8c5b2f16c2bec551b6fc1a": "2" +`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + if _, err := parseCfg(t, minimalConfig+oneAdapter+body); err == nil { + t.Fatalf("expected an error for %q", name) + } + }) + } +} + +// countingStrategy delegates to the in-process default strategy while counting quote decisions, so a +// gated request can assert the strategy was never consulted. +type countingStrategy struct { + types.Strategy + + quoteCalls int +} + +func (s *countingStrategy) DecideQuote( + ctx context.Context, + input types.QuoteInput, +) (types.QuoteOutput, error) { + s.quoteCalls++ + return s.Strategy.DecideQuote(ctx, input) +} + +func TestQuoteMinAmountIn(t *testing.T) { + // validQuoteBody quotes 1e18 of tIn; the gate is evaluated against that amount. + const amountIn = "1000000000000000000" + cases := map[string]struct { + minAmountsIn map[common.Address]*big.Int + wantQuote bool + }{ + "below minimum declines": {minAmountsIn: minAmountsFor(t, tIn, "2000000000000000000")}, + "equal to minimum quotes": {minAmountsIn: minAmountsFor(t, tIn, amountIn), wantQuote: true}, + "above minimum quotes": {minAmountsIn: minAmountsFor(t, tIn, "500000000000000000"), wantQuote: true}, + "minimum for another token": {minAmountsIn: minAmountsFor(t, tOut, "2000000000000000000"), wantQuote: true}, + "no minimum configured": {wantQuote: true}, + "one wei below the minimum": {minAmountsIn: minAmountsFor(t, tIn, "1000000000000000001")}, + "one wei above the minimum": {minAmountsIn: minAmountsFor(t, tIn, "999999999999999999"), wantQuote: true}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + srv := testServer() + strategy := &countingStrategy{Strategy: newDefaultTestStrategy()} + srv.quotes.strategy = strategy + srv.quotes.minAmountsIn = tc.minAmountsIn + request := validQuoteBody() + request.Amount = amountIn + + response, err := srv.quotes.quote(t.Context(), &request) + if err != nil { + t.Fatalf("quote: %v", err) + } + if !tc.wantQuote { + if response != nil { + t.Fatalf("quote = %+v, want no quote (204)", response) + } + if strategy.quoteCalls != 0 { + t.Fatalf("strategy consulted %d times for a below-minimum request", strategy.quoteCalls) + } + return + } + if response == nil { + t.Fatal("quote declined, want a quote") + } + if strategy.quoteCalls != 1 { + t.Fatalf("strategy quote calls = %d, want 1", strategy.quoteCalls) + } + }) + } +} + +func minAmountsFor(t *testing.T, token common.Address, amount string) map[common.Address]*big.Int { + t.Helper() + return map[common.Address]*big.Int{token: mustBig(t, amount)} +} + +type inputRecordingStrategy struct { + quoteInput types.QuoteInput + fillInput types.FillInput + quoteOut types.QuoteOutput + fillPlan *types.FillPlan +} + +func (s *inputRecordingStrategy) DecideQuote( + _ context.Context, + input types.QuoteInput, +) (types.QuoteOutput, error) { + s.quoteInput = input + return s.quoteOut, nil +} + +func (s *inputRecordingStrategy) BuildFillPlan( + _ context.Context, + input types.FillInput, +) (*types.FillPlan, error) { + s.fillInput = input + return s.fillPlan, nil +} + +func TestQuoteMarksPermissionedScopeAsSingleRoute(t *testing.T) { + route := liquidlane.NewRoute(1, vlt, common.Address{}, permissionedToken, tOut, 18, 6) + strategy := &inputRecordingStrategy{quoteOut: types.QuoteOutput{ + Decision: types.DecisionQuote, + QuotedAmountOut: big.NewInt(1_000000), + Legs: []types.QuoteLeg{{ + CandidateID: string(liquidlane.NewCandidateID(route, nil)), + AmountIn: big.NewInt(1_000000000000000000), + AmountOut: big.NewInt(1_000000), + }}, + }} + srv := testServer() + srv.quotes.tokenPolicy = testPermissionedPolicy(t, permissionedToken) + srv.quotes.strategy = strategy + request := validQuoteBody() + request.TokenIn = permissionedToken.Hex() + + response, err := srv.quotes.quote(t.Context(), &request) + if err != nil { + t.Fatalf("quote: %v", err) + } + if response == nil { + t.Fatal("quote declined, want response") + } + if !strategy.quoteInput.RequireSingleRoute { + t.Fatal("permissioned quote input did not require a single route") + } + if len(strategy.quoteInput.Candidates) != 1 { + t.Fatalf("candidates = %d, want one normalized LiquidLane candidate", len(strategy.quoteInput.Candidates)) + } + candidate := strategy.quoteInput.Candidates[0] + if candidate.Route.TokenIn != permissionedToken || candidate.Route.TokenOut != tOut || + candidate.Route.TokenInDecimals != 18 || + candidate.Rate.Cmp(big.NewInt(1_000_000_000_000_000_000)) != 0 || + candidate.MaxAmountOut.Cmp(big.NewInt(10_000_000)) != 0 { + t.Fatalf("candidate = %+v, want typed current LiquidLane facts", candidate) + } +} + +func TestQuoteNormalizesDiscountRateWithInputDecimals(t *testing.T) { + strategy := &inputRecordingStrategy{quoteOut: types.QuoteOutput{Decision: types.DecisionDecline}} + srv := testServer() + srv.quotes.strategy = strategy + request := validQuoteBody() + discountID := "0x00000000000000000000000000000000000000000000000000000000000000ab" + request.Adapters[0].DiscountID = &discountID + + response, err := srv.quotes.quote(t.Context(), &request) + if err != nil || response != nil { + t.Fatalf("quote = %+v, err %v; want strategy decline", response, err) + } + if len(strategy.quoteInput.Candidates) != 1 { + t.Fatalf("candidates = %d, want one", len(strategy.quoteInput.Candidates)) + } + candidate := strategy.quoteInput.Candidates[0] + if candidate.Route.TokenInDecimals != 18 || + candidate.Rate.Cmp(big.NewInt(1_000_000_000_000_000_000)) != 0 || + candidate.MaxAmountIn.Cmp(mustBig(t, "10000000000000000000")) != 0 { + t.Fatalf("candidate = %+v, want 1:1 rate and 10-token capacity", candidate) + } +} + +func testPermissionedPolicy(t *testing.T, tokens ...common.Address) tokenpolicy.Policy { + t.Helper() + policy, err := tokenpolicy.New(tokenpolicy.Permissioned, tokens) + if err != nil { + t.Fatalf("tokenpolicy.New: %v", err) + } + return policy +} diff --git a/internal/solvers/rfq/quote.go b/internal/solvers/rfq/quote.go index d9b5a010..cde1153c 100644 --- a/internal/solvers/rfq/quote.go +++ b/internal/solvers/rfq/quote.go @@ -2,34 +2,50 @@ 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/liquidlane" + "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/tokenpolicy" ) // 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 - whitelist adapterWhitelist // nil disables adapter filtering - // tokensToQuote scopes which input tokens are quotable: "all" (default), "permissioned", or - // "permissionless" (see Config.TokensToQuote); evaluated against permissionedTokens. - tokensToQuote string - permissionedTokens map[common.Address]bool - strategy types.Strategy - log logr.Logger - now func() time.Time + chainID int64 + executor common.Address + whitelist adapterWhitelist // nil disables adapter filtering + tokenPolicy tokenpolicy.Policy + // minAmountsIn holds per-input-token minimum request sizes in base units; a token absent from the + // map (or a nil map) has no minimum. + minAmountsIn map[common.Address]*big.Int + reader quoteCandidateReader + strategy types.Strategy + log logr.Logger + now func() time.Time +} + +type quoteCandidateReader interface { + readQuoteCandidates( + ctx context.Context, + inventory []solverInventory, + tokenIn common.Address, + tokenOut common.Address, + amountIn *big.Int, + ) ([]liquidlane.QuoteCandidate, error) } // quote returns a priced quote, or nil (→ HTTP 204) when the request is well-formed but this filler -// can't quote it (wrong type/chain, no whitelisted adapter, no matching asset, or no viable -// strategy). An error is returned only for malformed input or a failed chain read. +// can't quote it (wrong type/chain, input token out of scope or below its configured minimum, no +// whitelisted adapter, no matching asset, or no viable strategy). An error is returned only for +// malformed input or a failed chain read. func (qs *quoteService) quote(ctx context.Context, q *quoteRequest) (*quoteResponse, error) { parsed, err := q.toStrategy(qs.chainID) if err != nil { @@ -39,9 +55,15 @@ func (qs *quoteService) quote(ctx context.Context, q *quoteRequest) (*quoteRespo qs.log.V(1).Info("declining quote: not quotable", "quoteId", q.QuoteID, "type", q.Type) return nil, nil } - if !qs.quotesTokenIn(parsed.req.TokenIn) { + if !qs.tokenPolicy.Allows(parsed.req.TokenIn) { qs.log.V(1).Info("declining quote: input token out of scope", - "quoteId", q.QuoteID, "tokenIn", lowerAddr(parsed.req.TokenIn), "scope", qs.tokensToQuote) + "quoteId", q.QuoteID, "tokenIn", lowerAddr(parsed.req.TokenIn), "scope", qs.tokenPolicy.Scope()) + return nil, nil + } + if minIn, ok := qs.minAmountsIn[parsed.req.TokenIn]; ok && parsed.req.Amount.Cmp(minIn) < 0 { + qs.log.V(1).Info("declining quote: input amount below configured minimum", + "quoteId", q.QuoteID, "tokenIn", lowerAddr(parsed.req.TokenIn), + "amount", parsed.req.Amount.String(), "min", minIn.String()) return nil, nil } req, inv := parsed.req, qs.whitelist.filter(parsed.inv) @@ -50,7 +72,16 @@ func (qs *quoteService) quote(ctx context.Context, q *quoteRequest) (*quoteRespo return nil, nil } - input := newQuoteInput(qs.chainID, qs.executor, req, inv, nil, qs.now()) + requireSingleRoute := qs.tokenPolicy.RequiresSingleRoute(req.TokenIn) + candidates, err := qs.reader.readQuoteCandidates(ctx, inv, req.TokenIn, req.TokenOut, req.Amount) + if err != nil { + return nil, errors.Errorf("quote: read LiquidLane candidates: %w", err) + } + if len(candidates) == 0 { + qs.log.V(1).Info("declining quote: no viable LiquidLane candidates", "quoteId", q.QuoteID) + return nil, nil + } + input := newQuoteInput(qs.chainID, qs.executor, req, candidates, nil, requireSingleRoute, qs.now()) out, err := qs.strategy.DecideQuote(ctx, input) if err != nil { return nil, errors.Errorf("quote: strategy: %w", err) @@ -59,8 +90,8 @@ func (qs *quoteService) quote(ctx context.Context, q *quoteRequest) (*quoteRespo qs.log.V(1).Info("declining quote: no viable strategy", "quoteId", q.QuoteID) return nil, nil } - if out.QuotedAmountOut == nil { - return nil, errors.New("quote: strategy returned quote without amountOut") + if _, err := strategies.FillPlanFromQuote(input, out); err != nil { + return nil, errors.Errorf("quote: strategy: %w", err) } qs.log.V(1).Info("quoted", @@ -82,17 +113,3 @@ func (qs *quoteService) quote(ctx context.Context, q *quoteRequest) (*quoteRespo // lowerAddr renders an address as lowercase hex; RFQ backend payloads use lowercase addresses. func lowerAddr(a common.Address) string { return strings.ToLower(a.Hex()) } - -// quotesTokenIn reports whether this filler's TokensToQuote scope admits the request's input token: -// "permissioned" admits only tokens in permissionedTokens, "permissionless" admits only those not in -// it, and "all" (or any unset value, for hand-built services) admits every token. -func (qs *quoteService) quotesTokenIn(tokenIn common.Address) bool { - switch qs.tokensToQuote { - case tokensToQuotePermissioned: - return qs.permissionedTokens[tokenIn] - case tokensToQuotePermissionless: - return !qs.permissionedTokens[tokenIn] - default: - return true - } -} diff --git a/internal/solvers/rfq/server_test.go b/internal/solvers/rfq/server_test.go index b0c98b35..390c810c 100644 --- a/internal/solvers/rfq/server_test.go +++ b/internal/solvers/rfq/server_test.go @@ -23,7 +23,8 @@ func testServer() *server { q := "eService{ chainID: 1, executor: execAddr, - strategy: newDefaultTestStrategy(18, map[common.Address]*big.Int{tOut: big.NewInt(1_000000)}), + reader: &fakeQuoteCandidateReader{out: map[common.Address]*big.Int{tOut: big.NewInt(1_000000)}}, + strategy: newDefaultTestStrategy(), log: logr.Discard(), now: clk, } @@ -138,6 +139,23 @@ func TestServer_QuoteWrongChainNoContent(t *testing.T) { } } +func TestServer_QuoteBelowMinAmountNoContent(t *testing.T) { + srv := testServer() + body := validQuoteBody() // 1e18 of tIn + srv.quotes.minAmountsIn = map[common.Address]*big.Int{ + tIn: mustBig(t, "2000000000000000000"), + } + if rr := do(t, srv.handler(), http.MethodPost, "/quote", testSecret, body); rr.Code != http.StatusNoContent { + t.Fatalf("below-minimum quote = %d, want 204 (body %s)", rr.Code, rr.Body.String()) + } + + // The same request at exactly the minimum is still quoted. + srv.quotes.minAmountsIn = map[common.Address]*big.Int{tIn: mustBig(t, body.Amount)} + if rr := do(t, srv.handler(), http.MethodPost, "/quote", testSecret, body); rr.Code != http.StatusOK { + t.Fatalf("at-minimum quote = %d, want 200 (body %s)", rr.Code, rr.Body.String()) + } +} + func TestServer_QuoteWhitelist(t *testing.T) { rogue := common.HexToAddress("0x00000000000000000000000000000000000000aa") rogueAdapter := quoteAdapter{ diff --git a/internal/solvers/rfq/solver.go b/internal/solvers/rfq/solver.go index e8fe2faf..47a6cda5 100644 --- a/internal/solvers/rfq/solver.go +++ b/internal/solvers/rfq/solver.go @@ -48,8 +48,8 @@ func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { chainID := deps.Chain.ChainID().Int64() log := deps.Log.WithName(Name) st := newStore(time.Now) - rdr := newReader(deps.Chain, log) - quoteStrategy, err := newStrategy(cfg.Strategy, deps.Chain, log) + rdr := newReader(deps.Chain, log, cfg.LiquidityLens) + quoteStrategy, err := newStrategy(cfg.Strategy) if err != nil { return nil, err } @@ -90,14 +90,15 @@ func buildServices( execWhitelist := buildAdapterWhitelist(cfg.restrictsToAdapters(), cfg.Adapters) quotes := "eService{ - chainID: chainID, - executor: cfg.Executor, - whitelist: quoteWhitelist, - tokensToQuote: cfg.TokensToQuote, - permissionedTokens: cfg.PermissionedTokens, - strategy: quoteStrategy, - log: log, - now: time.Now, + chainID: chainID, + executor: cfg.Executor, + whitelist: quoteWhitelist, + tokenPolicy: cfg.TokenPolicy, + minAmountsIn: cfg.MinAmountsIn, + reader: rdr, + strategy: quoteStrategy, + log: log, + now: time.Now, } exec := &executionService{ chainID: chainID, @@ -105,6 +106,7 @@ func buildServices( orderLimit: cfg.OrderLimit, vaults: cfg.Adapters, whitelist: execWhitelist, + tokenPolicy: cfg.TokenPolicy, discountsEnabled: cfg.usesDiscounts(), backend: newBackendClient(cfg.BackendURL), store: st, @@ -124,14 +126,6 @@ func (s *Solver) Name() string { return Name } // Run serves the quote HTTP API until ctx is cancelled, then shuts it down gracefully, alongside the // backend order-poll + fill loop. The filler is poll-only (no push/notify endpoint). func (s *Solver) Run(ctx context.Context) error { - s.log.Info("starting", - "listenAddr", s.cfg.ListenAddr, - "executor", s.cfg.Executor.Hex(), - "solverMode", s.cfg.SolverMode, - "adapters", len(s.cfg.Adapters), - "backendUrl", s.cfg.BackendURL, - ) - // Resolve each recovery adapter's vault + collateral once at startup (config carries only adapter // addresses; both are fixed for the adapter's lifetime) and hand the resolved set to recovery. Runs // before the poll loop and the quote server, so there's no concurrent reader of exec.vaults. A @@ -139,11 +133,31 @@ func (s *Solver) Run(ctx context.Context) error { if len(s.cfg.Adapters) > 0 { resolved, err := s.exec.reader.resolveVaults(ctx, s.cfg.Adapters) if err != nil { - return errors.Errorf("rfq: resolve recovery vaults: %w", err) + startupErr := errors.Errorf("rfq: resolve recovery vaults: %w", err) + s.log.Error(startupErr, "adapter resolution failed", + "solverMode", s.cfg.SolverMode, "executor", s.cfg.Executor.Hex(), "adapters", s.cfg.Adapters) + return startupErr } s.exec.vaults = resolved + s.exec.reader.setQuoteAdapters(resolved) + if s.cfg.restrictsToAdapters() { + if err := s.exec.reader.validateDirectAuthorization(ctx, s.cfg.Executor, resolved); err != nil { + startupErr := errors.Errorf("rfq: validate direct authorization: %w", err) + s.log.Error(startupErr, "external adapter authorization failed", + "solverMode", s.cfg.SolverMode, "executor", s.cfg.Executor.Hex(), "adapters", s.cfg.Adapters) + return startupErr + } + } } + s.log.Info("starting", + "listenAddr", s.cfg.ListenAddr, + "executor", s.cfg.Executor.Hex(), + "solverMode", s.cfg.SolverMode, + "adapters", len(s.cfg.Adapters), + "backendUrl", s.cfg.BackendURL, + ) + httpSrv := &http.Server{ Addr: s.cfg.ListenAddr, Handler: s.server.handler(), diff --git a/internal/solvers/rfq/solver_test.go b/internal/solvers/rfq/solver_test.go index 1c25e99e..19f3fdee 100644 --- a/internal/solvers/rfq/solver_test.go +++ b/internal/solvers/rfq/solver_test.go @@ -2,13 +2,50 @@ package rfq import ( "math/big" + "strings" "testing" "time" "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" "github.com/go-logr/logr" + "github.com/go-logr/logr/funcr" ) +func TestRunExternalFailsForUnauthorizedConfiguredAdapter(t *testing.T) { + adapter := common.HexToAddress("0x0000000000000000000000000000000000000042") + executor := common.HexToAddress("0x0000000000000000000000000000000000000010") + rdr := &fakeRecoveryReader{authErr: errors.New("adapter is not authorized")} + var logs []string + s := &Solver{ + cfg: &Config{ + Executor: executor, + SolverMode: solverModeExternal, + Adapters: []recoveryVault{{Adapter: adapter}}, + }, + exec: &executionService{reader: rdr}, + log: funcr.NewJSON(func(entry string) { logs = append(logs, entry) }, funcr.Options{}), + } + + err := s.Run(t.Context()) + if err == nil || !strings.Contains(err.Error(), "rfq: validate direct authorization: adapter is not authorized") { + t.Fatalf("Run() error = %v, want direct authorization startup failure", err) + } + if rdr.authCalls != 1 { + t.Fatalf("authorization checks = %d, want 1", rdr.authCalls) + } + if rdr.setCalls != 1 { + t.Fatalf("quote metadata assignments = %d, want 1 before server start", rdr.setCalls) + } + logged := strings.Join(logs, "\n") + if !strings.Contains(logged, "external adapter authorization failed") || + !strings.Contains(logged, "adapter is not authorized") || + !strings.Contains(logged, executor.Hex()) || + !strings.Contains(logged, `"error"`) { + t.Fatalf("authorization failure was not logged with its reason: %s", logged) + } +} + // TestBuildServices_WhitelistWiring pins that solver mode actually reaches both services with the correct // per-path scoping: reverting the factory wiring (leaving a whitelist nil) would silently let a filler // quote/fill through adapters it isn't scoped to. The quote and execution paths scope independently — @@ -18,9 +55,10 @@ func TestBuildServices_WhitelistWiring(t *testing.T) { listed := common.HexToAddress("0x0000000000000000000000000000000000000042") rogue := common.HexToAddress("0x00000000000000000000000000000000000000aa") cfg := &Config{ - BackendURL: "https://rfq-backend.example", - Executor: common.HexToAddress("0x0000000000000000000000000000000000000010"), - Adapters: []recoveryVault{{Adapter: listed}}, + BackendURL: "https://rfq-backend.example", + Executor: common.HexToAddress("0x0000000000000000000000000000000000000010"), + Adapters: []recoveryVault{{Adapter: listed}}, + TokenPolicy: testPermissionedPolicy(t, permissionedToken), } st := newStore(func() time.Time { return time.Unix(0, 0) }) @@ -40,6 +78,10 @@ func TestBuildServices_WhitelistWiring(t *testing.T) { quotes, exec := buildServices(cfg, 1, st, nil, nil, nil, logr.Discard()) scopedToConfigured(t, "quote", quotes.whitelist) scopedToConfigured(t, "execution", exec.whitelist) + if !quotes.tokenPolicy.RequiresSingleRoute(permissionedToken) || + !exec.tokenPolicy.RequiresSingleRoute(permissionedToken) { + t.Fatal("token policy was not wired to both quote and execution services") + } // 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. @@ -77,7 +119,8 @@ func TestBuildServices_InternalModeQuoteScoping(t *testing.T) { 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)}) + quotes.reader = &fakeQuoteCandidateReader{out: map[common.Address]*big.Int{tOut: big.NewInt(1_000000)}} + quotes.strategy = newDefaultTestStrategy() rogue := common.HexToAddress("0x00000000000000000000000000000000000000aa") rogueAdapter := quoteAdapter{ diff --git a/internal/solvers/rfq/strategies/default/chainreader.go b/internal/solvers/rfq/strategies/default/chainreader.go deleted file mode 100644 index 26ac3ecd..00000000 --- a/internal/solvers/rfq/strategies/default/chainreader.go +++ /dev/null @@ -1,87 +0,0 @@ -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/liquidlane.go b/internal/solvers/rfq/strategies/default/liquidlane.go new file mode 100644 index 00000000..b56c09e6 --- /dev/null +++ b/internal/solvers/rfq/strategies/default/liquidlane.go @@ -0,0 +1,138 @@ +package defaultstrategy + +import ( + "context" + + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidgreedy "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies/greedy" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" +) + +func (s *Strategy) decideQuote(_ context.Context, input types.QuoteInput) (types.QuoteOutput, error) { + if len(input.Candidates) == 0 { + return decline(), nil + } + out, err := solveQuote(input, input.Candidates) + if err != nil { + return types.QuoteOutput{}, err + } + if out == nil { + return decline(), nil + } + return *out, nil +} + +func (s *Strategy) buildFillPlan(_ context.Context, input types.FillInput) (*types.FillPlan, error) { + if len(input.Candidates) == 0 { + return nil, nil + } + task, sources, err := rfqFillTask(input, input.Candidates) + if err != nil { + return nil, err + } + solution, err := liquidgreedy.SolveFill(task) + if err != nil || solution == nil { + return nil, err + } + quotedAmountOut := solution.MaxAmountOut() + if input.RequiredAmountOut != nil && quotedAmountOut.Cmp(input.RequiredAmountOut) < 0 { + if input.RequireSingleRoute { + return nil, nil + } + return nil, errors.New("strategy output is below required amount out") + } + routes := solution.Finalize(quotedAmountOut) + if len(routes) == 0 { + return nil, nil + } + legs := make([]types.FillLeg, 0, len(routes)) + for _, route := range routes { + source, ok := sources[route.CandidateID] + if !ok { + return nil, errors.Errorf("fill candidate %q lost its RFQ source", route.CandidateID) + } + legs = append(legs, types.FillLeg{ + Adapter: source.Route.Adapter, AmountIn: route.AmountIn, AmountOut: route.ExpectedAmountOut, + MaxRate: liquidlane.CloneBig(source.Rate), DiscountID: liquidlane.CloneHash(source.DiscountID), + }) + } + return &types.FillPlan{ + QuoteID: input.QuoteID, RequestID: input.RequestID, + TokenIn: input.TokenIn, TokenOut: input.TokenOut, AmountIn: liquidlane.CloneBig(input.AmountIn), + QuotedAmountOut: quotedAmountOut, Legs: legs, + }, nil +} + +func decline() types.QuoteOutput { + return types.QuoteOutput{Decision: types.DecisionDecline, Reason: "no viable strategy"} +} + +func solveQuote( + input types.QuoteInput, + candidates []liquidlane.QuoteCandidate, +) (*types.QuoteOutput, error) { + maxRoutes := len(candidates) + inputPolicy := liquidgreedy.AbsorbUncoveredInput + if input.RequireSingleRoute { + maxRoutes = 1 + } + solution, err := liquidgreedy.SolveQuote(liquidgreedy.QuoteTask{ + ExactInput: input.AmountIn, Candidates: candidates, MaxRoutes: maxRoutes, + InputPolicy: inputPolicy, + }) + if err != nil || solution == nil { + return nil, err + } + if input.RequireSingleRoute && input.RequiredAmountOut != nil && + solution.AmountOut.Cmp(input.RequiredAmountOut) < 0 { + return nil, nil + } + legs := make([]types.QuoteLeg, 0, len(solution.Allocations)) + for _, leg := range solution.Allocations { + legs = append(legs, types.QuoteLeg{ + CandidateID: string(leg.Candidate.ID), + AmountIn: liquidlane.CloneBig(leg.AmountIn), AmountOut: liquidlane.CloneBig(leg.AmountOut), + }) + } + return &types.QuoteOutput{ + Decision: types.DecisionQuote, QuotedAmountOut: liquidlane.CloneBig(solution.AmountOut), Legs: legs, + }, nil +} + +func rfqFillTask( + input types.FillInput, + candidates []liquidlane.QuoteCandidate, +) (liquidgreedy.FillTask, map[liquidlane.CandidateID]liquidlane.QuoteCandidate, error) { + quotes := make([]liquidlane.FillQuote, 0, len(candidates)) + sources := make(map[liquidlane.CandidateID]liquidlane.QuoteCandidate, len(candidates)) + for _, candidate := range candidates { + route := candidate.Route + candidateID := liquidlane.NewCandidateID(route, candidate.DiscountID) + if candidate.ID != candidateID { + return liquidgreedy.FillTask{}, nil, errors.Errorf("candidate %q has invalid identity", candidate.ID) + } + sources[candidateID] = candidate + quotes = append(quotes, liquidlane.FillQuote{ + Inventory: liquidlane.Inventory{ + Route: route, MaxAssets: liquidlane.CloneBig(candidate.MaxAmountOut), + MaxRate: liquidlane.CloneBig(candidate.Rate), DiscountID: liquidlane.CloneHash(candidate.DiscountID), + ValidUntil: candidate.ValidUntil, + }, + AmountIn: liquidlane.CloneBig(input.AmountIn), + MaxAmountOut: liquidlane.AmountOutForRate( + input.AmountIn, candidate.Rate, route.TokenInDecimals, route.TokenOutDecimals, + ), + }) + } + maxRoutes := len(quotes) + inputPolicy := liquidgreedy.AbsorbUncoveredInput + if input.RequireSingleRoute { + maxRoutes = 1 + } + return liquidgreedy.FillTask{ + TokenIn: input.TokenIn, TokenOut: input.TokenOut, AmountIn: liquidlane.CloneBig(input.AmountIn), + Quotes: quotes, ValidAfter: input.Now, MaxRoutes: maxRoutes, InputPolicy: inputPolicy, + }, sources, nil +} diff --git a/internal/solvers/rfq/strategies/default/strategy.go b/internal/solvers/rfq/strategies/default/strategy.go index 13d52b46..043058f3 100644 --- a/internal/solvers/rfq/strategies/default/strategy.go +++ b/internal/solvers/rfq/strategies/default/strategy.go @@ -2,55 +2,34 @@ 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 -} +type Strategy struct{} //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) { +func NewFromConfig(raw yaml.Node) (types.Strategy, error) { var cfg Config if err := decodeConfig(raw, &cfg); err != nil { return nil, err } - return New(NewChainReader(deps.Chain, deps.Log)), nil + return New(), nil } -func New(pricing types.Pricing) *Strategy { - return &Strategy{pricing: pricing, now: time.Now, plans: make(map[string]cachedFillPlan)} -} +func New() *Strategy { return &Strategy{} } func decodeConfig(node yaml.Node, out any) error { if node.Kind == 0 { @@ -60,384 +39,9 @@ func decodeConfig(node yaml.Node, out any) error { } 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 + return s.decideQuote(ctx, input) } 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 { - // Keep the exact-input quote while capping output at available liquidity. The residual input - // appears as price impact instead of suppressing the quote entirely. - legs[len(legs)-1].AmountIn.Add(legs[len(legs)-1].AmountIn, remainingIn) - } - - 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 + return s.buildFillPlan(ctx, input) } diff --git a/internal/solvers/rfq/strategies/default/strategy_test.go b/internal/solvers/rfq/strategies/default/strategy_test.go index 42849899..eb40b896 100644 --- a/internal/solvers/rfq/strategies/default/strategy_test.go +++ b/internal/solvers/rfq/strategies/default/strategy_test.go @@ -1,239 +1,146 @@ package defaultstrategy import ( - "context" "math/big" "testing" "time" "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" "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...)) +func quoteCandidate( + adapter common.Address, + rate int64, + maxInput int64, + maxOutput int64, + discountID *common.Hash, +) liquidlane.QuoteCandidate { + route := liquidlane.NewRoute(1, adapter, common.HexToAddress("0x10"), tIn, tOut, 0, 0) + route.CapacityID = liquidlane.CapacityID(route.ID) + return liquidlane.QuoteCandidate{ + ID: liquidlane.NewCandidateID(route, discountID), + Route: route, + Rate: new(big.Int).Mul(big.NewInt(rate), big.NewInt(1_000_000_000_000_000_000)), + MaxAmountIn: big.NewInt(maxInput), + MaxAmountOut: big.NewInt(maxOutput), + DiscountID: liquidlane.CloneHash(discountID), } - return f.out, nil } -func baseInput(t *testing.T, candidates []types.QuoteCandidate) types.QuoteInput { - t.Helper() +func baseInput(candidates ...liquidlane.QuoteCandidate) types.QuoteInput { 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), + RequestID: "r", QuoteID: "q", ChainID: 1, + Executor: common.HexToAddress("0x0000000000000000000000000000000000000010"), + TokenIn: tIn, TokenOut: tOut, AmountIn: big.NewInt(100), + 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) +func TestStrategyQuotesNormalizedLiquidLaneCandidates(t *testing.T) { + candidate := quoteCandidate(vlt, 2, 100, 200, nil) + got, err := New().DecideQuote(t.Context(), baseInput(candidate)) 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]) + if got.Decision != types.DecisionQuote || got.QuotedAmountOut.Cmp(big.NewInt(200)) != 0 || + len(got.Legs) != 1 || got.Legs[0].CandidateID != string(candidate.ID) { + t.Fatalf("output = %+v, want one 200-output leg", got) } } -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 TestStrategyAggregatesRoutesButHonorsSingleRoute(t *testing.T) { + first := quoteCandidate(vlt, 2, 60, 120, nil) + second := quoteCandidate(common.HexToAddress("0x04"), 1, 100, 100, nil) -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) + got, err := New().DecideQuote(t.Context(), baseInput(first, second)) + if err != nil || got.Decision != types.DecisionQuote || len(got.Legs) != 2 || + got.QuotedAmountOut.Cmp(big.NewInt(160)) != 0 { + t.Fatalf("aggregate quote = %+v, err %v", got, err) } - if got.Decision != types.DecisionDecline { - t.Fatalf("decision = %q, want decline", got.Decision) + + input := baseInput(first, second) + input.RequireSingleRoute = true + got, err = New().DecideQuote(t.Context(), input) + if err != nil || got.Decision != types.DecisionQuote || len(got.Legs) != 1 || + got.QuotedAmountOut.Cmp(big.NewInt(120)) != 0 || + got.Legs[0].CandidateID != string(first.ID) { + t.Fatalf("single-route quote = %+v, err %v", got, err) } } -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 TestStrategySingleRouteQuotesCappedOutputWhenInputExceedsCapacity(t *testing.T) { + candidate := quoteCandidate(vlt, 2, 60, 120, nil) + input := baseInput(candidate) + input.RequireSingleRoute = true + + got, err := New().DecideQuote(t.Context(), input) + if err != nil || got.Decision != types.DecisionQuote || + got.QuotedAmountOut.Cmp(big.NewInt(120)) != 0 || len(got.Legs) != 1 || + got.Legs[0].AmountIn.Cmp(big.NewInt(100)) != 0 || + got.Legs[0].AmountOut.Cmp(big.NewInt(120)) != 0 { + t.Fatalf("single-route capped quote = %+v, err %v", got, err) } } -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 TestStrategyTreatsDirectAndDiscountAsRouteAlternatives(t *testing.T) { + discountID := common.HexToHash("0x01") + private := quoteCandidate(vlt, 3, 60, 180, &discountID) + direct := quoteCandidate(vlt, 2, 100, 200, nil) + + got, err := New().DecideQuote(t.Context(), baseInput(private, direct)) + if err != nil || got.Decision != types.DecisionQuote || len(got.Legs) != 1 || + got.Legs[0].CandidateID != string(direct.ID) { + t.Fatalf("quote = %+v, err %v; want full direct alternative", got, err) } } -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) +func TestBuildFillPlanUsesTypedCandidateWithoutRepricing(t *testing.T) { + discountID := common.HexToHash("0x01") + candidate := quoteCandidate(vlt, 2, 50, 100, &discountID) + input := baseInput(candidate) + input.RequiredAmountOut = big.NewInt(100) + + plan, err := New().BuildFillPlan(t.Context(), input) + if err != nil || plan == nil || len(plan.Legs) != 1 { + t.Fatalf("plan = %+v, err %v", plan, err) } - if got.Decision != types.DecisionQuote || got.QuotedAmountOut.String() != "1000000" { - t.Fatalf("output = %+v, want discount quote at maxRate", got) + leg := plan.Legs[0] + if leg.Adapter != vlt || leg.AmountIn.Cmp(big.NewInt(100)) != 0 || + leg.AmountOut.Cmp(big.NewInt(100)) != 0 || + leg.MaxRate.Cmp(big.NewInt(2_000_000_000_000_000_000)) != 0 || + leg.DiscountID == nil || *leg.DiscountID != discountID { + t.Fatalf("leg = %+v", leg) } } -func TestStrategySplitsAcrossDiscountsBestRateFirst(t *testing.T) { - betterDiscountID := common.HexToHash("0x01") - worseDiscountID := common.HexToHash("0x02") - input := baseInput(t, []types.QuoteCandidate{ - { - ID: "worse", Adapter: common.HexToAddress("0x04"), Asset: tOut, AssetDecimals: 18, - MaxAssets: mustBig(t, "2000000000000000000000"), - MaxRate: mustBig(t, "880000000000000000"), - DiscountID: &worseDiscountID, - }, - { - ID: "better", Adapter: vlt, Asset: tOut, AssetDecimals: 18, - MaxAssets: mustBig(t, "1000000000000000000000"), - MaxRate: mustBig(t, "990000000000000000"), - DiscountID: &betterDiscountID, - }, - }) - input.AmountIn = mustBig(t, "2000000000000000000000") - - got, err := New(fakePricing{out: map[common.Address]*big.Int{ - tOut: mustBig(t, "2000000000000000000000"), - }}).DecideQuote(t.Context(), input) - if err != nil { - t.Fatalf("DecideQuote: %v", err) - } - if got.Decision != types.DecisionQuote || got.QuotedAmountOut.String() != "1871111111111111111110" { - t.Fatalf("output = %+v, want best-rate-first split totaling 1871111111111111111110", got) - } - if len(got.Legs) != 2 { - t.Fatalf("legs = %d, want 2", len(got.Legs)) - } - if got.Legs[0].CandidateID != "better" || - got.Legs[0].AmountIn.String() != "1010101010101010101011" || - got.Legs[0].AmountOut.String() != "1000000000000000000000" { - t.Fatalf("first leg = %+v, want better discount saturated at maxAssets", got.Legs[0]) - } - if got.Legs[1].CandidateID != "worse" || - got.Legs[1].AmountIn.String() != "989898989898989898989" || - got.Legs[1].AmountOut.String() != "871111111111111111110" { - t.Fatalf("second leg = %+v, want worse discount to consume remaining input", got.Legs[1]) +func TestBuildFillPlanSingleRouteKeepsCappedQuoteWhenInputExceedsCapacity(t *testing.T) { + candidate := quoteCandidate(vlt, 2, 60, 120, nil) + input := baseInput(candidate) + input.RequireSingleRoute = true + input.RequiredAmountOut = big.NewInt(120) + + plan, err := New().BuildFillPlan(t.Context(), input) + if err != nil || plan == nil || plan.QuotedAmountOut.Cmp(big.NewInt(120)) != 0 || + len(plan.Legs) != 1 || plan.Legs[0].AmountIn.Cmp(big.NewInt(100)) != 0 || + plan.Legs[0].AmountOut.Cmp(big.NewInt(120)) != 0 { + t.Fatalf("single-route capped plan = %+v, err %v", plan, err) } } -func TestStrategyCapsQuoteAtAvailableAssetsWhenCapacityCannotCoverInput(t *testing.T) { - discountID := common.HexToHash("0x01") - input := baseInput(t, []types.QuoteCandidate{{ - ID: "c0", Adapter: vlt, Asset: tOut, AssetDecimals: 6, - MaxAssets: mustBig(t, "500000"), - MaxRate: mustBig(t, "1000000000000000000"), - DiscountID: &discountID, - }}) - 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() != "500000" { - t.Fatalf("output = %+v, want quote capped at maxAssets 500000", got) - } - if len(got.Legs) != 1 || - got.Legs[0].AmountIn.Cmp(input.AmountIn) != 0 || - got.Legs[0].AmountOut.String() != "500000" { - t.Fatalf("legs = %+v, want full input quoted for capped output", got.Legs) +func TestBuildFillPlanRejectsNonCanonicalCandidateID(t *testing.T) { + candidate := quoteCandidate(vlt, 2, 100, 200, nil) + candidate.ID = "wrong" + plan, err := New().BuildFillPlan(t.Context(), baseInput(candidate)) + if err == nil || plan != nil { + t.Fatalf("plan = %+v, err %v; want invalid identity rejection", plan, err) } } diff --git a/internal/solvers/rfq/strategies/fillplan.go b/internal/solvers/rfq/strategies/fillplan.go new file mode 100644 index 00000000..a3de09d9 --- /dev/null +++ b/internal/solvers/rfq/strategies/fillplan.go @@ -0,0 +1,136 @@ +package strategies + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" +) + +// FillPlanFromQuote maps a trusted RFQ decision onto current candidates and +// validates the structural invariants required by Executor calldata. +func FillPlanFromQuote(input types.QuoteInput, out types.QuoteOutput) (*types.FillPlan, error) { + if out.Decision != types.DecisionQuote { + return nil, errors.Errorf("invalid fill-plan decision %q", out.Decision) + } + if out.QuotedAmountOut == nil || out.QuotedAmountOut.Sign() <= 0 { + return nil, errors.New("quote output has invalid quotedAmountOut") + } + if len(out.Legs) == 0 { + return nil, errors.New("quote output has no legs") + } + if input.RequireSingleRoute && len(out.Legs) != 1 { + return nil, errors.New("single-route input requires exactly one leg") + } + + candidates, err := indexCandidates(input.Candidates) + if err != nil { + return nil, err + } + legs := make([]types.FillLeg, 0, len(out.Legs)) + sumIn := new(big.Int) + sumOut := new(big.Int) + seen := make(map[string]bool, len(out.Legs)) + usedRoutes := make(map[liquidlane.RouteID]bool, len(out.Legs)) + for i, leg := range out.Legs { + candidate, err := validateLeg(input.TokenOut, candidates, seen, leg, i) + if err != nil { + return nil, err + } + if usedRoutes[candidate.Route.ID] { + return nil, errors.Errorf("fill repeats physical route %q", candidate.Route.ID) + } + usedRoutes[candidate.Route.ID] = true + sumIn.Add(sumIn, leg.AmountIn) + sumOut.Add(sumOut, leg.AmountOut) + legs = append(legs, types.FillLeg{ + Adapter: candidate.Route.Adapter, + AmountIn: liquidlane.CloneBig(leg.AmountIn), + AmountOut: liquidlane.CloneBig(leg.AmountOut), + MaxRate: liquidlane.CloneBig(candidate.Rate), + DiscountID: liquidlane.CloneHash(candidate.DiscountID), + }) + } + if input.AmountIn == nil || sumIn.Cmp(input.AmountIn) != 0 { + return nil, errors.Errorf("strategy amountIn sum %s does not match request %v", 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: liquidlane.CloneBig(input.AmountIn), + QuotedAmountOut: liquidlane.CloneBig(out.QuotedAmountOut), + Legs: legs, + }, nil +} + +func indexCandidates(input []liquidlane.QuoteCandidate) (map[string]liquidlane.QuoteCandidate, error) { + candidates := make(map[string]liquidlane.QuoteCandidate, len(input)) + for _, candidate := range input { + if candidate.ID == "" { + return nil, errors.New("candidate id is empty") + } + id := string(candidate.ID) + if _, exists := candidates[id]; exists { + return nil, errors.Errorf("duplicate candidate id %q", candidate.ID) + } + if candidate.Route.ID == "" || candidate.ID != liquidlane.NewCandidateID(candidate.Route, candidate.DiscountID) { + return nil, errors.Errorf("candidate %q has invalid route", candidate.ID) + } + candidates[id] = candidate + } + return candidates, nil +} + +func validateLeg( + tokenOut common.Address, + candidates map[string]liquidlane.QuoteCandidate, + seen map[string]bool, + leg types.QuoteLeg, + index int, +) (liquidlane.QuoteCandidate, error) { + if seen[leg.CandidateID] { + return liquidlane.QuoteCandidate{}, errors.Errorf("duplicate candidate %q", leg.CandidateID) + } + seen[leg.CandidateID] = true + candidate, exists := candidates[leg.CandidateID] + if !exists { + return liquidlane.QuoteCandidate{}, errors.Errorf("unknown candidate %q", leg.CandidateID) + } + if candidate.Route.TokenOut != tokenOut { + return liquidlane.QuoteCandidate{}, errors.Errorf("candidate %q asset does not match tokenOut", leg.CandidateID) + } + if leg.AmountIn == nil || leg.AmountIn.Sign() <= 0 { + return liquidlane.QuoteCandidate{}, errors.Errorf("leg %d has invalid amountIn", index) + } + if leg.AmountOut == nil || leg.AmountOut.Sign() <= 0 { + return liquidlane.QuoteCandidate{}, errors.Errorf("leg %d has invalid amountOut", index) + } + if candidate.MaxAmountOut == nil || candidate.MaxAmountOut.Sign() <= 0 || + leg.AmountOut.Cmp(candidate.MaxAmountOut) > 0 { + return liquidlane.QuoteCandidate{}, errors.Errorf("leg %d exceeds candidate maxAmountOut", index) + } + if candidate.Rate == nil || candidate.Rate.Sign() <= 0 { + return liquidlane.QuoteCandidate{}, errors.Errorf("candidate %q has invalid rate", leg.CandidateID) + } + achievable := liquidlane.AmountOutForRate( + leg.AmountIn, + candidate.Rate, + candidate.Route.TokenInDecimals, + candidate.Route.TokenOutDecimals, + ) + if leg.AmountOut.Cmp(achievable) > 0 { + return liquidlane.QuoteCandidate{}, errors.Errorf("leg %d exceeds output achievable at candidate rate", index) + } + return candidate, nil +} diff --git a/internal/solvers/rfq/strategies/fillplan_test.go b/internal/solvers/rfq/strategies/fillplan_test.go new file mode 100644 index 00000000..34ec13fa --- /dev/null +++ b/internal/solvers/rfq/strategies/fillplan_test.go @@ -0,0 +1,139 @@ +package strategies + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" +) + +func TestFillPlanFromQuote(t *testing.T) { + tokenIn := common.HexToAddress("0x01") + tokenOut := common.HexToAddress("0x02") + adapter := common.HexToAddress("0x03") + route := liquidlane.NewRoute(1, adapter, common.HexToAddress("0x04"), tokenIn, tokenOut, 18, 18) + candidateID := liquidlane.NewCandidateID(route, nil) + input := types.QuoteInput{ + RequestID: "request", QuoteID: "quote", TokenIn: tokenIn, TokenOut: tokenOut, + AmountIn: big.NewInt(100), RequiredAmountOut: big.NewInt(90), + Candidates: []liquidlane.QuoteCandidate{{ + ID: candidateID, Route: route, Rate: big.NewInt(1_000_000_000_000_000_000), + MaxAmountIn: big.NewInt(100), MaxAmountOut: big.NewInt(100), + }}, + } + out := types.QuoteOutput{ + Decision: types.DecisionQuote, QuotedAmountOut: big.NewInt(95), + Legs: []types.QuoteLeg{{CandidateID: string(candidateID), AmountIn: big.NewInt(100), AmountOut: big.NewInt(95)}}, + } + + plan, err := FillPlanFromQuote(input, out) + if err != nil { + t.Fatalf("FillPlanFromQuote: %v", err) + } + if plan == nil || len(plan.Legs) != 1 || plan.Legs[0].Adapter != adapter || + plan.AmountIn.Cmp(input.AmountIn) != 0 || plan.QuotedAmountOut.Cmp(out.QuotedAmountOut) != 0 { + t.Fatalf("plan = %+v", plan) + } +} + +func TestFillPlanFromQuoteRejectsInconsistentDecision(t *testing.T) { + tokenIn := common.HexToAddress("0x01") + tokenOut := common.HexToAddress("0x02") + adapter := common.HexToAddress("0x03") + route := liquidlane.NewRoute(1, adapter, common.HexToAddress("0x04"), tokenIn, tokenOut, 18, 18) + candidateID := liquidlane.NewCandidateID(route, nil) + input := types.QuoteInput{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(100), + Candidates: []liquidlane.QuoteCandidate{{ + ID: candidateID, Route: route, Rate: big.NewInt(1_000_000_000_000_000_000), + MaxAmountIn: big.NewInt(100), MaxAmountOut: big.NewInt(100), + }}, + } + tests := []struct { + name string + out types.QuoteOutput + }{ + { + name: "unknown candidate", + out: types.QuoteOutput{ + Decision: types.DecisionQuote, QuotedAmountOut: big.NewInt(90), + Legs: []types.QuoteLeg{{CandidateID: "unknown", AmountIn: big.NewInt(100), AmountOut: big.NewInt(90)}}, + }, + }, + { + name: "input sum", + out: types.QuoteOutput{ + Decision: types.DecisionQuote, QuotedAmountOut: big.NewInt(90), + Legs: []types.QuoteLeg{{CandidateID: string(candidateID), AmountIn: big.NewInt(99), AmountOut: big.NewInt(90)}}, + }, + }, + { + name: "output capacity", + out: types.QuoteOutput{ + Decision: types.DecisionQuote, QuotedAmountOut: big.NewInt(101), + Legs: []types.QuoteLeg{{CandidateID: string(candidateID), AmountIn: big.NewInt(100), AmountOut: big.NewInt(101)}}, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := FillPlanFromQuote(input, test.out); err == nil { + t.Fatal("FillPlanFromQuote returned nil error") + } + }) + } +} + +func TestFillPlanFromQuoteRejectsOutputAboveCandidateRate(t *testing.T) { + tokenIn := common.HexToAddress("0x01") + tokenOut := common.HexToAddress("0x02") + route := liquidlane.NewRoute( + 1, common.HexToAddress("0x03"), common.HexToAddress("0x04"), tokenIn, tokenOut, 18, 18, + ) + candidateID := liquidlane.NewCandidateID(route, nil) + input := types.QuoteInput{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(100), + Candidates: []liquidlane.QuoteCandidate{{ + ID: candidateID, Route: route, Rate: big.NewInt(500_000_000_000_000_000), + MaxAmountIn: big.NewInt(100), MaxAmountOut: big.NewInt(100), + }}, + } + out := types.QuoteOutput{ + Decision: types.DecisionQuote, QuotedAmountOut: big.NewInt(90), + Legs: []types.QuoteLeg{{ + CandidateID: string(candidateID), AmountIn: big.NewInt(100), AmountOut: big.NewInt(90), + }}, + } + + if _, err := FillPlanFromQuote(input, out); err == nil { + t.Fatal("FillPlanFromQuote returned nil error") + } +} + +func TestFillPlanFromQuoteRejectsRepeatedRoute(t *testing.T) { + tokenIn := common.HexToAddress("0x01") + tokenOut := common.HexToAddress("0x02") + vault := common.HexToAddress("0x04") + first := liquidlane.NewRoute(1, common.HexToAddress("0x11"), vault, tokenIn, tokenOut, 18, 18) + discountID := common.HexToHash("0x01") + directID := liquidlane.NewCandidateID(first, nil) + privateID := liquidlane.NewCandidateID(first, &discountID) + input := types.QuoteInput{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(100), + Candidates: []liquidlane.QuoteCandidate{ + {ID: directID, Route: first, Rate: big.NewInt(1_000_000_000_000_000_000), MaxAmountIn: big.NewInt(100), MaxAmountOut: big.NewInt(100)}, + {ID: privateID, Route: first, Rate: big.NewInt(1_000_000_000_000_000_000), MaxAmountIn: big.NewInt(100), MaxAmountOut: big.NewInt(100), DiscountID: &discountID}, + }, + } + legs := []types.QuoteLeg{ + {CandidateID: string(directID), AmountIn: big.NewInt(50), AmountOut: big.NewInt(50)}, + {CandidateID: string(privateID), AmountIn: big.NewInt(50), AmountOut: big.NewInt(50)}, + } + out := types.QuoteOutput{Decision: types.DecisionQuote, Legs: legs, QuotedAmountOut: big.NewInt(100)} + if _, err := FillPlanFromQuote(input, out); err == nil { + t.Fatal("FillPlanFromQuote returned nil error") + } +} diff --git a/internal/solvers/rfq/strategies/registry.go b/internal/solvers/rfq/strategies/registry.go index 57bf38ac..d5fefb5d 100644 --- a/internal/solvers/rfq/strategies/registry.go +++ b/internal/solvers/rfq/strategies/registry.go @@ -5,19 +5,11 @@ import ( "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) +type Factory func(raw yaml.Node) (types.Strategy, error) var ( mu sync.RWMutex @@ -39,14 +31,14 @@ func Register(name string, f Factory) { registry[name] = f } -func New(name string, raw yaml.Node, deps Deps) (types.Strategy, error) { +func New(name string, raw yaml.Node) (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) + return f(raw) } func Registered() []string { diff --git a/internal/solvers/rfq/strategies/types/types.go b/internal/solvers/rfq/strategies/types/types.go index 6471962e..fc434a21 100644 --- a/internal/solvers/rfq/strategies/types/types.go +++ b/internal/solvers/rfq/strategies/types/types.go @@ -7,6 +7,8 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" ) type Decision string @@ -21,17 +23,9 @@ type Strategy interface { 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. +// QuoteInput is the RFQ strategy decision snapshot. The solver has already +// normalized backend inventory and current adapter reads into LiquidLane +// candidates; the strategy only decides how to allocate the request. type QuoteInput struct { RequestID string QuoteID string @@ -42,20 +36,10 @@ type QuoteInput struct { 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 + RequiredAmountOut *big.Int + RequireSingleRoute bool + Candidates []liquidlane.QuoteCandidate + Now time.Time } type QuoteOutput struct { @@ -71,25 +55,13 @@ type QuoteLeg struct { 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 -} +// FillInput has the same decision shape as QuoteInput; only the solver-owned +// lifecycle stage differs. Fill-time callers populate fresh candidates and the +// awarded RequiredAmountOut. +type FillInput = QuoteInput -// FillPlan is the execution output trusted strategies hand to the solver. The solver only translates -// this plan into Executor calldata. +// FillPlan is the execution output trusted strategies hand to the solver. The solver enforces its +// structural constraints, then translates the plan into Executor calldata. type FillPlan struct { QuoteID string RequestID string diff --git a/internal/solvers/rfq/strategies/types/wire_json.go b/internal/solvers/rfq/strategies/types/wire_json.go index 65363f0c..a7b4e169 100644 --- a/internal/solvers/rfq/strategies/types/wire_json.go +++ b/internal/solvers/rfq/strategies/types/wire_json.go @@ -13,16 +13,17 @@ import ( // 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"` + 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"` + RequireSingleRoute bool `json:"requireSingleRoute"` + Candidates []quoteCandidateJSON `json:"candidates"` + Now time.Time `json:"now"` } type quoteCandidateJSON struct { @@ -52,14 +53,16 @@ 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, + ID: string(c.ID), Adapter: c.Route.Adapter, Asset: c.Route.TokenOut, + AssetDecimals: c.Route.TokenOutDecimals, + MaxAssets: bigString(c.MaxAmountOut), MaxRate: bigString(c.Rate), 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, + RequiredAmountOut: bigString(in.RequiredAmountOut), RequireSingleRoute: in.RequireSingleRoute, + Candidates: candidates, Now: in.Now, }) } diff --git a/internal/solvers/rfq/strategies/types/wire_json_test.go b/internal/solvers/rfq/strategies/types/wire_json_test.go index d826dc6e..aa2979bf 100644 --- a/internal/solvers/rfq/strategies/types/wire_json_test.go +++ b/internal/solvers/rfq/strategies/types/wire_json_test.go @@ -8,6 +8,8 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" ) func mustBig(t *testing.T, s string) *big.Int { @@ -21,22 +23,25 @@ func mustBig(t *testing.T, s string) *big.Int { func TestQuoteInputMarshalJSONWireShape(t *testing.T) { discountID := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000ab") + route := liquidlane.Route{ + ID: "internal-only", Adapter: common.HexToAddress("0x0000000000000000000000000000000000000003"), + TokenOut: common.HexToAddress("0x0000000000000000000000000000000000000002"), TokenOutDecimals: 6, + } 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, + RequestID: "request-1", + QuoteID: "quote-1", + ChainID: 1, + Executor: common.HexToAddress("0x0000000000000000000000000000000000000010"), + TokenIn: common.HexToAddress("0x0000000000000000000000000000000000000001"), + TokenOut: common.HexToAddress("0x0000000000000000000000000000000000000002"), + AmountIn: mustBig(t, "1000000000000000000"), + RequireSingleRoute: true, + Candidates: []liquidlane.QuoteCandidate{{ + ID: "candidate-1", + Route: route, + MaxAmountOut: mustBig(t, "1000000"), + Rate: mustBig(t, "1000000000000000000"), + DiscountID: &discountID, }}, Now: time.Unix(1, 0).UTC(), } @@ -58,6 +63,10 @@ func TestQuoteInputMarshalJSONWireShape(t *testing.T) { if _, ok := raw["mode"]; ok { t.Fatalf("mode should not be part of the RFQ strategy input: %s", body) } + requireSingleRoute, ok := raw["requireSingleRoute"].(bool) + if !ok || !requireSingleRoute { + t.Fatalf("requireSingleRoute = %#v, want true", raw["requireSingleRoute"]) + } candidates, ok := raw["candidates"].([]any) if !ok || len(candidates) != 1 { t.Fatalf("candidates = %#v, want one candidate", raw["candidates"]) @@ -69,6 +78,9 @@ func TestQuoteInputMarshalJSONWireShape(t *testing.T) { if candidate["maxAssets"] != "1000000" || candidate["maxRate"] != "1000000000000000000" { t.Fatalf("candidate amounts not decimal strings: %#v", candidate) } + if _, routeExists := candidate["route"]; routeExists { + t.Fatalf("internal route leaked into webhook JSON: %#v", candidate) + } } func TestQuoteOutputUnmarshalJSONWireShape(t *testing.T) { diff --git a/internal/solvers/rfq/strategies/webhook/strategy.go b/internal/solvers/rfq/strategies/webhook/strategy.go index f874cfaa..239619bd 100644 --- a/internal/solvers/rfq/strategies/webhook/strategy.go +++ b/internal/solvers/rfq/strategies/webhook/strategy.go @@ -2,10 +2,7 @@ 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" @@ -24,7 +21,7 @@ func init() { strategies.Register(Name, NewFromConfig) } -func NewFromConfig(raw yaml.Node, _ strategies.Deps) (types.Strategy, error) { +func NewFromConfig(raw yaml.Node) (types.Strategy, error) { cfg, err := webhook.ParseConfig(raw) if err != nil { return nil, err @@ -48,73 +45,11 @@ func (s *Strategy) DecideQuote(ctx context.Context, input types.QuoteInput) (typ 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. +// BuildFillPlan delegates to the external decider against the current fill 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) + out, err := s.DecideQuote(ctx, input) 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 + return strategies.FillPlanFromQuote(input, out) } diff --git a/internal/solvers/rfq/strategy.go b/internal/solvers/rfq/strategy.go index f6bb1ffe..d93d014e 100644 --- a/internal/solvers/rfq/strategy.go +++ b/internal/solvers/rfq/strategy.go @@ -2,37 +2,22 @@ package rfq import ( "math/big" - "strconv" "time" "github.com/ethereum/go-ethereum/common" - "github.com/go-logr/logr" + "github.com/go-errors/errors" + "github.com/symbioticfi/vault-solver/internal/liquidlane" "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" ) -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}) +func newStrategy(spec StrategyConfig) (types.Strategy, error) { + return strategies.New(spec.Name, spec.Config) } -// 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 - MaxAssets *big.Int - MaxRate *big.Int - DiscountID *common.Hash // nil for a direct leg; set for a discount leg -} +// solverInventory is one LiquidLane candidate leg; RFQ maps backend adapter snapshots and fill-time +// recovery reads into the shared LiquidLane inventory shape. +type solverInventory = liquidlane.Inventory type fillLeg = types.FillLeg type fillPlan = types.FillPlan @@ -50,37 +35,23 @@ func newQuoteInput( chainID int64, executor common.Address, req strategyRequest, - inv []solverInventory, + candidates []liquidlane.QuoteCandidate, required *big.Int, + requireSingleRoute bool, now time.Time, ) 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 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, + RequestID: req.RequestID, + QuoteID: req.QuoteID, + ChainID: chainID, + Executor: executor, + TokenIn: req.TokenIn, + TokenOut: req.TokenOut, + AmountIn: liquidlane.CloneBig(req.Amount), + RequiredAmountOut: liquidlane.CloneBig(required), + RequireSingleRoute: requireSingleRoute, + Candidates: candidates, + Now: now, } } @@ -88,25 +59,16 @@ func newFillInput( chainID int64, executor common.Address, req strategyRequest, - inv []solverInventory, + candidates []liquidlane.QuoteCandidate, required *big.Int, + requireSingleRoute bool, now time.Time, ) types.FillInput { - q := newQuoteInput(chainID, executor, req, inv, required, now) - return types.FillInput(q) + return newQuoteInput(chainID, executor, req, candidates, required, requireSingleRoute, now) } - -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 +func validateSingleRoute(requireSingleRoute bool, legCount int) error { + if requireSingleRoute && legCount != 1 { + return errors.Errorf("single-route input requires exactly one leg, got %d", legCount) } - out := *h - return &out + return nil } diff --git a/internal/solvers/rfq/strategy_test.go b/internal/solvers/rfq/strategy_test.go index 393cbaff..5cd191ac 100644 --- a/internal/solvers/rfq/strategy_test.go +++ b/internal/solvers/rfq/strategy_test.go @@ -2,7 +2,6 @@ package rfq import ( "io" - "math/big" "net/http" "net/http/httptest" "strings" @@ -10,7 +9,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" - "github.com/go-logr/logr" + "github.com/symbioticfi/vault-solver/internal/liquidlane" "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies" "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" @@ -21,41 +20,36 @@ import ( func baseQuoteInput(t *testing.T) types.QuoteInput { t.Helper() + route := liquidlane.NewRoute(1, vlt, common.Address{}, tIn, tOut, 18, 6) + route.CapacityID = liquidlane.CapacityID(route.ID) 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"), + Candidates: []liquidlane.QuoteCandidate{{ + ID: liquidlane.NewCandidateID(route, nil), Route: route, + Rate: mustBig(t, "1000000000000000000"), + MaxAmountIn: mustBig(t, "1000000000000000000"), MaxAmountOut: mustBig(t, "10000000"), }}, Now: time.Unix(0, 0), } } func TestDefaultStrategyDecideQuote(t *testing.T) { - pricing := &fakeStrategyPricing{ - decimals: 18, - out: map[common.Address]*big.Int{tOut: mustBig(t, "1000000")}, - } - out, err := defaultstrategy.New(pricing).DecideQuote(t.Context(), baseQuoteInput(t)) + out, err := defaultstrategy.New().DecideQuote(t.Context(), baseQuoteInput(t)) if err != nil { t.Fatalf("DecideQuote: %v", err) } if out.Decision != types.DecisionQuote || out.QuotedAmountOut.String() != "1000000" { t.Fatalf("unexpected output: %+v", out) } - if len(out.Legs) != 1 || out.Legs[0].CandidateID != "c0" { - t.Fatalf("legs = %+v, want candidate c0", out.Legs) - } - 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()) + if len(out.Legs) != 1 || out.Legs[0].CandidateID != string(baseQuoteInput(t).Candidates[0].ID) { + t.Fatalf("legs = %+v, want normalized candidate", out.Legs) } } func TestNewStrategyUsesRegistry(t *testing.T) { - got, err := newStrategy(StrategyConfig{Name: "default"}, nil, logr.Discard()) + got, err := newStrategy(StrategyConfig{Name: "default"}) if err != nil { t.Fatalf("newStrategy default: %v", err) } @@ -68,15 +62,15 @@ func TestNewStrategyUsesRegistry(t *testing.T) { } } -func TestDefaultStrategyBuildFillPlanUsesQuoteCache(t *testing.T) { - strategy := defaultstrategy.New(&fakeStrategyPricing{ - decimals: 18, - out: map[common.Address]*big.Int{tOut: mustBig(t, "1000000")}, - }) +func TestDefaultStrategyBuildFillPlanUsesCurrentCandidates(t *testing.T) { + strategy := defaultstrategy.New() input := baseQuoteInput(t) if _, err := strategy.DecideQuote(t.Context(), input); err != nil { t.Fatalf("DecideQuote: %v", err) } + fillAdapter := common.HexToAddress("0x0000000000000000000000000000000000000004") + fillRoute := liquidlane.NewRoute(1, fillAdapter, common.Address{}, input.TokenIn, input.TokenOut, 18, 6) + fillRoute.CapacityID = liquidlane.CapacityID(fillRoute.ID) plan, err := strategy.BuildFillPlan(t.Context(), types.FillInput{ RequestID: input.RequestID, QuoteID: input.QuoteID, @@ -85,13 +79,18 @@ func TestDefaultStrategyBuildFillPlanUsesQuoteCache(t *testing.T) { TokenIn: input.TokenIn, TokenOut: input.TokenOut, AmountIn: input.AmountIn, - Now: input.Now, + Candidates: []liquidlane.QuoteCandidate{{ + ID: liquidlane.NewCandidateID(fillRoute, nil), Route: fillRoute, + Rate: mustBig(t, "1000000000000000000"), + MaxAmountIn: mustBig(t, "1000000000000000000"), MaxAmountOut: mustBig(t, "10000000"), + }}, + 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) + if plan == nil || len(plan.Legs) != 1 || plan.Legs[0].Adapter != fillAdapter { + t.Fatalf("fill plan = %+v, want current adapter %s", plan, fillAdapter) } } @@ -125,3 +124,44 @@ func TestWebhookStrategyDecodesLowerCamelResponse(t *testing.T) { t.Fatalf("unexpected webhook output: %+v", out) } } + +func TestQuoteRejectsWebhookMultiLegPlanForPermissionedScope(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), `"requireSingleRoute":true`) { + t.Fatalf("webhook request missing single-route constraint: %s", body) + } + _, _ = w.Write([]byte(`{ + "decision": "quote", + "quotedAmountOut": "1000000", + "legs": [ + {"candidateId": "candidate-0", "amountIn": "500000000000000000", "amountOut": "500000"}, + {"candidateId": "candidate-1", "amountIn": "500000000000000000", "amountOut": "500000"} + ] + }`)) + })) + defer srv.Close() + client, err := webhook.NewClient(webhook.Config{URL: srv.URL, Timeout: time.Second}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + quoteServer := testServer() + quoteServer.quotes.tokenPolicy = testPermissionedPolicy(t, tIn) + quoteServer.quotes.strategy = webhookstrategy.New(client) + request := validQuoteBody() + request.Adapters = append(request.Adapters, quoteAdapter{ + Adapter: "0x0000000000000000000000000000000000000004", Asset: tOut.Hex(), AssetDecimals: 6, + MaxAssets: "10000000", MaxRate: "1000000000000000000", + }) + + response, err := quoteServer.quotes.quote(t.Context(), &request) + if err == nil || !strings.Contains(err.Error(), "single-route input requires exactly one leg") { + t.Fatalf("quote error = %v, want single-route rejection", err) + } + if response != nil { + t.Fatalf("quote response = %+v, want nil", response) + } +} diff --git a/internal/solvers/rfq/test_helpers_test.go b/internal/solvers/rfq/test_helpers_test.go index 2e35d66b..6ddae1e7 100644 --- a/internal/solvers/rfq/test_helpers_test.go +++ b/internal/solvers/rfq/test_helpers_test.go @@ -6,6 +6,8 @@ import ( "testing" "github.com/ethereum/go-ethereum/common" + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidgreedy "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies/greedy" "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/types" defaultstrategy "github.com/symbioticfi/vault-solver/internal/solvers/rfq/strategies/default" @@ -26,26 +28,57 @@ var ( vlt = common.HexToAddress("0x0000000000000000000000000000000000000003") ) -type fakeStrategyPricing struct { - decimals int - out map[common.Address]*big.Int - queries [][]types.QuoteCandidate +type fakeQuoteCandidateReader struct { + out map[common.Address]*big.Int + inputDecimals int + queries [][]liquidlane.Route } -func (f *fakeStrategyPricing) TokenDecimals(context.Context, common.Address) (int, error) { - return f.decimals, nil +func (f *fakeQuoteCandidateReader) readQuoteCandidates( + _ context.Context, + inventory []solverInventory, + tokenIn common.Address, + tokenOut common.Address, + amount *big.Int, +) ([]liquidlane.QuoteCandidate, error) { + matching := make([]liquidlane.Inventory, 0, len(inventory)) + inputDecimals := f.inputDecimals + if inputDecimals == 0 { + inputDecimals = 18 + } + for _, item := range inventory { + if item.TokenIn == tokenIn && item.TokenOut == tokenOut { + item.TokenInDecimals = inputDecimals + matching = append(matching, item) + } + } + matching = liquidgreedy.AllocateInventoryCapacity(matching, nil, 0) + routes := make([]liquidlane.Route, 0, len(matching)) + for _, item := range matching { + routes = append(routes, item.Route) + } + f.queries = append(f.queries, append([]liquidlane.Route(nil), routes...)) + quotes := make([]liquidlane.FillQuote, 0, len(routes)) + for _, route := range routes { + amountOut := f.out[route.TokenOut] + if amountOut == nil { + continue + } + quotes = append(quotes, liquidlane.FillQuote{ + Inventory: liquidlane.Inventory{Route: route, MaxAssets: maxUint256()}, + AmountIn: liquidlane.CloneBig(amount), MaxAmountOut: liquidlane.CloneBig(amountOut), + }) + } + return liquidgreedy.NormalizeOracleInventory(amount, matching, quotes), 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() types.Strategy { return defaultstrategy.New() } + +func maxUint256() *big.Int { + return new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)) } -func newDefaultTestStrategy(decimals int, out map[common.Address]*big.Int) types.Strategy { - return defaultstrategy.New(&fakeStrategyPricing{decimals: decimals, out: out}) +func testInventory(adapter, tokenIn, tokenOut common.Address, maxAssets, maxRate *big.Int) solverInventory { + route := liquidlane.NewRoute(1, adapter, common.Address{}, tokenIn, tokenOut, 18, 6) + return liquidlane.DirectInventory(route, maxAssets, maxRate) } diff --git a/internal/solvers/rfq/whitelist_test.go b/internal/solvers/rfq/whitelist_test.go index b378dfe1..265620cd 100644 --- a/internal/solvers/rfq/whitelist_test.go +++ b/internal/solvers/rfq/whitelist_test.go @@ -38,8 +38,8 @@ func TestAdapterWhitelist_Filter(t *testing.T) { listed := common.HexToAddress("0x0000000000000000000000000000000000000042") rogue := common.HexToAddress("0x00000000000000000000000000000000000000aa") inv := []solverInventory{ - {Adapter: listed, Asset: tOut, MaxAssets: big.NewInt(1), MaxRate: big.NewInt(1)}, - {Adapter: rogue, Asset: tOut, MaxAssets: big.NewInt(1), MaxRate: big.NewInt(1)}, + testInventory(listed, tIn, tOut, big.NewInt(1), big.NewInt(1)), + testInventory(rogue, tIn, tOut, big.NewInt(1), big.NewInt(1)), } wl := buildAdapterWhitelist(true, []recoveryVault{{Adapter: listed}}) diff --git a/internal/solvers/uniswapx/apitypes.go b/internal/solvers/uniswapx/apitypes.go new file mode 100644 index 00000000..2c612c5c --- /dev/null +++ b/internal/solvers/uniswapx/apitypes.go @@ -0,0 +1,60 @@ +package uniswapx + +type quoteRequest struct { + BlockUntilTimestamp *int64 `json:"blockUntilTimestamp,omitempty"` + RequestID string `json:"requestId"` + QuoteID string `json:"quoteId"` + TokenInChainID int64 `json:"tokenInChainId"` + TokenOutChainID int64 `json:"tokenOutChainId"` + Swapper string `json:"swapper"` + TokenIn string `json:"tokenIn"` + TokenOut string `json:"tokenOut"` + Amount string `json:"amount"` + Type string `json:"type"` + NumOutputs int `json:"numOutputs"` + Protocol string `json:"protocol"` +} + +type quoteResponse struct { + ChainID int64 `json:"chainId"` + RequestID string `json:"requestId"` + Swapper string `json:"swapper"` + TokenIn string `json:"tokenIn"` + AmountIn string `json:"amountIn"` + TokenOut string `json:"tokenOut"` + AmountOut string `json:"amountOut"` + Filler string `json:"filler"` + QuoteID string `json:"quoteId"` + + declineReason string +} + +type orderPage struct { + Orders []orderEntry `json:"orders"` + Cursor string `json:"cursor,omitempty"` +} + +type orderEntry struct { + Type string `json:"type"` + EncodedOrder string `json:"encodedOrder"` + Signature string `json:"signature"` + OrderHash string `json:"orderHash"` + OrderStatus string `json:"orderStatus"` + ChainID int64 `json:"chainId"` + QuoteID string `json:"quoteId"` + Input orderToken `json:"input"` + Outputs []orderOutput `json:"outputs"` +} + +type orderToken struct { + Token string `json:"token"` + StartAmount string `json:"startAmount"` + EndAmount string `json:"endAmount"` +} + +type orderOutput struct { + Token string `json:"token"` + StartAmount string `json:"startAmount"` + EndAmount string `json:"endAmount"` + Recipient string `json:"recipient"` +} diff --git a/internal/solvers/uniswapx/chainreader.go b/internal/solvers/uniswapx/chainreader.go new file mode 100644 index 00000000..cd2d8c4a --- /dev/null +++ b/internal/solvers/uniswapx/chainreader.go @@ -0,0 +1,167 @@ +package uniswapx + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + + uxexecutor "github.com/symbioticfi/vault-solver/api/bindings/uniswapx/executor" + "github.com/symbioticfi/vault-solver/internal/chain" + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" + liquidsnapshot "github.com/symbioticfi/vault-solver/internal/liquidlane/snapshot" +) + +var uniswapXExecutor = uxexecutor.NewLiquidLaneUniswapXExecutor() + +const maxExecutorCallers = 256 + +type reader struct { + chain *chain.Client + snapshots *liquidsnapshot.Reader +} + +type snapshot = liquidsnapshot.Quote +type fillSnapshot = liquidsnapshot.Fill + +func newReader(c *chain.Client, log logr.Logger, cfg *liquidlanegas.OracleConfig, liquidityLens common.Address) (*reader, error) { + snapshots, err := liquidsnapshot.New(c, log, cfg, liquidityLens) + if err != nil { + return nil, err + } + return &reader{chain: c, snapshots: snapshots}, nil +} + +func (r *reader) resolveRoutes(ctx context.Context, adapters []common.Address) ([]liquidlane.Route, error) { + return r.snapshots.ResolveRoutes(ctx, adapters) +} + +func (r *reader) validateExecutorCode( + ctx context.Context, + executor common.Address, +) error { + code, err := r.chain.CodeAt(ctx, executor, nil) + if err != nil { + return errors.Errorf("read executor bytecode: %w", err) + } + return requireExecutorCode(executor, code) +} + +func requireExecutorCode(executor common.Address, code []byte) error { + if len(code) == 0 { + return errors.Errorf("executor %s has no bytecode", executor.Hex()) + } + return nil +} + +func (r *reader) validateExecutorCaller( + ctx context.Context, + executor, caller common.Address, +) error { + calls := make([]chain.Call, maxExecutorCallers) + for i := range calls { + calls[i] = chain.Call{ + Target: executor, + AllowFailure: true, + Data: uniswapXExecutor.PackCallers(big.NewInt(int64(i))), + } + } + results, err := r.chain.Multicall(ctx, calls) + if err != nil { + return errors.Errorf("read executor callers: %w", err) + } + if len(results) != len(calls) { + return errors.Errorf("read executor callers: got %d results, want %d", len(results), len(calls)) + } + return requireExecutorCaller(caller, results) +} + +func requireExecutorCaller(caller common.Address, results []chain.CallResult) error { + for i, result := range results { + if !result.Success { + return errors.Errorf("executor caller %s is not authorized", caller.Hex()) + } + got, err := uniswapXExecutor.UnpackCallers(result.ReturnData) + if err != nil { + return errors.Errorf("decode executor caller %d: %w", i, err) + } + if got == caller { + return nil + } + } + return errors.Errorf( + "executor caller scan reached safety limit %d before finding %s", + len(results), + caller.Hex(), + ) +} + +func (r *reader) unauthorizedAdapters( + ctx context.Context, + executor common.Address, + routes []liquidlane.Route, +) ([]common.Address, error) { + authorized, err := r.snapshots.FilterAuthorizedRoutes(ctx, routes, executor) + if err != nil { + return nil, err + } + return liquidlane.UnauthorizedAdapters(routes, authorized), nil +} + +func (r *reader) validateGasTokens(routes []liquidlane.Route) error { + return r.snapshots.ValidateGasTokens(routes) +} + +func (r *reader) quoteSnapshot(ctx context.Context, routes []liquidlane.Route, executor common.Address, now time.Time) (snapshot, error) { + return r.snapshots.Quote(ctx, routes, executor, now) +} + +func (r *reader) fillSnapshot( + ctx context.Context, + routes []liquidlane.Route, + executor, tokenIn common.Address, + amountIn *big.Int, + now time.Time, +) (fillSnapshot, error) { + return r.snapshots.Fill(ctx, routes, executor, tokenIn, amountIn, now) +} + +func (r *reader) physicalFillQuotes( + ctx context.Context, + routes []liquidlane.Route, + tokenIn common.Address, + amountIn *big.Int, +) ([]liquidlane.FillQuote, error) { + return r.snapshots.ReadFillQuotes(ctx, routes, tokenIn, amountIn) +} + +func (r *reader) latestBlockTime(ctx context.Context) (time.Time, error) { + header, err := r.chain.HeaderByNumber(ctx, nil) + if err != nil { + return time.Time{}, err + } + return time.Unix(int64(header.Time), 0), nil +} + +func (r *reader) transactionBlockTime(ctx context.Context, txHash common.Hash) (time.Time, error) { + receipt, err := r.chain.TransactionReceipt(ctx, txHash) + if err != nil { + return time.Time{}, errors.Errorf("read transaction receipt %s: %w", txHash.Hex(), err) + } + if receipt == nil || receipt.BlockNumber == nil || receipt.Status != types.ReceiptStatusSuccessful { + return time.Time{}, errors.Errorf("transaction %s has no successful canonical receipt", txHash.Hex()) + } + header, err := r.chain.HeaderByNumber(ctx, receipt.BlockNumber) + if err != nil { + return time.Time{}, errors.Errorf("read transaction block %s: %w", txHash.Hex(), err) + } + if header == nil || receipt.BlockHash != (common.Hash{}) && header.Hash() != receipt.BlockHash { + return time.Time{}, errors.Errorf("transaction %s receipt is not canonical", txHash.Hex()) + } + return time.Unix(int64(header.Time), 0), nil +} diff --git a/internal/solvers/uniswapx/chainreader_test.go b/internal/solvers/uniswapx/chainreader_test.go new file mode 100644 index 00000000..a922d256 --- /dev/null +++ b/internal/solvers/uniswapx/chainreader_test.go @@ -0,0 +1,93 @@ +package uniswapx + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/chain" +) + +func TestRequireExecutorCode(t *testing.T) { + executor := common.HexToAddress("0x1111111111111111111111111111111111111111") + tests := []struct { + name string + code []byte + wantError string + }{ + {name: "contract", code: []byte{0x60}}, + {name: "empty account", wantError: "has no bytecode"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := requireExecutorCode(executor, tc.code) + if tc.wantError == "" { + if err != nil { + t.Fatalf("requireExecutorCode() error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("requireExecutorCode() error = %v, want %q", err, tc.wantError) + } + }) + } +} + +func TestRequireExecutorCaller(t *testing.T) { + caller := common.HexToAddress("0x1111111111111111111111111111111111111111") + other := common.HexToAddress("0x2222222222222222222222222222222222222222") + encoded := func(address common.Address) []byte { + return common.LeftPadBytes(address.Bytes(), 32) + } + + tests := []struct { + name string + results []chain.CallResult + wantError string + }{ + { + name: "authorized", + results: []chain.CallResult{ + {Success: true, ReturnData: encoded(other)}, + {Success: true, ReturnData: encoded(caller)}, + {Success: false}, + }, + }, + { + name: "not authorized", + results: []chain.CallResult{ + {Success: true, ReturnData: encoded(other)}, + {Success: false}, + }, + wantError: "is not authorized", + }, + { + name: "malformed", + results: []chain.CallResult{{Success: true, ReturnData: []byte{1}}}, + wantError: "decode executor caller 0", + }, + { + name: "safety limit", + results: []chain.CallResult{{Success: true, ReturnData: encoded(other)}}, + wantError: "safety limit", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := requireExecutorCaller(caller, tc.results) + if tc.wantError == "" { + if err != nil { + t.Fatalf("requireExecutorCaller() error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("requireExecutorCaller() error = %v, want %q", err, tc.wantError) + } + }) + } +} diff --git a/internal/solvers/uniswapx/config.go b/internal/solvers/uniswapx/config.go new file mode 100644 index 00000000..feaeb3df --- /dev/null +++ b/internal/solvers/uniswapx/config.go @@ -0,0 +1,351 @@ +package uniswapx + +import ( + "net" + "net/url" + "strconv" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "gopkg.in/yaml.v3" + + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" + "github.com/symbioticfi/vault-solver/internal/parse" + "github.com/symbioticfi/vault-solver/internal/solver" + "github.com/symbioticfi/vault-solver/internal/tokenpolicy" +) + +const ( + defaultListenAddress = ":42080" + defaultHTTPTimeout = 450 * time.Millisecond + defaultPollInterval = time.Second + defaultRefreshInterval = 12 * time.Second + defaultQuoteTTL = 30 * time.Second + defaultDiscountTimeout = 2 * time.Second + defaultDiscountValidity = 15 * time.Second + defaultStrategyName = "default" + defaultSolverMode = solverModeExternal + solverModeExternal = "external" + solverModeInternal = "internal" +) + +type rawConfig struct { + Reactor string `yaml:"reactor"` + Executor string `yaml:"executor"` + LiquidityLens string `yaml:"liquidityLens"` + Adapters []string `yaml:"adapters"` + SolverMode string `yaml:"solverMode"` + TokensToQuote string `yaml:"tokensToQuote"` + Permissioned []string `yaml:"permissionedTokens"` + QuoteServer rawQuoteServerConfig `yaml:"quoteServer"` + OrderServer rawOrderServerConfig `yaml:"orderServer"` + Discounts *rawDiscountConfig `yaml:"discounts"` + Gas *liquidlanegas.RawConfig `yaml:"gas"` + Breaker rawBreakerConfig `yaml:"breaker"` + Strategy rawStrategyConfig `yaml:"strategy"` +} + +type rawDiscountConfig struct { + BaseURL string `yaml:"baseUrl"` + HTTPTimeout string `yaml:"httpTimeout"` + MinimumValidity string `yaml:"minimumValidity"` +} + +type rawQuoteServerConfig struct { + ListenAddress string `yaml:"listenAddress"` + HTTPTimeout string `yaml:"httpTimeout"` + RefreshInterval string `yaml:"refreshInterval"` + QuoteTTL string `yaml:"quoteTtl"` +} + +type rawOrderServerConfig struct { + BaseURL string `yaml:"baseUrl"` + APIKeyEnv string `yaml:"apiKeyEnv"` + PollInterval string `yaml:"pollInterval"` + HTTPTimeout string `yaml:"httpTimeout"` + Beta bool `yaml:"beta"` + Sources rawOrderSourcesConfig `yaml:"sources"` +} + +type rawOrderSourcesConfig struct { + ExclusiveV2 *bool `yaml:"exclusiveV2"` + PublicV2 bool `yaml:"publicV2"` +} + +type rawStrategyConfig struct { + Name string `yaml:"name"` + Config yaml.Node `yaml:"config"` +} + +type rawBreakerConfig struct { + MaxFailures int `yaml:"maxFailures"` + Window string `yaml:"window"` +} + +type Config struct { + Reactor common.Address + Executor common.Address + // LiquidityLens is the optional FrontendLiquidityLens address. When set, LiquidLane swappable headroom + // is read from the lens's cross-adapter deallocation-cascade estimate instead of each adapter's own + // getMaxAssets(tokenToRedeem); zero falls back to the adapter getter. + LiquidityLens common.Address + Adapters []common.Address + SolverMode string + TokenPolicy tokenpolicy.Policy + QuoteServer QuoteServerConfig + OrderServer OrderServerConfig + Discounts *DiscountConfig + Gas *liquidlanegas.OracleConfig + Breaker BreakerConfig + Strategy StrategyConfig +} + +type DiscountConfig struct { + BaseURL string + HTTPTimeout time.Duration + MinimumValidity time.Duration +} + +type QuoteServerConfig struct { + ListenAddress string + HTTPTimeout time.Duration + RefreshInterval time.Duration + QuoteTTL time.Duration +} + +type OrderServerConfig struct { + BaseURL string + APIKeyEnv string + PollInterval time.Duration + HTTPTimeout time.Duration + Beta bool + Sources OrderSourcesConfig +} + +type OrderSourcesConfig struct { + ExclusiveV2 bool + PublicV2 bool +} + +type StrategyConfig struct { + Name string + Config yaml.Node +} + +type BreakerConfig struct { + MaxFailures int + Window time.Duration +} + +func parseConfig(node yaml.Node) (*Config, error) { + var raw rawConfig + if err := solver.DecodeStrict(node, &raw); err != nil { + return nil, err + } + reactor, err := parse.NonZeroAddress(raw.Reactor, "reactor") + if err != nil { + return nil, err + } + executor, err := parse.NonZeroAddress(raw.Executor, "executor") + if err != nil { + return nil, err + } + var liquidityLens common.Address + if raw.LiquidityLens != "" { + if liquidityLens, err = parse.NonZeroAddress(raw.LiquidityLens, "liquidityLens"); err != nil { + return nil, err + } + } + adapters, err := parseAddressList(raw.Adapters, "adapters") + if err != nil { + return nil, err + } + solverMode := parse.OrDefault(raw.SolverMode, defaultSolverMode) + if solverMode != solverModeExternal && solverMode != solverModeInternal { + return nil, errors.Errorf( + "solverMode: must be %q or %q, got %q", + solverModeExternal, + solverModeInternal, + solverMode, + ) + } + if solverMode == solverModeExternal && len(adapters) == 0 { + return nil, errors.New(`solverMode "external" requires at least one adapters entry`) + } + policy, err := tokenpolicy.Parse(raw.TokensToQuote, raw.Permissioned) + if err != nil { + return nil, err + } + quoteHTTPTimeout, err := parse.Duration(raw.QuoteServer.HTTPTimeout, defaultHTTPTimeout, "quoteServer.httpTimeout") + if err != nil { + return nil, err + } + quoteTTL, err := parse.Duration(raw.QuoteServer.QuoteTTL, defaultQuoteTTL, "quoteServer.quoteTtl") + if err != nil { + return nil, err + } + refreshInterval, err := parse.Duration( + raw.QuoteServer.RefreshInterval, + defaultRefreshInterval, + "quoteServer.refreshInterval", + ) + if err != nil { + return nil, err + } + if quoteTTL/2 < refreshInterval { + return nil, errors.Errorf( + "quoteServer.quoteTtl must be at least twice refresh interval %s, got %s", + refreshInterval, + quoteTTL, + ) + } + pollInterval, err := parse.Duration(raw.OrderServer.PollInterval, defaultPollInterval, "orderServer.pollInterval") + if err != nil { + return nil, err + } + if pollInterval < 167*time.Millisecond { + return nil, errors.New("orderServer.pollInterval must be at least 167ms") + } + orderHTTPTimeout, err := parse.Duration(raw.OrderServer.HTTPTimeout, 5*time.Second, "orderServer.httpTimeout") + if err != nil { + return nil, err + } + if raw.OrderServer.APIKeyEnv == "" { + return nil, errors.New("orderServer.apiKeyEnv is required") + } + if raw.OrderServer.BaseURL == "" { + return nil, errors.New("orderServer.baseUrl is required") + } + if err := validateServiceURL(raw.OrderServer.BaseURL, "orderServer.baseUrl"); err != nil { + return nil, err + } + exclusiveV2 := true + if raw.OrderServer.Sources.ExclusiveV2 != nil { + exclusiveV2 = *raw.OrderServer.Sources.ExclusiveV2 + } + sources := OrderSourcesConfig{ExclusiveV2: exclusiveV2, PublicV2: raw.OrderServer.Sources.PublicV2} + if !sources.ExclusiveV2 { + return nil, errors.New("orderServer.sources.exclusiveV2 must be enabled while quote server is enabled") + } + if solverMode == solverModeInternal && raw.Discounts == nil { + return nil, errors.New("discounts is required in internal solverMode") + } + if solverMode == solverModeExternal && raw.Discounts != nil { + return nil, errors.New("discounts requires internal solverMode") + } + discountConfig, err := parseDiscountConfig(raw.Discounts) + if err != nil { + return nil, err + } + var gas *liquidlanegas.OracleConfig + if raw.Gas != nil { + parsed, gasErr := liquidlanegas.ParseConfig(*raw.Gas) + if gasErr != nil { + return nil, gasErr + } + gas = &parsed + } + breaker, err := parseBreakerConfig(raw.Breaker) + if err != nil { + return nil, err + } + return &Config{ + Reactor: reactor, Executor: executor, LiquidityLens: liquidityLens, + Adapters: adapters, SolverMode: solverMode, TokenPolicy: policy, + QuoteServer: QuoteServerConfig{ + ListenAddress: parse.OrDefault(raw.QuoteServer.ListenAddress, defaultListenAddress), + HTTPTimeout: quoteHTTPTimeout, + RefreshInterval: refreshInterval, QuoteTTL: quoteTTL, + }, + OrderServer: OrderServerConfig{ + BaseURL: raw.OrderServer.BaseURL, APIKeyEnv: raw.OrderServer.APIKeyEnv, + PollInterval: pollInterval, HTTPTimeout: orderHTTPTimeout, Beta: raw.OrderServer.Beta, Sources: sources, + }, + Discounts: discountConfig, + Gas: gas, + Breaker: breaker, + Strategy: StrategyConfig{Name: parse.OrDefault(raw.Strategy.Name, defaultStrategyName), Config: raw.Strategy.Config}, + }, nil +} + +func (c *Config) usesDiscounts() bool { return c.SolverMode == solverModeInternal } + +func (c *Config) restrictsToAdapters() bool { + return c.SolverMode == solverModeExternal && len(c.Adapters) > 0 +} + +func (c *Config) quoteScopesToAdapters() bool { + return len(c.Adapters) > 0 +} + +func parseDiscountConfig(raw *rawDiscountConfig) (*DiscountConfig, error) { + if raw == nil { + return nil, nil + } + if raw.BaseURL == "" { + return nil, errors.New("discounts.baseUrl is required") + } + if err := validateServiceURL(raw.BaseURL, "discounts.baseUrl"); err != nil { + return nil, err + } + timeout, err := parse.Duration(raw.HTTPTimeout, defaultDiscountTimeout, "discounts.httpTimeout") + if err != nil { + return nil, err + } + validity, err := parse.Duration(raw.MinimumValidity, defaultDiscountValidity, "discounts.minimumValidity") + if err != nil { + return nil, err + } + return &DiscountConfig{BaseURL: raw.BaseURL, HTTPTimeout: timeout, MinimumValidity: validity}, nil +} + +func parseBreakerConfig(raw rawBreakerConfig) (BreakerConfig, error) { + maxFailures := raw.MaxFailures + if maxFailures == 0 { + maxFailures = 3 + } + if maxFailures < 1 { + return BreakerConfig{}, errors.New("breaker.maxFailures must be positive") + } + window, err := parse.Duration(raw.Window, 5*time.Minute, "breaker.window") + if err != nil { + return BreakerConfig{}, err + } + return BreakerConfig{MaxFailures: maxFailures, Window: window}, nil +} + +func validateServiceURL(raw, field string) error { + u, err := url.Parse(raw) + if err != nil || !u.IsAbs() || u.Host == "" { + return errors.Errorf("%s must be an absolute URL, got %q", field, raw) + } + if u.Scheme == "https" { + return nil + } + if u.Scheme == "http" { + host := u.Hostname() + ip := net.ParseIP(host) + if host == "localhost" || ip != nil && ip.IsLoopback() { + return nil + } + } + return errors.Errorf("%s must use https, except loopback http for local development", field) +} + +func parseAddressList(values []string, field string) ([]common.Address, error) { + out := make([]common.Address, 0, len(values)) + seen := make(map[common.Address]bool, len(values)) + for i, value := range values { + address, err := parse.NonZeroAddress(value, field+"["+strconv.Itoa(i)+"]") + if err != nil { + return nil, err + } + if seen[address] { + return nil, errors.Errorf("%s[%d]: duplicate address %s", field, i, address.Hex()) + } + seen[address] = true + out = append(out, address) + } + return out, nil +} diff --git a/internal/solvers/uniswapx/config_test.go b/internal/solvers/uniswapx/config_test.go new file mode 100644 index 00000000..1c87ef62 --- /dev/null +++ b/internal/solvers/uniswapx/config_test.go @@ -0,0 +1,280 @@ +package uniswapx + +import ( + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "gopkg.in/yaml.v3" +) + +const validUniswapXGasConfig = `gas: + nativeUsdFeed: "0x5555555555555555555555555555555555555555" + nativeMaxAge: 1h + tokenUsdFeeds: + - token: "0x6666666666666666666666666666666666666666" + feed: "0x7777777777777777777777777777777777777777" + maxAge: 2h +` + +const validUniswapXConfig = ` +reactor: "0x1111111111111111111111111111111111111111" +executor: "0x2222222222222222222222222222222222222222" +adapters: + - "0x4444444444444444444444444444444444444444" +tokensToQuote: all +quoteServer: +orderServer: + baseUrl: https://api.uniswap.org/v2 + apiKeyEnv: UNISWAP_API_KEY + sources: + exclusiveV2: true + publicV2: true +` + validUniswapXGasConfig + `strategy: {} +` + +func TestParseConfigDefaultsAndSources(t *testing.T) { + cfg, err := parseConfig(uniswapXConfigNode(t, validUniswapXConfig)) + if err != nil { + t.Fatal(err) + } + if cfg.QuoteServer.ListenAddress != defaultListenAddress || + cfg.QuoteServer.HTTPTimeout != defaultHTTPTimeout || + cfg.QuoteServer.RefreshInterval != defaultRefreshInterval || + cfg.QuoteServer.QuoteTTL != defaultQuoteTTL || + cfg.OrderServer.PollInterval != defaultPollInterval || + cfg.Strategy.Name != defaultStrategyName || + cfg.SolverMode != solverModeExternal || + cfg.usesDiscounts() { + t.Fatalf("defaults were not applied: %+v", cfg) + } + if !cfg.OrderServer.Sources.ExclusiveV2 || !cfg.OrderServer.Sources.PublicV2 { + t.Fatalf("sources = %+v", cfg.OrderServer.Sources) + } + if cfg.Gas == nil { + t.Fatal("gas config = nil") + } + feed := cfg.Gas.TokenUSDFeeds[common.HexToAddress("0x6666666666666666666666666666666666666666")] + if feed.MaxAge != 2*time.Hour { + t.Fatalf("token feed max age = %s, want 2h", feed.MaxAge) + } +} + +func TestParseConfigAllowsMissingGas(t *testing.T) { + raw := strings.Replace(validUniswapXConfig, validUniswapXGasConfig, "", 1) + cfg, err := parseConfig(uniswapXConfigNode(t, raw)) + if err != nil { + t.Fatal(err) + } + if cfg.Gas != nil { + t.Fatalf("gas config = %#v, want nil", cfg.Gas) + } +} + +func TestParseConfigValidatesOrderServerURL(t *testing.T) { + tests := map[string]struct { + baseURL string + wantErr bool + }{ + "https": {baseURL: "https://api.uniswap.org/v2"}, + "loopback IPv4": {baseURL: "http://127.0.0.1:8080/v2"}, + "loopback IPv6": {baseURL: "http://[::1]:8080/v2"}, + "localhost": {baseURL: "http://localhost:8080/v2"}, + "remote HTTP": {baseURL: "http://api.uniswap.org/v2", wantErr: true}, + "relative URL": {baseURL: "/v2", wantErr: true}, + "unsupported URL": {baseURL: "ftp://api.uniswap.org/v2", wantErr: true}, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + raw := strings.Replace(validUniswapXConfig, "https://api.uniswap.org/v2", test.baseURL, 1) + _, err := parseConfig(uniswapXConfigNode(t, raw)) + if (err != nil) != test.wantErr { + t.Fatalf("parseConfig() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + +func TestParseConfigSolverMode(t *testing.T) { + discounts := `discounts: + baseUrl: https://backend.example +` + withoutAdapters := strings.Replace( + validUniswapXConfig, + `adapters: + - "0x4444444444444444444444444444444444444444" +`, + "", + 1, + ) + tests := map[string]struct { + base string + suffix string + wantMode string + wantDiscounts bool + wantRestrict bool + wantQuote bool + wantError string + }{ + "default external": { + base: validUniswapXConfig, wantMode: solverModeExternal, wantRestrict: true, wantQuote: true, + }, + "default external without adapters": { + base: withoutAdapters, wantError: `solverMode "external" requires at least one adapters entry`, + }, + "explicit external": { + base: validUniswapXConfig, suffix: "solverMode: external\n", + wantMode: solverModeExternal, wantRestrict: true, wantQuote: true, + }, + "explicit external without adapters": { + base: withoutAdapters, suffix: "solverMode: external\n", + wantError: `solverMode "external" requires at least one adapters entry`, + }, + "internal with adapters": { + base: validUniswapXConfig, suffix: "solverMode: internal\n" + discounts, + wantMode: solverModeInternal, wantDiscounts: true, wantQuote: true, + }, + "internal without adapters": { + base: withoutAdapters, suffix: "solverMode: internal\n" + discounts, + wantMode: solverModeInternal, wantDiscounts: true, + }, + "invalid": { + base: validUniswapXConfig, suffix: "solverMode: hybrid\n", wantError: "solverMode: must be", + }, + "internal without discounts": { + base: validUniswapXConfig, suffix: "solverMode: internal\n", + wantError: "discounts is required in internal solverMode", + }, + "external with discounts": { + base: validUniswapXConfig, suffix: "solverMode: external\n" + discounts, + wantError: "discounts requires internal solverMode", + }, + "default with discounts": { + base: validUniswapXConfig, suffix: discounts, wantError: "discounts requires internal solverMode", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + cfg, err := parseConfig(uniswapXConfigNode(t, test.base+test.suffix)) + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("parseConfig() error = %v, want %q", err, test.wantError) + } + return + } + if err != nil { + t.Fatal(err) + } + if cfg.SolverMode != test.wantMode || cfg.usesDiscounts() != test.wantDiscounts { + t.Fatalf( + "solverMode = %q, usesDiscounts = %t", + cfg.SolverMode, + cfg.usesDiscounts(), + ) + } + if cfg.restrictsToAdapters() != test.wantRestrict { + t.Fatalf( + "restrictsToAdapters() = %t, want %t", + cfg.restrictsToAdapters(), + test.wantRestrict, + ) + } + if cfg.quoteScopesToAdapters() != test.wantQuote { + t.Fatalf( + "quoteScopesToAdapters() = %t, want %t", + cfg.quoteScopesToAdapters(), + test.wantQuote, + ) + } + }) + } +} + +func TestParseConfigDiscounts(t *testing.T) { + raw := validUniswapXConfig + `solverMode: internal +discounts: + baseUrl: https://backend.example + httpTimeout: 3s + minimumValidity: 20s +` + cfg, err := parseConfig(uniswapXConfigNode(t, raw)) + if err != nil { + t.Fatal(err) + } + if cfg.Discounts == nil || cfg.Discounts.HTTPTimeout != 3*time.Second || + cfg.Discounts.MinimumValidity != 20*time.Second { + t.Fatalf("discount config = %+v", cfg.Discounts) + } + + raw = strings.Replace(raw, "https://backend.example", "http://backend.example", 1) + if _, err := parseConfig(uniswapXConfigNode(t, raw)); err == nil { + t.Fatal("expected unsafe discounts URL rejection") + } +} + +func TestParseConfigRejectsUnsafeOrderPolling(t *testing.T) { + tests := map[string]string{ + "exclusive source disabled": strings.Replace(validUniswapXConfig, "exclusiveV2: true", "exclusiveV2: false", 1), + "polls faster than 6 RPS": strings.Replace( + validUniswapXConfig, "apiKeyEnv: UNISWAP_API_KEY", "apiKeyEnv: UNISWAP_API_KEY\n pollInterval: 166ms", 1, + ), + } + for name, raw := range tests { + t.Run(name, func(t *testing.T) { + if _, err := parseConfig(uniswapXConfigNode(t, raw)); err == nil { + t.Fatal("expected config rejection") + } + }) + } +} + +func TestParseConfigRejectsQuoteTTLWithoutRefreshHeadroom(t *testing.T) { + raw := strings.Replace(validUniswapXConfig, "quoteServer:\n", `quoteServer: + refreshInterval: 20s + quoteTtl: 30s +`, 1) + if _, err := parseConfig(uniswapXConfigNode(t, raw)); err == nil || + !strings.Contains(err.Error(), "quoteServer.quoteTtl must be at least twice refresh interval") { + t.Fatalf("err = %v", err) + } +} + +func TestParseConfigRejectsLegacyLimitSource(t *testing.T) { + raw := strings.Replace(validUniswapXConfig, "publicV2: true", `publicV2: true + limit: + reactor: "0x8888888888888888888888888888888888888888" + executor: "0x2222222222222222222222222222222222222222"`, 1) + if _, err := parseConfig(uniswapXConfigNode(t, raw)); err == nil || + !strings.Contains(err.Error(), "field limit not found") { + t.Fatalf("err = %v", err) + } +} + +func TestParseConfigRejectsUnknownFields(t *testing.T) { + if _, err := parseConfig(uniswapXConfigNode(t, validUniswapXConfig+"unknown: true\n")); err == nil { + t.Fatal("expected strict decode rejection") + } +} + +func TestParseConfigRejectsLegacyCosignerPin(t *testing.T) { + raw := strings.Replace( + validUniswapXConfig, + "executor: \"0x2222222222222222222222222222222222222222\"", + "executor: \"0x2222222222222222222222222222222222222222\"\n"+ + "cosigner: \"0x3333333333333333333333333333333333333333\"", + 1, + ) + if _, err := parseConfig(uniswapXConfigNode(t, raw)); err == nil { + t.Fatal("expected legacy cosigner pin rejection") + } +} + +func uniswapXConfigNode(t *testing.T, raw string) yaml.Node { + t.Helper() + var document yaml.Node + if err := yaml.Unmarshal([]byte(raw), &document); err != nil { + t.Fatal(err) + } + return *document.Content[0] +} diff --git a/internal/solvers/uniswapx/discounts.go b/internal/solvers/uniswapx/discounts.go new file mode 100644 index 00000000..4c9cd97a --- /dev/null +++ b/internal/solvers/uniswapx/discounts.go @@ -0,0 +1,286 @@ +package uniswapx + +import ( + "context" + "slices" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquiddiscounts "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" +) + +const maxAdvertisedDiscountRoutes = 256 + +type advertisedRouteFilter struct { + adapters map[common.Address]bool + tokenIn common.Address + tokenOut common.Address +} + +func (s *Solver) listDiscounts(ctx context.Context) (*liquiddiscounts.List, error) { + if s.discounts == nil { + return &liquiddiscounts.List{}, nil + } + requestCtx, cancel := context.WithTimeout(ctx, s.cfg.Discounts.HTTPTimeout) + defer cancel() + return s.discounts.ListDiscounts(requestCtx) +} + +func (s *Solver) quoteRoutesWithDiscounts( + ctx context.Context, + configured []liquidlane.Route, + now time.Time, +) ([]liquidlane.Route, *liquiddiscounts.List, error) { + filter := advertisedRouteFilter{} + if s.cfg.quoteScopesToAdapters() { + filter.adapters = adapterSet(s.cfg.Adapters) + } + return s.routesWithDiscounts(ctx, configured, now, filter) +} + +func (s *Solver) fillRoutesWithDiscounts( + ctx context.Context, + configured []liquidlane.Route, + tokenIn, tokenOut common.Address, + now time.Time, +) ([]liquidlane.Route, *liquiddiscounts.List, error) { + return s.routesWithDiscounts(ctx, configured, now, advertisedRouteFilter{ + tokenIn: tokenIn, tokenOut: tokenOut, + }) +} + +func (s *Solver) routesWithDiscounts( + ctx context.Context, + configured []liquidlane.Route, + now time.Time, + filter advertisedRouteFilter, +) ([]liquidlane.Route, *liquiddiscounts.List, error) { + if !s.cfg.usesDiscounts() { + return configured, nil, nil + } + listed, err := s.listDiscounts(ctx) + if err != nil { + return configured, nil, err + } + dynamic := s.resolveAdvertisedRoutes(ctx, listed, configured, now, filter) + return mergeRoutes(configured, dynamic), listed, nil +} + +func (s *Solver) resolveAdvertisedRoutes( + ctx context.Context, + listed *liquiddiscounts.List, + configured []liquidlane.Route, + now time.Time, + filter advertisedRouteFilter, +) []liquidlane.Route { + offers, _ := liquiddiscounts.LiveOffers(listed, now) + type routeKey struct { + adapter common.Address + tokenIn common.Address + tokenOut common.Address + decimals int + } + expected := make(map[routeKey]bool, len(offers)) + known := make(map[routeKey]bool, len(configured)) + for _, route := range configured { + known[routeKey{ + adapter: route.Adapter, tokenIn: route.TokenIn, + tokenOut: route.TokenOut, decimals: route.TokenOutDecimals, + }] = true + } + adapters := make(map[common.Address]bool) + skipped := 0 + for _, offer := range offers { + if !s.cfg.TokenPolicy.Allows(offer.TokenToRedeem) || + filter.adapters != nil && !filter.adapters[offer.Adapter] || + filter.tokenIn != (common.Address{}) && offer.TokenToRedeem != filter.tokenIn || + filter.tokenOut != (common.Address{}) && offer.Collateral != filter.tokenOut { + continue + } + key := routeKey{ + adapter: offer.Adapter, tokenIn: offer.TokenToRedeem, + tokenOut: offer.Collateral, decimals: offer.CollateralDecimals, + } + if known[key] || expected[key] { + continue + } + if len(expected) == maxAdvertisedDiscountRoutes { + skipped++ + continue + } + expected[key] = true + } + for key := range expected { + adapters[key.adapter] = true + } + if skipped > 0 { + s.log.V(1).Info( + "ignore advertised discount routes above safety cap", + "cap", maxAdvertisedDiscountRoutes, + "skipped", skipped, + ) + } + orderedAdapters := make([]common.Address, 0, len(adapters)) + for adapter := range adapters { + orderedAdapters = append(orderedAdapters, adapter) + } + if len(orderedAdapters) == 0 { + return nil + } + slices.SortFunc(orderedAdapters, func(a, b common.Address) int { return a.Cmp(b) }) + + routes := s.resolveAdvertisedAdapters(ctx, orderedAdapters) + resolved := make([]liquidlane.Route, 0, len(expected)) + for _, route := range routes { + if !expected[routeKey{ + adapter: route.Adapter, tokenIn: route.TokenIn, + tokenOut: route.TokenOut, decimals: route.TokenOutDecimals, + }] { + continue + } + if err := s.reader.validateGasTokens([]liquidlane.Route{route}); err != nil { + s.log.V(1).Info( + "skip advertised discount route", + "adapter", route.Adapter.Hex(), + "tokenIn", route.TokenIn.Hex(), + "tokenOut", route.TokenOut.Hex(), + "error", err.Error(), + ) + continue + } + resolved = append(resolved, route) + } + return resolved +} + +func (s *Solver) resolveAdvertisedAdapters( + ctx context.Context, + adapters []common.Address, +) []liquidlane.Route { + routes, err := s.reader.resolveRoutes(ctx, adapters) + if err == nil { + return routes + } + if len(adapters) == 1 { + s.log.Error(err, "skip unresolved advertised discount adapter", "adapter", adapters[0].Hex()) + return nil + } + s.log.Error(err, "batch advertised adapter resolution failed; retry individually") + + var resolved []liquidlane.Route + for _, adapter := range adapters { + adapterRoutes, err := s.reader.resolveRoutes(ctx, []common.Address{adapter}) + if err != nil { + s.log.Error(err, "skip unresolved advertised discount adapter", "adapter", adapter.Hex()) + continue + } + resolved = append(resolved, adapterRoutes...) + } + return mergeRoutes(resolved) +} + +func (s *Solver) discountInventories( + listed *liquiddiscounts.List, + physical []liquidlane.Inventory, + now time.Time, +) []liquidlane.Inventory { + inventory, issues := liquiddiscounts.MatchInventories(listed, physical, liquiddiscounts.MatchOptions{ + Now: now, AllowsToken: s.cfg.TokenPolicy.Allows, + }) + s.logDiscountIssues(issues) + return inventory +} + +func (s *Solver) discountFillQuotes( + listed *liquiddiscounts.List, + physical []liquidlane.FillQuote, + now time.Time, +) []liquidlane.FillQuote { + quotes, issues := liquiddiscounts.AdvertisedFillQuotes(listed, physical, liquiddiscounts.MatchOptions{ + Now: now, AllowsToken: s.cfg.TokenPolicy.Allows, + }) + s.logDiscountIssues(issues) + return quotes +} + +func (s *Solver) resolveDiscount( + ctx context.Context, + selection liquiddiscounts.Selection, + physical []liquidlane.FillQuote, + now time.Time, +) (*liquiddiscounts.Signed, error) { + if s.discounts == nil || s.cfg.Discounts == nil || selection.DiscountID == (common.Hash{}) { + return nil, errors.New("discount route cannot be resolved") + } + requestCtx, cancel := context.WithTimeout(ctx, s.cfg.Discounts.HTTPTimeout) + defer cancel() + return liquiddiscounts.ResolveSelected( + requestCtx, + s.discounts, + selection, + physical, + now.Add(s.cfg.Discounts.MinimumValidity), + ) +} + +func (s *Solver) logDiscountIssues(issues []liquiddiscounts.OfferIssue) { + for _, issue := range issues { + s.log.V(1).Info( + "skip invalid advertised discount", "discountId", issue.DiscountID, "error", issue.Err.Error(), + ) + } +} + +func adapterSet(adapters []common.Address) map[common.Address]bool { + set := make(map[common.Address]bool, len(adapters)) + for _, adapter := range adapters { + set[adapter] = true + } + return set +} + +func mergeRoutes(groups ...[]liquidlane.Route) []liquidlane.Route { + seen := make(map[liquidlane.RouteID]bool) + var routes []liquidlane.Route + for _, group := range groups { + for _, route := range group { + if route.ID == "" || seen[route.ID] { + continue + } + seen[route.ID] = true + routes = append(routes, route) + } + } + return routes +} + +func directInventoriesForAdapters( + inventory []liquidlane.Inventory, + adapters []common.Address, +) []liquidlane.Inventory { + allowed := adapterSet(adapters) + direct := make([]liquidlane.Inventory, 0, len(inventory)) + for _, item := range inventory { + if allowed[item.Adapter] { + direct = append(direct, item) + } + } + return direct +} + +func directFillQuotesForAdapters( + quotes []liquidlane.FillQuote, + adapters []common.Address, +) []liquidlane.FillQuote { + allowed := adapterSet(adapters) + direct := make([]liquidlane.FillQuote, 0, len(quotes)) + for _, quote := range quotes { + if allowed[quote.Adapter] { + direct = append(direct, quote) + } + } + return direct +} diff --git a/internal/solvers/uniswapx/discounts_test.go b/internal/solvers/uniswapx/discounts_test.go new file mode 100644 index 00000000..cde06389 --- /dev/null +++ b/internal/solvers/uniswapx/discounts_test.go @@ -0,0 +1,234 @@ +package uniswapx + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquiddiscounts "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" + "github.com/symbioticfi/vault-solver/internal/tokenpolicy" +) + +const testDiscountID = "0x1111111111111111111111111111111111111111111111111111111111111111" + +type fakeDiscountProvider struct { + list *liquiddiscounts.List + resolved *liquiddiscounts.Resolved + listErr error + + listCalls int +} + +func (f *fakeDiscountProvider) ListDiscounts(context.Context) (*liquiddiscounts.List, error) { + f.listCalls++ + return f.list, f.listErr +} + +func (f *fakeDiscountProvider) Resolve(context.Context, string) (*liquiddiscounts.Resolved, error) { + return f.resolved, nil +} + +func TestDiscountInventoriesUseConfiguredPhysicalRoute(t *testing.T) { + now := time.Unix(1_000, 0) + route := testDiscountRoute() + policy, _ := tokenpolicy.New(tokenpolicy.All, nil) + listed := &liquiddiscounts.List{Discounts: []liquiddiscounts.ListItem{ + testDiscountOffer(route, now.Add(time.Minute), "80", "100"), + }} + solver := &Solver{ + cfg: &Config{Discounts: &DiscountConfig{HTTPTimeout: time.Second}, TokenPolicy: policy}, + discounts: &fakeDiscountProvider{list: listed}, log: logr.Discard(), + } + physical := liquidlane.DirectInventory(route, big.NewInt(100), big.NewInt(100)) + physical.AdapterMinDiscount = new(big.Int) + inventory := solver.discountInventories(listed, []liquidlane.Inventory{physical}, now) + if len(inventory) != 1 || inventory[0].DiscountID == nil || inventory[0].MaxAssets.Cmp(big.NewInt(80)) != 0 { + t.Fatalf("discount inventory = %+v", inventory) + } +} + +func TestDiscountFillQuotesUseCurrentOracleAmount(t *testing.T) { + now := time.Unix(1_000, 0) + route := testDiscountRoute() + policy, _ := tokenpolicy.New(tokenpolicy.All, nil) + offer := testDiscountOffer(route, now.Add(time.Minute), "100", "2000000000000000000") + offer.Discount = "100000" + listed := &liquiddiscounts.List{Discounts: []liquiddiscounts.ListItem{offer}} + solver := &Solver{ + cfg: &Config{Discounts: &DiscountConfig{HTTPTimeout: time.Second}, TokenPolicy: policy}, + discounts: &fakeDiscountProvider{list: listed}, + log: logr.Discard(), + } + quotes := solver.discountFillQuotes(listed, []liquidlane.FillQuote{{ + Inventory: testInventoryWithMinDiscount( + route, big.NewInt(100), big.NewInt(2_000_000_000_000_000_000), new(big.Int), + ), + AmountIn: big.NewInt(10), + GrossAmountOut: big.NewInt(20), + MaxAmountOut: big.NewInt(20), + MinDiscount: new(big.Int), + }}, now) + if len(quotes) != 1 || quotes[0].MaxAmountOut.Cmp(big.NewInt(18)) != 0 || quotes[0].DiscountID == nil { + t.Fatalf("discount quotes = %+v", quotes) + } +} + +func TestExternalModeNeverListsDiscounts(t *testing.T) { + route := testDiscountRoute() + provider := &fakeDiscountProvider{listErr: errors.New("must not be called")} + solver := &Solver{ + cfg: &Config{ + SolverMode: solverModeExternal, + Adapters: []common.Address{route.Adapter}, + }, + discounts: provider, + log: logr.Discard(), + } + + quoteRoutes, quoted, err := solver.quoteRoutesWithDiscounts(t.Context(), []liquidlane.Route{route}, time.Now()) + if err != nil || quoted != nil || len(quoteRoutes) != 1 { + t.Fatalf("external quote routes/list/error = %+v/%+v/%v", quoteRoutes, quoted, err) + } + fillRoutes, filled, err := solver.fillRoutesWithDiscounts( + t.Context(), + []liquidlane.Route{route}, + route.TokenIn, + route.TokenOut, + time.Now(), + ) + if err != nil || filled != nil || len(fillRoutes) != 1 || provider.listCalls != 0 { + t.Fatalf( + "external fill routes/list/calls/error = %+v/%+v/%d/%v", + fillRoutes, + filled, + provider.listCalls, + err, + ) + } +} + +func TestResolveDiscountRevalidatesSelectedTerms(t *testing.T) { + now := time.Unix(1_000, 0) + route := testDiscountRoute() + physical := []liquidlane.FillQuote{{ + Inventory: testInventoryWithMinDiscount( + route, big.NewInt(100), big.NewInt(1_000_000_000_000_000_000), new(big.Int), + ), + AmountIn: big.NewInt(100), GrossAmountOut: big.NewInt(100), MaxAmountOut: big.NewInt(100), + MinDiscount: new(big.Int), + }} + selected := liquiddiscounts.Selection{ + DiscountID: common.HexToHash(testDiscountID), + Adapter: route.Adapter, + TokenIn: route.TokenIn, + TokenOut: route.TokenOut, + AmountIn: big.NewInt(100), + MinAmountOut: big.NewInt(90), + } + validResolved := func() *liquiddiscounts.Resolved { + deadline := now.Add(time.Minute).Unix() + return &liquiddiscounts.Resolved{ + DiscountID: testDiscountID, + Discount: liquiddiscounts.Terms{ + Adapter: route.Adapter.Hex(), TokenToRedeem: route.TokenIn.Hex(), Discount: "0", + Signer: common.HexToAddress("0x5555555555555555555555555555555555555555").Hex(), + Protocol: common.HexToAddress("0x6666666666666666666666666666666666666666").Hex(), + Nonce: "0x1", Deadline: deadline, + }, + SignerSignature: "0x01", ProtocolDeadline: deadline, ProtocolSignature: "0x02", + } + } + + for name, mutate := range map[string]func(*liquiddiscounts.Resolved, *liquiddiscounts.Selection){ + "discount id": func(resolved *liquiddiscounts.Resolved, _ *liquiddiscounts.Selection) { + resolved.DiscountID = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "adapter": func(resolved *liquiddiscounts.Resolved, _ *liquiddiscounts.Selection) { + resolved.Discount.Adapter = common.HexToAddress("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Hex() + }, + "token": func(resolved *liquiddiscounts.Resolved, _ *liquiddiscounts.Selection) { + resolved.Discount.TokenToRedeem = common.HexToAddress("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").Hex() + }, + "discount deadline": func(resolved *liquiddiscounts.Resolved, _ *liquiddiscounts.Selection) { + resolved.Discount.Deadline = now.Add(15 * time.Second).Unix() + }, + "protocol deadline": func(resolved *liquiddiscounts.Resolved, _ *liquiddiscounts.Selection) { + resolved.ProtocolDeadline = now.Add(15 * time.Second).Unix() + }, + "minimum output": func(_ *liquiddiscounts.Resolved, selection *liquiddiscounts.Selection) { + selection.MinAmountOut = big.NewInt(101) + }, + "adapter minimum discount": func(resolved *liquiddiscounts.Resolved, _ *liquiddiscounts.Selection) { + resolved.Discount.Discount = "0" + physical[0].MinDiscount = big.NewInt(1) + }, + } { + t.Run(name, func(t *testing.T) { + physical[0].MinDiscount = new(big.Int) + resolved := validResolved() + selection := selected + mutate(resolved, &selection) + solver := &Solver{ + cfg: &Config{Discounts: &DiscountConfig{ + HTTPTimeout: time.Second, MinimumValidity: 15 * time.Second, + }}, + discounts: &fakeDiscountProvider{resolved: resolved}, log: logr.Discard(), + } + if _, err := solver.resolveDiscount(t.Context(), selection, physical, now); err == nil { + t.Fatal("expected resolved discount rejection") + } + }) + } + + physical[0].MinDiscount = new(big.Int) + solver := &Solver{ + cfg: &Config{Discounts: &DiscountConfig{ + HTTPTimeout: time.Second, MinimumValidity: 15 * time.Second, + }}, + discounts: &fakeDiscountProvider{resolved: validResolved()}, log: logr.Discard(), + } + if _, err := solver.resolveDiscount(t.Context(), selected, physical, now); err != nil { + t.Fatalf("valid resolved discount: %v", err) + } +} + +func testDiscountRoute() liquidlane.Route { + return liquidlane.NewRoute( + 1, + common.HexToAddress("0x1111111111111111111111111111111111111111"), + common.HexToAddress("0x2222222222222222222222222222222222222222"), + common.HexToAddress("0x3333333333333333333333333333333333333333"), + common.HexToAddress("0x4444444444444444444444444444444444444444"), + 18, + 18, + ) +} + +func testDiscountOffer( + route liquidlane.Route, + deadline time.Time, + maxAssets string, + maxRate string, +) liquiddiscounts.ListItem { + return liquiddiscounts.ListItem{ + DiscountID: testDiscountID, Adapter: route.Adapter.Hex(), TokenToRedeem: route.TokenIn.Hex(), + Collateral: route.TokenOut.Hex(), CollateralDecimals: route.TokenOutDecimals, + Discount: "0", Signer: common.HexToAddress("0x5555555555555555555555555555555555555555").Hex(), + Deadline: deadline.Unix(), MaxRate: maxRate, MaxAssets: maxAssets, + } +} + +func testInventoryWithMinDiscount( + route liquidlane.Route, + maxAssets, maxRate, minDiscount *big.Int, +) liquidlane.Inventory { + inventory := liquidlane.DirectInventory(route, maxAssets, maxRate) + inventory.AdapterMinDiscount = liquidlane.CloneBig(minDiscount) + return inventory +} diff --git a/internal/solvers/uniswapx/execution.go b/internal/solvers/uniswapx/execution.go new file mode 100644 index 00000000..df654de7 --- /dev/null +++ b/internal/solvers/uniswapx/execution.go @@ -0,0 +1,401 @@ +package uniswapx + +import ( + "context" + "math/big" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + uxexecutor "github.com/symbioticfi/vault-solver/api/bindings/uniswapx/executor" + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquiddiscounts "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" + strategytypes "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/types" + "github.com/symbioticfi/vault-solver/internal/txmanager" +) + +var ( + errOrderNotFillable = errors.New("order is not fillable at current chain state") + errFillPreflight = errors.New("fill preflight failed") +) + +type pendingUniswapFill struct { + order *resolvedOrder + result <-chan txmanager.Result +} + +type uniswapFillCompletion struct { + fill *pendingUniswapFill + result txmanager.Result +} + +func (s *Solver) fillLoop( + ctx context.Context, + routes []liquidlane.Route, + orders <-chan *resolvedOrder, +) error { + completions := make(chan uniswapFillCompletion, orderQueueCapacity) + pending := make(map[common.Hash]*pendingUniswapFill) + for orders != nil || len(pending) > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case completion := <-completions: + delete(pending, completion.fill.order.Hash) + s.completePendingFill(completion) + case order, ok := <-orders: + if !ok { + orders = nil + continue + } + s.log.V(1).Info( + "order fill planning started", + "source", order.Source, + "orderHash", order.Hash.Hex(), + "quoteId", order.QuoteID, + ) + now, err := s.reader.latestBlockTime(ctx) + if err != nil { + s.retry(order.Hash, time.Now(), false) + s.log.Error(err, "order fill: read current chain time", "orderHash", order.Hash.Hex()) + continue + } + s.beginFillPlanning() + fill, err := s.startFill(ctx, routes, order, now) + s.endFillPlanning() + if err != nil { + s.retry(order.Hash, now, errors.Is(err, errFillPreflight)) + if errors.Is(err, errFillPreflight) { + s.recordOrderFillFailure(order, now) + } + if errors.Is(err, errOrderNotFillable) { + s.log.V(1).Info("order not fillable yet", "source", order.Source, + "orderHash", order.Hash.Hex(), "quoteId", order.QuoteID) + continue + } + s.log.Error(err, "order fill preparation failed", "orderHash", order.Hash.Hex(), "quoteId", order.QuoteID) + continue + } + pending[order.Hash] = fill + go awaitUniswapFill(ctx, fill, completions) + } + } + return nil +} + +func awaitUniswapFill( + ctx context.Context, + fill *pendingUniswapFill, + out chan<- uniswapFillCompletion, +) { + select { + case result, ok := <-fill.result: + if !ok { + result.Err = errors.New("transaction result channel closed without a result") + } + select { + case out <- uniswapFillCompletion{fill: fill, result: result}: + case <-ctx.Done(): + } + case <-ctx.Done(): + } +} + +func (s *Solver) startFill( + ctx context.Context, + routes []liquidlane.Route, + order *resolvedOrder, + now time.Time, +) (*pendingUniswapFill, error) { + if order.TokenOut == (common.Address{}) { + return nil, errOrderNotFillable + } + if order.Deadline == 0 || int64(order.Deadline) <= now.Unix() { + return nil, errOrderNotFillable + } + decisionRoutes, listed, discountErr := s.fillRoutesWithDiscounts( + ctx, + routes, + order.TokenIn, + order.TokenOut, + now, + ) + if discountErr != nil { + s.log.Error(discountErr, "refresh fill discount routes", "orderHash", order.Hash.Hex()) + } + snapshot, err := s.reader.fillSnapshot( + ctx, + decisionRoutes, + order.Executor, + order.TokenIn, + order.AmountIn, + now, + ) + if err != nil { + return nil, err + } + if s.cfg.usesDiscounts() { + snapshot.Direct = directFillQuotesForAdapters(snapshot.Direct, s.cfg.Adapters) + if listed != nil { + snapshot.Direct = append(snapshot.Direct, s.discountFillQuotes(listed, snapshot.Physical, now)...) + } + } + s.log.V(1).Info( + "order fill snapshot loaded", + "source", order.Source, + "orderHash", order.Hash.Hex(), + "quoteId", order.QuoteID, + "routes", len(decisionRoutes), + "fillQuotes", len(snapshot.Direct), + "physicalQuotes", len(snapshot.Physical), + ) + maxFee, err := s.txm.MaxFeePerGas(ctx) + if err != nil { + return nil, err + } + pricingMaxFee := maxFee + if s.cfg.Gas == nil { + pricingMaxFee = new(big.Int) + } + fillInput := strategytypes.FillInput{ + OrderID: order.Hash.Hex(), QuoteID: order.QuoteID, + TokenIn: order.TokenIn, TokenOut: order.TokenOut, AmountIn: order.AmountIn, OutputAmount: order.AmountOut, + Deadline: order.Deadline, + RequireSingleRoute: s.cfg.TokenPolicy.RequiresSingleRoute(order.TokenIn), Quotes: snapshot.Direct, + Reservations: s.capacity.Snapshot(), + GasSnapshot: snapshot.GasSnapshot, GasPrices: snapshot.GasPrices, MaxFeePerGas: pricingMaxFee, ChainTime: now, + Trace: s.decisionTrace( + "source", order.Source, + "orderHash", order.Hash.Hex(), + "quoteId", order.QuoteID, + ), + } + plan, err := s.strategy.DecideFill(ctx, fillInput) + if err != nil { + return nil, err + } + if plan == nil || len(plan.Routes) == 0 { + s.log.V(1).Info( + "order fill strategy declined", + "source", order.Source, + "orderHash", order.Hash.Hex(), + "quoteId", order.QuoteID, + "fillQuotes", len(fillInput.Quotes), + "amountIn", order.AmountIn.String(), + "requiredAmountOut", order.AmountOut.String(), + ) + return nil, errOrderNotFillable + } + validatedRoutes, err := liquidstrategies.ValidateFillRoutes(liquidstrategies.FillValidation{ + TokenIn: fillInput.TokenIn, TokenOut: fillInput.TokenOut, AmountIn: fillInput.AmountIn, + RequiredAmountOut: fillInput.OutputAmount, RequireSingleRoute: fillInput.RequireSingleRoute, + MaxRoutes: strategytypes.MaxRoutes, Quotes: fillInput.Quotes, Reservations: fillInput.Reservations, + GasSnapshot: fillInput.GasSnapshot, GasPrices: fillInput.GasPrices, MaxFeePerGas: fillInput.MaxFeePerGas, + GasEnvelope: strategytypes.LiquidLaneGasEnvelope(), + }, plan.Routes) + if err != nil { + return nil, errors.Errorf("strategy returned invalid fill plan: %w", err) + } + plan.Routes = validatedRoutes + s.logFillPlan(order, plan) + reservations, ok := liquidstrategies.FillRouteReservations(plan.Routes) + if !ok { + return nil, errors.New("strategy returned invalid capacity reservations") + } + data, err := s.buildExecutorCalldata(ctx, order, plan, decisionRoutes, now) + if err != nil { + return nil, err + } + if _, err := s.chain.CallContract(ctx, ethereum.CallMsg{From: s.solverAddress, To: &order.Executor, Data: data}, nil); err != nil { + return nil, errors.Errorf("%w: %v", errFillPreflight, err) + } + s.log.V(1).Info( + "order fill preflight succeeded", + "source", order.Source, + "orderHash", order.Hash.Hex(), + "quoteId", order.QuoteID, + "executor", order.Executor.Hex(), + "caller", s.solverAddress.Hex(), + "calldataBytes", len(data), + "maxFeePerGas", maxFee.String(), + "deadline", order.Deadline, + "deadlineRemaining", time.Unix(int64(order.Deadline), 0).Sub(now), + ) + result, accepted := s.txm.SendAsync(ctx, txmanager.Request{ + To: order.Executor, Data: data, MaxFeePerGas: new(big.Int).Set(maxFee), + Label: "uniswapx-fill", + }) + if !accepted { + if err := ctx.Err(); err != nil { + return nil, err + } + return nil, errors.New("transaction submission was not accepted") + } + s.setPendingReservations(order.Hash, reservations) + s.log.V(1).Info( + "order fill submitted", + "source", order.Source, + "orderHash", order.Hash.Hex(), + "quoteId", order.QuoteID, + "routes", len(plan.Routes), + "reservationDomains", len(reservations), + "maxFeePerGas", maxFee.String(), + ) + return &pendingUniswapFill{order: order, result: result}, nil +} + +func (s *Solver) buildExecutorCalldata( + ctx context.Context, + order *resolvedOrder, + plan *strategytypes.FillPlan, + routes []liquidlane.Route, + now time.Time, +) ([]byte, error) { + fillRoutes := make([]uxexecutor.ILiquidLaneUniswapXExecutorFillRoute, 0, len(plan.Routes)) + discountRoutes := make([]uxexecutor.ILiquidLaneUniswapXExecutorDiscountRoute, 0, len(plan.Routes)) + for _, route := range plan.Routes { + if route.DiscountID == nil { + fillRoutes = append(fillRoutes, uxexecutor.ILiquidLaneUniswapXExecutorFillRoute{ + Adapter: route.Adapter, AmountIn: route.AmountIn, AmountOut: route.MinAmountOut, + }) + continue + } + selectedRoute, ok := findRoute(routes, route.RouteID) + if !ok { + return nil, errors.Errorf("selected discount route %s is unavailable", route.RouteID) + } + s.log.V(1).Info( + "selected discount route repricing", + "orderHash", order.Hash.Hex(), + "quoteId", order.QuoteID, + "discountId", route.DiscountID.Hex(), + "routeId", route.RouteID, + "adapter", route.Adapter.Hex(), + "amountIn", route.AmountIn.String(), + ) + physicalQuotes, err := s.reader.physicalFillQuotes( + ctx, + []liquidlane.Route{selectedRoute}, + order.TokenIn, + route.AmountIn, + ) + if err != nil { + return nil, errors.Errorf("reprice selected discount %s: %w", route.DiscountID.Hex(), err) + } + signed, err := s.resolveDiscount(ctx, liquiddiscounts.Selection{ + DiscountID: *route.DiscountID, + Adapter: route.Adapter, + TokenIn: order.TokenIn, + TokenOut: order.TokenOut, + AmountIn: route.AmountIn, + MinAmountOut: route.MinAmountOut, + }, physicalQuotes, now) + if err != nil { + return nil, errors.Errorf("resolve selected discount %s: %w", route.DiscountID.Hex(), err) + } + s.log.V(1).Info( + "selected discount resolved", + "orderHash", order.Hash.Hex(), + "quoteId", order.QuoteID, + "discountId", route.DiscountID.Hex(), + "routeId", route.RouteID, + "adapter", route.Adapter.Hex(), + "amountIn", route.AmountIn.String(), + "discountDeadline", signed.Terms.Deadline, + "protocolDeadline", signed.ProtocolDeadline, + ) + discountRoutes = append(discountRoutes, uxexecutor.ILiquidLaneUniswapXExecutorDiscountRoute{ + Adapter: route.Adapter, AmountIn: route.AmountIn, + DiscountSwap: uxexecutor.ILiquidLaneAdapterDiscountSwap{ + Discount: uxexecutor.ILiquidLaneAdapterDiscount{ + TokenToRedeem: signed.Terms.TokenToRedeem, + Discount: signed.Terms.Discount, + Signer: signed.Terms.Signer, + Protocol: signed.Terms.Protocol, + Nonce: signed.Terms.Nonce, + Deadline: signed.Terms.Deadline, + }, + SignerSignature: signed.SignerSignature, ProtocolDeadline: signed.ProtocolDeadline, + }, + ProtocolSignature: signed.ProtocolSignature, + }) + } + return uniswapXExecutor.TryPackExecute( + uxexecutor.UniswapXSignedOrder{Order: order.Encoded, Sig: order.Signature}, + uxexecutor.ILiquidLaneUniswapXExecutorFillCall{Routes: fillRoutes, DiscountRoutes: discountRoutes}, + ) +} + +func findRoute(routes []liquidlane.Route, id liquidlane.RouteID) (liquidlane.Route, bool) { + for _, route := range routes { + if route.ID == id { + return route, true + } + } + return liquidlane.Route{}, false +} + +func (s *Solver) logFillPlan(order *resolvedOrder, plan *strategytypes.FillPlan) { + discountRoutes := 0 + for index, route := range plan.Routes { + if route.DiscountID != nil { + discountRoutes++ + } + fields := []any{ + "source", order.Source, + "orderHash", order.Hash.Hex(), + "quoteId", order.QuoteID, + "route", index, + "routeId", route.RouteID, + "adapter", route.Adapter.Hex(), + "amountIn", route.AmountIn.String(), + "expectedAmountOut", route.ExpectedAmountOut.String(), + "minAmountOut", route.MinAmountOut.String(), + "reservedAmountOut", route.ReservedAmountOut.String(), + "capacityId", route.CapacityID, + "private", route.DiscountID != nil, + } + if route.DiscountID != nil { + fields = append(fields, "discountId", route.DiscountID.Hex()) + } + s.log.V(1).Info("order fill route selected", fields...) + } + s.log.V(1).Info( + "order fill plan selected", + "source", order.Source, + "orderHash", order.Hash.Hex(), + "quoteId", order.QuoteID, + "routes", len(plan.Routes), + "discountRoutes", discountRoutes, + ) +} + +func (s *Solver) completePendingFill(completion uniswapFillCompletion) { + order := completion.fill.order + now := time.Now() + s.clearPendingReservations(order.Hash) + if completion.result.Err != nil { + s.retry(order.Hash, now, true) + s.recordOrderFillFailure(order, now) + s.observeFill("failed") + s.log.Error(completion.result.Err, "order fill failed", "source", order.Source, + "orderHash", order.Hash.Hex(), "quoteId", order.QuoteID, "tx", completion.result.Hash.Hex()) + return + } + s.recordFillSuccess() + s.complete(order.Hash, now) + s.observeFill("filled") + s.log.Info("order filled", "source", order.Source, "executor", order.Executor.Hex(), + "orderHash", order.Hash.Hex(), "quoteId", order.QuoteID, "tx", completion.result.Hash.Hex()) +} + +func (s *Solver) recordOrderFillFailure(order *resolvedOrder, now time.Time) { + // An exclusive attempt can legitimately lose to a timely soft override. Its tracked + // obligation is classified from terminal API and canonical receipt state after the deadline. + if order.Source != orderSourceExclusiveV2 { + s.recordFillFailure(now) + } +} diff --git a/internal/solvers/uniswapx/execution_test.go b/internal/solvers/uniswapx/execution_test.go new file mode 100644 index 00000000..f37fa134 --- /dev/null +++ b/internal/solvers/uniswapx/execution_test.go @@ -0,0 +1,487 @@ +package uniswapx + +import ( + "context" + "math/big" + "testing" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + + uxexecutor "github.com/symbioticfi/vault-solver/api/bindings/uniswapx/executor" + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquiddiscounts "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" + strategytypes "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/types" + "github.com/symbioticfi/vault-solver/internal/tokenpolicy" + "github.com/symbioticfi/vault-solver/internal/txmanager" +) + +type executionTestReader struct { + chainReader + + resolved []liquidlane.Route + adapters []common.Address + fillRoutes []liquidlane.Route + fillAmounts []*big.Int + fillQuoteRoutes [][]liquidlane.Route + fillQuoteAmounts []*big.Int + snapshot fillSnapshot + fillSnapshotFn func([]liquidlane.Route, *big.Int) fillSnapshot +} + +func (r *executionTestReader) resolveRoutes( + _ context.Context, + adapters []common.Address, +) ([]liquidlane.Route, error) { + r.adapters = append([]common.Address(nil), adapters...) + return append([]liquidlane.Route(nil), r.resolved...), nil +} + +func (r *executionTestReader) validateGasTokens([]liquidlane.Route) error { return nil } + +func TestStartFillEncodesResolvedDiscountRoute(t *testing.T) { + now := time.Unix(1_000, 0) + route := testDiscountRoute() + configuredRoute := liquidlane.NewRoute( + 1, + common.HexToAddress("0x9999999999999999999999999999999999999999"), + common.HexToAddress("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + route.TokenIn, + route.TokenOut, + route.TokenInDecimals, + route.TokenOutDecimals, + ) + strategy := &executionTestStrategy{plan: &strategytypes.FillPlan{Routes: []strategytypes.FillRoute{{ + RouteID: route.ID, CapacityID: route.CapacityID, Adapter: route.Adapter, + AmountIn: big.NewInt(100), ExpectedAmountOut: big.NewInt(100), MinAmountOut: big.NewInt(90), + ReservedAmountOut: big.NewInt(100), DiscountID: hashPointer(common.HexToHash(testDiscountID)), + }}}} + policy, _ := tokenpolicy.New(tokenpolicy.All, nil) + deadline := now.Add(time.Minute).Unix() + provider := &fakeDiscountProvider{ + list: &liquiddiscounts.List{Discounts: []liquiddiscounts.ListItem{ + testDiscountOffer(route, now.Add(time.Minute), "100", "1000000000000000000"), + }}, + resolved: &liquiddiscounts.Resolved{ + DiscountID: testDiscountID, + Discount: liquiddiscounts.Terms{ + Adapter: route.Adapter.Hex(), TokenToRedeem: route.TokenIn.Hex(), Discount: "0", + Signer: common.HexToAddress("0x5555555555555555555555555555555555555555").Hex(), + Protocol: common.HexToAddress("0x6666666666666666666666666666666666666666").Hex(), + Nonce: "0x1", Deadline: deadline, + }, + SignerSignature: "0x01", ProtocolDeadline: deadline, ProtocolSignature: "0x02", + }, + } + physicalQuote := liquidlane.FillQuote{ + Inventory: testInventoryWithMinDiscount( + route, big.NewInt(100), big.NewInt(1_000_000_000_000_000_000), new(big.Int), + ), + AmountIn: big.NewInt(100), GrossAmountOut: big.NewInt(100), MaxAmountOut: big.NewInt(100), + MinDiscount: new(big.Int), + } + var packed uxexecutor.ILiquidLaneUniswapXExecutorFillCall + reader := &executionTestReader{ + resolved: []liquidlane.Route{route}, + snapshot: fillSnapshot{ + Direct: []liquidlane.FillQuote{physicalQuote}, + Physical: []liquidlane.FillQuote{physicalQuote}, + }, + } + solver := &Solver{ + cfg: &Config{ + Executor: common.HexToAddress("0x7777777777777777777777777777777777777777"), TokenPolicy: policy, + Adapters: []common.Address{configuredRoute.Adapter}, + SolverMode: solverModeInternal, + Discounts: &DiscountConfig{HTTPTimeout: time.Second, MinimumValidity: 15 * time.Second}, + OrderServer: OrderServerConfig{PollInterval: time.Second}, + }, + solverAddress: common.HexToAddress("0x8888888888888888888888888888888888888888"), + chain: contractCallerFunc(func(_ context.Context, call ethereum.CallMsg, _ *big.Int) ([]byte, error) { + parsed, err := uxexecutor.LiquidLaneUniswapXExecutorMetaData.ParseABI() + if err != nil { + return nil, err + } + values, err := parsed.Methods["execute"].Inputs.Unpack(call.Data[4:]) + if err != nil { + return nil, err + } + packed = *abi.ConvertType(values[1], new(uxexecutor.ILiquidLaneUniswapXExecutorFillCall)).(*uxexecutor.ILiquidLaneUniswapXExecutorFillCall) + return nil, nil + }), + reader: reader, + strategy: strategy, txm: &executionTestTxManager{result: make(chan txmanager.Result, 1)}, + discounts: provider, log: logr.Discard(), + filled: make(map[common.Hash]time.Time), retryAt: make(map[common.Hash]time.Time), + inFlight: make(map[common.Hash]bool), attempts: make(map[common.Hash]int), + } + order := &resolvedOrder{ + Encoded: []byte{1}, Signature: []byte{2}, Hash: common.HexToHash("0x1"), Source: orderSourcePublicV2, + Executor: solver.cfg.Executor, TokenIn: route.TokenIn, TokenOut: route.TokenOut, + AmountIn: big.NewInt(100), AmountOut: big.NewInt(90), Deadline: uint32(now.Add(time.Minute).Unix()), + } + if _, err := solver.startFill(t.Context(), []liquidlane.Route{configuredRoute}, order, now); err != nil { + t.Fatalf("startFill: %v", err) + } + if len(reader.adapters) != 1 || reader.adapters[0] != route.Adapter || + len(reader.fillRoutes) != 2 { + t.Fatalf("dynamic fill routes: adapters=%+v routes=%+v", reader.adapters, reader.fillRoutes) + } + if len(strategy.input.Quotes) != 1 || strategy.input.Quotes[0].DiscountID == nil || + strategy.input.Quotes[0].Adapter != route.Adapter { + t.Fatalf("fill candidates leaked dynamic direct route: %+v", strategy.input.Quotes) + } + if len(packed.Routes) != 0 || len(packed.DiscountRoutes) != 1 { + t.Fatalf("packed fill call = %+v", packed) + } + discountRoute := packed.DiscountRoutes[0] + if discountRoute.Adapter != route.Adapter || + discountRoute.AmountIn.Cmp(big.NewInt(100)) != 0 || + discountRoute.DiscountSwap.Discount.TokenToRedeem != route.TokenIn { + t.Fatalf("packed discount route = %+v", discountRoute) + } +} + +func TestStartFillRepricesPartialDiscountLeg(t *testing.T) { + now := time.Unix(1_000, 0) + discountRoute := testDiscountRoute() + directRoute := liquidlane.NewRoute( + 1, + common.HexToAddress("0x9999999999999999999999999999999999999999"), + common.HexToAddress("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + discountRoute.TokenIn, + discountRoute.TokenOut, + discountRoute.TokenInDecimals, + discountRoute.TokenOutDecimals, + ) + strategy := &executionTestStrategy{plan: &strategytypes.FillPlan{Routes: []strategytypes.FillRoute{ + { + RouteID: directRoute.ID, CapacityID: directRoute.CapacityID, Adapter: directRoute.Adapter, + AmountIn: big.NewInt(60), ExpectedAmountOut: big.NewInt(60), MinAmountOut: big.NewInt(50), + ReservedAmountOut: big.NewInt(60), + }, + { + RouteID: discountRoute.ID, CapacityID: discountRoute.CapacityID, Adapter: discountRoute.Adapter, + AmountIn: big.NewInt(40), ExpectedAmountOut: big.NewInt(40), MinAmountOut: big.NewInt(40), + ReservedAmountOut: big.NewInt(40), DiscountID: hashPointer(common.HexToHash(testDiscountID)), + }, + }}} + fullDirect := liquidlane.FillQuote{ + Inventory: liquidlane.DirectInventory( + directRoute, big.NewInt(100), big.NewInt(1_000_000_000_000_000_000), + ), + AmountIn: big.NewInt(100), GrossAmountOut: big.NewInt(100), MaxAmountOut: big.NewInt(100), + MinDiscount: new(big.Int), + } + fullDiscount := liquidlane.FillQuote{ + Inventory: testInventoryWithMinDiscount( + discountRoute, big.NewInt(100), big.NewInt(1_000_000_000_000_000_000), new(big.Int), + ), + AmountIn: big.NewInt(100), GrossAmountOut: big.NewInt(100), MaxAmountOut: big.NewInt(100), + MinDiscount: new(big.Int), + } + partialDiscount := fullDiscount + partialDiscount.AmountIn = big.NewInt(40) + partialDiscount.GrossAmountOut = big.NewInt(41) + partialDiscount.MaxAmountOut = big.NewInt(41) + + reader := &executionTestReader{resolved: []liquidlane.Route{discountRoute}} + reader.fillSnapshotFn = func(routes []liquidlane.Route, amountIn *big.Int) fillSnapshot { + if amountIn.Cmp(big.NewInt(100)) == 0 { + return fillSnapshot{ + Direct: []liquidlane.FillQuote{fullDirect, fullDiscount}, + Physical: []liquidlane.FillQuote{ + fullDirect, + fullDiscount, + }, + } + } + if len(routes) == 1 && routes[0].ID == discountRoute.ID && amountIn.Cmp(big.NewInt(40)) == 0 { + return fillSnapshot{Physical: []liquidlane.FillQuote{partialDiscount}} + } + t.Fatalf("unexpected fill snapshot request: routes=%+v amountIn=%s", routes, amountIn) + return fillSnapshot{} + } + policy, _ := tokenpolicy.New(tokenpolicy.All, nil) + deadline := now.Add(time.Minute).Unix() + provider := &fakeDiscountProvider{ + list: &liquiddiscounts.List{Discounts: []liquiddiscounts.ListItem{ + testDiscountOffer(discountRoute, now.Add(time.Minute), "100", "1000000000000000000"), + }}, + resolved: &liquiddiscounts.Resolved{ + DiscountID: testDiscountID, + Discount: liquiddiscounts.Terms{ + Adapter: discountRoute.Adapter.Hex(), TokenToRedeem: discountRoute.TokenIn.Hex(), Discount: "0", + Signer: common.HexToAddress("0x5555555555555555555555555555555555555555").Hex(), + Protocol: common.HexToAddress("0x6666666666666666666666666666666666666666").Hex(), + Nonce: "0x1", Deadline: deadline, + }, + SignerSignature: "0x01", ProtocolDeadline: deadline, ProtocolSignature: "0x02", + }, + } + var packed uxexecutor.ILiquidLaneUniswapXExecutorFillCall + solver := &Solver{ + cfg: &Config{ + Executor: common.HexToAddress("0x7777777777777777777777777777777777777777"), TokenPolicy: policy, + Adapters: []common.Address{directRoute.Adapter}, + SolverMode: solverModeInternal, + Discounts: &DiscountConfig{HTTPTimeout: time.Second, MinimumValidity: 15 * time.Second}, + OrderServer: OrderServerConfig{PollInterval: time.Second}, + }, + solverAddress: common.HexToAddress("0x8888888888888888888888888888888888888888"), + chain: contractCallerFunc(func(_ context.Context, call ethereum.CallMsg, _ *big.Int) ([]byte, error) { + parsed, err := uxexecutor.LiquidLaneUniswapXExecutorMetaData.ParseABI() + if err != nil { + return nil, err + } + values, err := parsed.Methods["execute"].Inputs.Unpack(call.Data[4:]) + if err != nil { + return nil, err + } + packed = *abi.ConvertType( + values[1], new(uxexecutor.ILiquidLaneUniswapXExecutorFillCall), + ).(*uxexecutor.ILiquidLaneUniswapXExecutorFillCall) + return nil, nil + }), + reader: reader, strategy: strategy, + txm: &executionTestTxManager{result: make(chan txmanager.Result, 1)}, + discounts: provider, log: logr.Discard(), + filled: make(map[common.Hash]time.Time), retryAt: make(map[common.Hash]time.Time), + inFlight: make(map[common.Hash]bool), attempts: make(map[common.Hash]int), + } + order := &resolvedOrder{ + Encoded: []byte{1}, Signature: []byte{2}, Hash: common.HexToHash("0x1"), Source: orderSourcePublicV2, + Executor: solver.cfg.Executor, TokenIn: discountRoute.TokenIn, TokenOut: discountRoute.TokenOut, + AmountIn: big.NewInt(100), AmountOut: big.NewInt(90), Deadline: uint32(now.Add(time.Minute).Unix()), + } + + if _, err := solver.startFill(t.Context(), []liquidlane.Route{directRoute}, order, now); err != nil { + t.Fatalf("startFill: %v", err) + } + if len(reader.fillAmounts) != 1 || reader.fillAmounts[0].Cmp(big.NewInt(100)) != 0 || + len(reader.fillQuoteAmounts) != 1 || reader.fillQuoteAmounts[0].Cmp(big.NewInt(40)) != 0 { + t.Fatalf("fill snapshot/quote amounts = %v/%v, want [100]/[40]", + reader.fillAmounts, reader.fillQuoteAmounts) + } + if len(reader.fillQuoteRoutes) != 1 || len(reader.fillQuoteRoutes[0]) != 1 || + reader.fillQuoteRoutes[0][0].ID != discountRoute.ID { + t.Fatalf("repriced routes = %+v, want only %s", reader.fillQuoteRoutes, discountRoute.ID) + } + if len(packed.Routes) != 1 || len(packed.DiscountRoutes) != 1 || + packed.DiscountRoutes[0].AmountIn.Cmp(big.NewInt(40)) != 0 { + t.Fatalf("packed split fill = %+v", packed) + } +} + +func hashPointer(hash common.Hash) *common.Hash { return &hash } + +func (r *executionTestReader) fillSnapshot( + _ context.Context, + routes []liquidlane.Route, + _ common.Address, + _ common.Address, + amountIn *big.Int, + _ time.Time, +) (fillSnapshot, error) { + r.fillRoutes = append([]liquidlane.Route(nil), routes...) + r.fillAmounts = append(r.fillAmounts, new(big.Int).Set(amountIn)) + if r.fillSnapshotFn != nil { + return r.fillSnapshotFn(routes, amountIn), nil + } + return r.snapshot, nil +} + +func (r *executionTestReader) physicalFillQuotes( + _ context.Context, + routes []liquidlane.Route, + _ common.Address, + amountIn *big.Int, +) ([]liquidlane.FillQuote, error) { + r.fillQuoteRoutes = append(r.fillQuoteRoutes, append([]liquidlane.Route(nil), routes...)) + r.fillQuoteAmounts = append(r.fillQuoteAmounts, new(big.Int).Set(amountIn)) + if r.fillSnapshotFn != nil { + return r.fillSnapshotFn(routes, amountIn).Physical, nil + } + return r.snapshot.Physical, nil +} + +func TestStartFillRejectsExpiredOrderBeforeStrategy(t *testing.T) { + now := time.Unix(1_000, 0) + strategy := &executionTestStrategy{} + solver := &Solver{strategy: strategy} + order := &resolvedOrder{ + TokenOut: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Deadline: uint32(now.Unix()), + } + + if _, err := solver.startFill(t.Context(), nil, order, now); !errors.Is(err, errOrderNotFillable) { + t.Fatalf("startFill error = %v, want %v", err, errOrderNotFillable) + } + if strategy.input.OrderID != "" { + t.Fatal("expired order reached strategy") + } +} + +type executionTestStrategy struct { + input strategytypes.FillInput + plan *strategytypes.FillPlan +} + +func (s *executionTestStrategy) DecideQuote( + context.Context, + strategytypes.QuoteInput, +) (*strategytypes.Quote, error) { + return nil, nil +} + +func (s *executionTestStrategy) DecideFill( + _ context.Context, + input strategytypes.FillInput, +) (*strategytypes.FillPlan, error) { + s.input = input + return s.plan, nil +} + +type executionTestTxManager struct { + result chan txmanager.Result + maxFee *big.Int + maxFeeReads int + sent int + reqs []txmanager.Request +} + +func (m *executionTestTxManager) MaxFeePerGas(context.Context) (*big.Int, error) { + m.maxFeeReads++ + if m.maxFee != nil { + return new(big.Int).Set(m.maxFee), nil + } + return new(big.Int), nil +} + +func (m *executionTestTxManager) SendAsync( + _ context.Context, + request txmanager.Request, +) (<-chan txmanager.Result, bool) { + m.sent++ + m.reqs = append(m.reqs, request) + return m.result, true +} + +type contractCallerFunc func(context.Context, ethereum.CallMsg, *big.Int) ([]byte, error) + +func (f contractCallerFunc) CallContract( + ctx context.Context, + call ethereum.CallMsg, + blockNumber *big.Int, +) ([]byte, error) { + return f(ctx, call, blockNumber) +} + +func TestStartFillSubmitsAsynchronouslyAndReservesCapacity(t *testing.T) { + now := time.Now() + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + executor := common.HexToAddress("0x3333333333333333333333333333333333333333") + adapter := common.HexToAddress("0x4444444444444444444444444444444444444444") + route := liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", Adapter: adapter, + TokenIn: tokenIn, TokenOut: tokenOut, TokenInDecimals: 18, TokenOutDecimals: 18, + } + strategy := &executionTestStrategy{plan: &strategytypes.FillPlan{Routes: []strategytypes.FillRoute{{ + RouteID: route.ID, CapacityID: route.CapacityID, Adapter: adapter, + AmountIn: big.NewInt(100), ExpectedAmountOut: big.NewInt(100), MinAmountOut: big.NewInt(90), + ReservedAmountOut: big.NewInt(100), + }}}} + txm := &executionTestTxManager{result: make(chan txmanager.Result, 1), maxFee: big.NewInt(123)} + var packed uxexecutor.ILiquidLaneUniswapXExecutorFillCall + solver := &Solver{ + cfg: &Config{Executor: executor, OrderServer: OrderServerConfig{PollInterval: time.Second}}, + solverAddress: common.HexToAddress("0x5555555555555555555555555555555555555555"), + chain: contractCallerFunc(func(_ context.Context, call ethereum.CallMsg, _ *big.Int) ([]byte, error) { + parsed, err := uxexecutor.LiquidLaneUniswapXExecutorMetaData.ParseABI() + if err != nil { + return nil, err + } + values, err := parsed.Methods["execute"].Inputs.Unpack(call.Data[4:]) + if err != nil { + return nil, err + } + packed = *abi.ConvertType( + values[1], new(uxexecutor.ILiquidLaneUniswapXExecutorFillCall), + ).(*uxexecutor.ILiquidLaneUniswapXExecutorFillCall) + return nil, nil + }), + reader: &executionTestReader{snapshot: fillSnapshot{Direct: []liquidlane.FillQuote{{ + Inventory: liquidlane.DirectInventory(route, big.NewInt(100), big.NewInt(1_000_000_000_000_000_000)), + AmountIn: big.NewInt(100), MaxAmountOut: big.NewInt(100), + }}}}, strategy: strategy, txm: txm, log: logr.Discard(), + filled: make(map[common.Hash]time.Time), retryAt: make(map[common.Hash]time.Time), + inFlight: make(map[common.Hash]bool), attempts: make(map[common.Hash]int), + } + order := &resolvedOrder{ + Encoded: []byte{1}, Signature: []byte{2}, Hash: common.HexToHash("0x1"), QuoteID: "quote-1", + Source: orderSourceExclusiveV2, + Executor: executor, TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(100), AmountOut: big.NewInt(90), + Deadline: uint32(now.Add(time.Minute).Unix()), ExclusiveUntil: uint64(now.Add(30 * time.Second).Unix()), + } + solver.trackExclusive(order, now) + pending, err := solver.startFill(t.Context(), []liquidlane.Route{route}, order, now) + if err != nil { + t.Fatalf("startFill: %v", err) + } + if pending == nil || txm.sent != 1 { + t.Fatalf("pending/sent = %v/%d", pending, txm.sent) + } + if strategy.input.MaxFeePerGas.Sign() != 0 { + t.Fatalf("strategy max fee = %v, want zero with gas accounting disabled", strategy.input.MaxFeePerGas) + } + if txm.reqs[0].MaxFeePerGas.Cmp(big.NewInt(123)) != 0 { + t.Fatalf("transaction max fee = %s, want 123", txm.reqs[0].MaxFeePerGas) + } + if txm.reqs[0].Confirmations != nil { + t.Fatalf("fill confirmations override = %d, want global txmanager configuration", *txm.reqs[0].Confirmations) + } + if len(packed.Routes) != 1 || packed.Routes[0].AmountOut.Cmp(big.NewInt(90)) != 0 || + len(packed.DiscountRoutes) != 0 { + t.Fatalf("packed direct fill call = %+v", packed) + } + if got := solver.capacity.Snapshot()[route.CapacityID]; got == nil || got.Cmp(big.NewInt(100)) != 0 { + t.Fatalf("pending reservation = %v", got) + } + if reservations := strategy.input.Reservations; len(reservations) != 0 { + t.Fatalf("unexpected pre-existing reservations: %v", reservations) + } + txm.result <- txmanager.Result{Hash: common.HexToHash("0x2")} + result := <-pending.result + solver.completePendingFill(uniswapFillCompletion{fill: pending, result: result}) + if solver.capacity.Len() != 0 { + t.Fatal("pending reservation was not released") + } + if _, pending := solver.exclusiveUntil[order.Hash]; !pending { + t.Fatal("successful tx cleared exclusive obligation before its canonical block time was reconciled") + } +} + +func TestExclusiveExecutionFailureWaitsForTerminalReconciliation(t *testing.T) { + now := time.Now() + solver := &Solver{ + cfg: &Config{Breaker: BreakerConfig{MaxFailures: 1, Window: time.Minute}}, + log: logr.Discard(), + } + + solver.recordOrderFillFailure(&resolvedOrder{Source: orderSourceExclusiveV2}, now) + + if len(solver.failureTimes) != 0 || solver.localBlockUntil.Load() != 0 { + t.Fatal("exclusive execution failure opened the ordinary local breaker") + } + + solver.recordOrderFillFailure(&resolvedOrder{Source: orderSourcePublicV2}, now) + + if solver.localBlockUntil.Load() == 0 { + t.Fatal("public execution failure did not open the ordinary local breaker") + } +} diff --git a/internal/solvers/uniswapx/health.go b/internal/solvers/uniswapx/health.go new file mode 100644 index 00000000..35df9975 --- /dev/null +++ b/internal/solvers/uniswapx/health.go @@ -0,0 +1,56 @@ +package uniswapx + +import ( + "net/http" + "time" +) + +func (s *Solver) exclusiveDeliveryHealthy() bool { + if s.exclusiveStateUnknown.Load() { + return false + } + last := s.lastExclusivePoll.Load() + if last == 0 { + return true + } + maxAge := max(3*s.cfg.OrderServer.PollInterval, 5*time.Second) + return time.Since(time.Unix(last, 0)) <= maxAge +} + +func (s *Solver) markExclusiveStateUnknown() { + s.exclusiveStateUnknown.Store(true) + s.invalidateQuotes() +} + +func (s *Solver) markExclusivePollFailure() { + if !s.exclusiveDeliveryHealthy() { + s.invalidateQuotes() + } +} + +func (s *Solver) ready() bool { + now := time.Now() + lastPoll := s.lastExclusivePoll.Load() + epoch := s.quoteEpoch.Load() + state := s.quoteState.Load() + ready := lastPoll > 0 && !s.quoteBlocked(now.Unix()) && + state != nil && len(state.inventory) > 0 && + state.epoch == epoch && state.expiresAt.After(now) && + s.quoteEpoch.Load() == epoch && s.quoteState.Load() == state + if s.metrics != nil { + if ready { + s.metrics.ready.Set(1) + } else { + s.metrics.ready.Set(0) + } + } + return ready +} + +func (s *Solver) readyHandler(w http.ResponseWriter, _ *http.Request) { + if !s.ready() { + http.Error(w, "not ready", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/solvers/uniswapx/health_test.go b/internal/solvers/uniswapx/health_test.go new file mode 100644 index 00000000..ae77d51a --- /dev/null +++ b/internal/solvers/uniswapx/health_test.go @@ -0,0 +1,49 @@ +package uniswapx + +import ( + "testing" + "time" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +func TestReadyRequiresFreshDeliveryAndQuoteState(t *testing.T) { + now := time.Now() + solver := &Solver{cfg: &Config{ + QuoteServer: QuoteServerConfig{QuoteTTL: 30 * time.Second}, + OrderServer: OrderServerConfig{PollInterval: time.Second}, + }} + solver.lastExclusivePoll.Store(now.Unix()) + if solver.ready() { + t.Fatal("solver without a quote state should not be ready") + } + solver.quoteState.Store("eState{epoch: solver.quoteEpoch.Load(), expiresAt: now.Add(30 * time.Second)}) + if solver.ready() { + t.Fatal("solver without quote inventory should not be ready") + } + solver.quoteState.Store("eState{ + epoch: solver.quoteEpoch.Load(), expiresAt: now.Add(30 * time.Second), + inventory: []liquidlane.Inventory{{}}, + }) + if !solver.ready() { + t.Fatal("fresh solver should be ready") + } + solver.beginFillPlanning() + if solver.ready() { + t.Fatal("solver planning a fill should not be ready") + } + solver.endFillPlanning() + solver.quoteState.Store("eState{ + epoch: solver.quoteEpoch.Load(), expiresAt: now.Add(30 * time.Second), + inventory: []liquidlane.Inventory{{}}, + }) + solver.warmupUntil.Store(now.Add(time.Minute).Unix()) + if solver.ready() { + t.Fatal("warmup solver should not be ready") + } + solver.warmupUntil.Store(0) + solver.lastExclusivePoll.Store(now.Add(-time.Minute).Unix()) + if solver.ready() { + t.Fatal("solver with stale exclusive delivery should not be ready") + } +} diff --git a/internal/solvers/uniswapx/metrics.go b/internal/solvers/uniswapx/metrics.go new file mode 100644 index 00000000..d88f5b89 --- /dev/null +++ b/internal/solvers/uniswapx/metrics.go @@ -0,0 +1,87 @@ +package uniswapx + +import ( + "time" + + "github.com/go-errors/errors" + "github.com/prometheus/client_golang/prometheus" +) + +type uniswapXMetrics struct { + quotes *prometheus.CounterVec + quoteTime prometheus.Histogram + polls *prometheus.CounterVec + fills *prometheus.CounterVec + blockUntil prometheus.Gauge + ready prometheus.Gauge + quoteRefresh prometheus.Gauge + exclusivePoll prometheus.Gauge + pendingFills prometheus.Gauge +} + +func newUniswapXMetrics(reg prometheus.Registerer) (*uniswapXMetrics, error) { + m := &uniswapXMetrics{ + quotes: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "uniswapx_quote_requests_total", Help: "UniswapX quote requests by bounded outcome.", + }, []string{"outcome"}), + quoteTime: prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "uniswapx_quote_duration_seconds", Help: "UniswapX quote handler latency.", + Buckets: []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5}, + }), + polls: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "uniswapx_order_polls_total", Help: "UniswapX order polls by source and outcome.", + }, []string{"source", "outcome"}), + fills: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "uniswapx_fills_total", Help: "UniswapX fill attempts by outcome.", + }, []string{"outcome"}), + blockUntil: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "uniswapx_block_until_timestamp", Help: "Unix timestamp until which UniswapX quoting is blocked.", + }), + ready: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "uniswapx_ready", Help: "1 when quote cache and exclusive order delivery are healthy.", + }), + quoteRefresh: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "uniswapx_last_quote_refresh_timestamp", Help: "Unix timestamp of the last successful quote refresh.", + }), + exclusivePoll: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "uniswapx_last_exclusive_poll_timestamp", Help: "Unix timestamp of the last successful exclusive order poll.", + }), + pendingFills: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "uniswapx_pending_fills", Help: "Current asynchronous UniswapX fills awaiting a transaction result.", + }), + } + collectors := []prometheus.Collector{ + m.quotes, m.quoteTime, m.polls, m.fills, m.blockUntil, m.ready, + m.quoteRefresh, m.exclusivePoll, m.pendingFills, + } + for _, collector := range collectors { + if err := reg.Register(collector); err != nil { + return nil, errors.Errorf("uniswapx: register metric: %w", err) + } + } + return m, nil +} + +func (s *Solver) observeQuote(outcome string) { + if s.metrics != nil { + s.metrics.quotes.WithLabelValues(outcome).Inc() + } +} + +func (m *uniswapXMetrics) observeQuoteLatency(elapsed time.Duration) { + if m != nil { + m.quoteTime.Observe(elapsed.Seconds()) + } +} + +func (s *Solver) observePoll(source, outcome string) { + if s.metrics != nil { + s.metrics.polls.WithLabelValues(source, outcome).Inc() + } +} + +func (s *Solver) observeFill(outcome string) { + if s.metrics != nil { + s.metrics.fills.WithLabelValues(outcome).Inc() + } +} diff --git a/internal/solvers/uniswapx/middleware.go b/internal/solvers/uniswapx/middleware.go new file mode 100644 index 00000000..f036c6c5 --- /dev/null +++ b/internal/solvers/uniswapx/middleware.go @@ -0,0 +1,20 @@ +package uniswapx + +import ( + "net/http" + + "github.com/go-errors/errors" + "github.com/go-logr/logr" +) + +func recoverQuoteServer(next http.Handler, log logr.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + defer func() { + if recovered := recover(); recovered != nil { + log.Error(errors.Errorf("panic: %v", recovered), "quote server panic", "path", request.URL.Path) + http.Error(w, "internal server error", http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, request) + }) +} diff --git a/internal/solvers/uniswapx/order.go b/internal/solvers/uniswapx/order.go new file mode 100644 index 00000000..7eb716dc --- /dev/null +++ b/internal/solvers/uniswapx/order.go @@ -0,0 +1,426 @@ +package uniswapx + +import ( + "math/big" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/go-errors/errors" +) + +const ( + v2OrderInfoType = "OrderInfo(address reactor,address swapper,uint256 nonce,uint256 deadline,address additionalValidationContract,bytes additionalValidationData)" + v2OutputType = "DutchOutput(address token,uint256 startAmount,uint256 endAmount,address recipient)" + v2WitnessType = "V2DutchOrder(OrderInfo info,address cosigner,address baseInputToken,uint256 baseInputStartAmount,uint256 baseInputEndAmount,DutchOutput[] baseOutputs)" + + v2OutputType + v2OrderInfoType +) + +type orderSource string + +const ( + orderSourceExclusiveV2 orderSource = "exclusive-v2" + orderSourcePublicV2 orderSource = "public-v2" + orderStatusOpen = "open" + orderTypeDutchV2 = "Dutch_V2" +) + +type v2OrderInfo struct { + Reactor common.Address + Swapper common.Address + Nonce *big.Int + Deadline *big.Int + AdditionalValidationContract common.Address + AdditionalValidationData []byte +} + +type v2Input struct { + Token common.Address + StartAmount *big.Int + EndAmount *big.Int +} + +type v2Output struct { + Token common.Address + StartAmount *big.Int + EndAmount *big.Int + Recipient common.Address +} + +type v2CosignerData struct { + DecayStartTime *big.Int + DecayEndTime *big.Int + ExclusiveFiller common.Address + ExclusivityOverrideBps *big.Int + InputOverride *big.Int + OutputOverrides []*big.Int +} + +type v2Order struct { + Info v2OrderInfo + Cosigner common.Address + BaseInput v2Input + BaseOutputs []v2Output + CosignerData v2CosignerData + Cosignature []byte +} + +type resolvedOrder struct { + Encoded []byte + Signature []byte + Hash common.Hash + QuoteID string + Source orderSource + Executor common.Address + TokenIn common.Address + TokenOut common.Address + AmountIn *big.Int + AmountOut *big.Int + Deadline uint32 + ExclusiveUntil uint64 +} + +var ( + v2OrderArguments = mustV2OrderArguments() + v2CosignerDataArguments = mustV2CosignerDataArguments() + v2HashArguments = mustV2HashArguments() +) + +type v2HashABIs struct { + info abi.Arguments + output abi.Arguments + witness abi.Arguments +} + +func mustV2OrderArguments() abi.Arguments { + components := []abi.ArgumentMarshaling{ + {Name: "info", Type: "tuple", Components: []abi.ArgumentMarshaling{ + {Name: "reactor", Type: "address"}, {Name: "swapper", Type: "address"}, + {Name: "nonce", Type: "uint256"}, {Name: "deadline", Type: "uint256"}, + {Name: "additionalValidationContract", Type: "address"}, + {Name: "additionalValidationData", Type: "bytes"}, + }}, + {Name: "cosigner", Type: "address"}, + {Name: "baseInput", Type: "tuple", Components: []abi.ArgumentMarshaling{ + {Name: "token", Type: "address"}, {Name: "startAmount", Type: "uint256"}, {Name: "endAmount", Type: "uint256"}, + }}, + {Name: "baseOutputs", Type: "tuple[]", Components: []abi.ArgumentMarshaling{ + {Name: "token", Type: "address"}, {Name: "startAmount", Type: "uint256"}, + {Name: "endAmount", Type: "uint256"}, {Name: "recipient", Type: "address"}, + }}, + {Name: "cosignerData", Type: "tuple", Components: []abi.ArgumentMarshaling{ + {Name: "decayStartTime", Type: "uint256"}, {Name: "decayEndTime", Type: "uint256"}, + {Name: "exclusiveFiller", Type: "address"}, {Name: "exclusivityOverrideBps", Type: "uint256"}, + {Name: "inputOverride", Type: "uint256"}, {Name: "outputOverrides", Type: "uint256[]"}, + }}, + {Name: "cosignature", Type: "bytes"}, + } + t, err := abi.NewType("tuple", "V2DutchOrder", components) + if err != nil { + panic(err) + } + return abi.Arguments{{Type: t}} +} + +func mustV2CosignerDataArguments() abi.Arguments { + t, err := abi.NewType("tuple", "CosignerData", []abi.ArgumentMarshaling{ + {Name: "decayStartTime", Type: "uint256"}, {Name: "decayEndTime", Type: "uint256"}, + {Name: "exclusiveFiller", Type: "address"}, {Name: "exclusivityOverrideBps", Type: "uint256"}, + {Name: "inputOverride", Type: "uint256"}, {Name: "outputOverrides", Type: "uint256[]"}, + }) + if err != nil { + panic(err) + } + return abi.Arguments{{Type: t}} +} + +func mustV2HashArguments() v2HashABIs { + bytes32Type, err := abi.NewType("bytes32", "", nil) + if err != nil { + panic(err) + } + addressType, err := abi.NewType("address", "", nil) + if err != nil { + panic(err) + } + uintType, err := abi.NewType("uint256", "", nil) + if err != nil { + panic(err) + } + return v2HashABIs{ + info: abi.Arguments{ + {Type: bytes32Type}, {Type: addressType}, {Type: addressType}, {Type: uintType}, + {Type: uintType}, {Type: addressType}, {Type: bytes32Type}, + }, + output: abi.Arguments{ + {Type: bytes32Type}, {Type: addressType}, {Type: uintType}, {Type: uintType}, {Type: addressType}, + }, + witness: abi.Arguments{ + {Type: bytes32Type}, {Type: bytes32Type}, {Type: addressType}, {Type: addressType}, + {Type: uintType}, {Type: uintType}, {Type: bytes32Type}, + }, + } +} + +func parseAndResolveOrder( + entry orderEntry, + source orderSource, + cfg *Config, + chainID int64, + now time.Time, +) (*resolvedOrder, error) { + return parseAndResolveV2Order(entry, source, cfg, chainID, now) +} + +func parseAndResolveV2Order( + entry orderEntry, + source orderSource, + cfg *Config, + chainID int64, + now time.Time, +) (*resolvedOrder, error) { + if entry.Type != orderTypeDutchV2 || entry.OrderStatus != orderStatusOpen { + return nil, errors.Errorf("unsupported order type/status %q/%q", entry.Type, entry.OrderStatus) + } + if entry.ChainID != chainID { + return nil, errors.Errorf("order chain id %d does not match %d", entry.ChainID, chainID) + } + encoded, err := hexutil.Decode(entry.EncodedOrder) + if err != nil { + return nil, errors.Errorf("encodedOrder: %w", err) + } + signature, err := hexutil.Decode(entry.Signature) + if err != nil || len(signature) == 0 { + return nil, errors.New("signature is missing or malformed") + } + decoded, err := v2OrderArguments.Unpack(encoded) + if err != nil || len(decoded) != 1 { + return nil, errors.Errorf("decode V2 order: %w", err) + } + order := *abi.ConvertType(decoded[0], new(v2Order)).(*v2Order) + if order.Info.Reactor != cfg.Reactor { + return nil, errors.New("reactor mismatch") + } + if order.Cosigner == (common.Address{}) { + return nil, errors.New("cosigner must be non-zero") + } + if source == orderSourceExclusiveV2 && order.CosignerData.ExclusiveFiller != cfg.Executor { + return nil, errors.Errorf("exclusive filler mismatch: got %s", order.CosignerData.ExclusiveFiller.Hex()) + } + if source == orderSourcePublicV2 && order.CosignerData.ExclusiveFiller == cfg.Executor { + return nil, errors.New("order belongs to the exclusive V2 source") + } + if len(order.Cosignature) != 65 || len(order.BaseOutputs) == 0 || + len(order.CosignerData.OutputOverrides) != len(order.BaseOutputs) { + return nil, errors.New("order must have outputs, one override per output, and a 65-byte cosignature") + } + if order.Info.Swapper == (common.Address{}) { + return nil, errors.New("swapper must be non-zero") + } + if !cfg.TokenPolicy.Allows(order.BaseInput.Token) { + return nil, errors.New("input token rejected by token policy") + } + deadline, ok := uint32Value(order.Info.Deadline) + if !ok || int64(deadline) <= now.Unix() { + return nil, errors.New("order deadline is expired or exceeds uint32") + } + start, startOK := uint64Value(order.CosignerData.DecayStartTime) + end, endOK := uint64Value(order.CosignerData.DecayEndTime) + if !startOK || !endOK || end <= start || order.Info.Deadline.Cmp(order.CosignerData.DecayEndTime) < 0 { + return nil, errors.New("invalid decay window") + } + if order.CosignerData.InputOverride != nil && order.CosignerData.InputOverride.Sign() > 0 && + order.CosignerData.InputOverride.Cmp(order.BaseInput.StartAmount) > 0 { + return nil, errors.New("cosigner input override exceeds base start amount") + } + inputStart := originalIfZero(order.CosignerData.InputOverride, order.BaseInput.StartAmount) + if inputStart.Sign() <= 0 || order.BaseInput.EndAmount.Sign() <= 0 || inputStart.Cmp(order.BaseInput.EndAmount) > 0 { + return nil, errors.New("order input must be positive and ascend or remain fixed") + } + amountIn := decay(inputStart, order.BaseInput.EndAmount, start, end, uint64(now.Unix())) + applyOverride := source == orderSourcePublicV2 && requiresExclusiveOverride( + order.CosignerData.ExclusiveFiller, + cfg.Executor, + start, + uint64(now.Unix()), + ) + if applyOverride { + if order.CosignerData.ExclusivityOverrideBps == nil || order.CosignerData.ExclusivityOverrideBps.Sign() == 0 { + return nil, errors.New("order has active strict exclusivity") + } + } + tokenOut := order.BaseOutputs[0].Token + if tokenOut == (common.Address{}) || tokenOut == order.BaseInput.Token { + return nil, errors.New("native and identical output tokens are unsupported") + } + amountOut := new(big.Int) + for i, output := range order.BaseOutputs { + if override := order.CosignerData.OutputOverrides[i]; override != nil && override.Sign() > 0 && + override.Cmp(output.StartAmount) < 0 { + return nil, errors.Errorf("cosigner output override %d is below base start amount", i) + } + outputStart := originalIfZero(order.CosignerData.OutputOverrides[i], output.StartAmount) + if output.Token != tokenOut || output.Recipient == (common.Address{}) || + outputStart.Sign() <= 0 || outputStart.Cmp(output.EndAmount) < 0 { + return nil, errors.New("outputs must use one token, non-zero recipients, and descend or remain fixed") + } + resolved := decay(outputStart, output.EndAmount, start, end, uint64(now.Unix())) + if applyOverride { + resolved = applyExclusiveOverride(resolved, order.CosignerData.ExclusivityOverrideBps) + } + amountOut.Add(amountOut, resolved) + } + if !sameOrderEnvelope(entry, order) { + return nil, errors.New("order envelope does not match encoded order") + } + hash, err := v2OrderHash(order) + if err != nil { + return nil, err + } + backendHash, err := parseHash(entry.OrderHash) + if err != nil || backendHash != hash { + return nil, errors.New("orderHash does not match encoded order") + } + if err := validateCosignature(order, hash); err != nil { + return nil, err + } + return &resolvedOrder{ + Encoded: encoded, Signature: signature, Hash: hash, QuoteID: entry.QuoteID, + Source: source, Executor: cfg.Executor, + TokenIn: order.BaseInput.Token, TokenOut: tokenOut, + AmountIn: amountIn, AmountOut: amountOut, Deadline: deadline, ExclusiveUntil: start, + }, nil +} + +func originalIfZero(override, original *big.Int) *big.Int { + if override == nil || override.Sign() == 0 { + return new(big.Int).Set(original) + } + return new(big.Int).Set(override) +} + +func decay(startAmount, endAmount *big.Int, start, end, now uint64) *big.Int { + if now <= start { + return new(big.Int).Set(startAmount) + } + if now >= end { + return new(big.Int).Set(endAmount) + } + delta := new(big.Int).Sub(endAmount, startAmount) + elapsed := new(big.Int).SetUint64(now - start) + duration := new(big.Int).SetUint64(end - start) + delta.Mul(delta, elapsed).Quo(delta, duration) + return new(big.Int).Add(startAmount, delta) +} + +func sameOrderEnvelope(entry orderEntry, order v2Order) bool { + if !sameAddress(entry.Input.Token, order.BaseInput.Token) || len(entry.Outputs) != len(order.BaseOutputs) { + return false + } + if entry.Input.StartAmount != order.BaseInput.StartAmount.String() || + entry.Input.EndAmount != order.BaseInput.EndAmount.String() { + return false + } + for i, output := range order.BaseOutputs { + if !sameAddress(entry.Outputs[i].Token, output.Token) || + !sameAddress(entry.Outputs[i].Recipient, output.Recipient) || + entry.Outputs[i].StartAmount != output.StartAmount.String() || + entry.Outputs[i].EndAmount != output.EndAmount.String() { + return false + } + } + return true +} + +func requiresExclusiveOverride(exclusive, executor common.Address, exclusivityEnd, now uint64) bool { + return exclusive != (common.Address{}) && exclusive != executor && now <= exclusivityEnd +} + +func applyExclusiveOverride(amount, bps *big.Int) *big.Int { + numerator := new(big.Int).Mul(amount, new(big.Int).Add(big.NewInt(10_000), bps)) + numerator.Add(numerator, big.NewInt(9_999)) + return numerator.Quo(numerator, big.NewInt(10_000)) +} + +func v2OrderHash(order v2Order) (common.Hash, error) { + info, err := v2HashArguments.info.Pack( + crypto.Keccak256Hash([]byte(v2OrderInfoType)), order.Info.Reactor, order.Info.Swapper, + order.Info.Nonce, order.Info.Deadline, order.Info.AdditionalValidationContract, + crypto.Keccak256Hash(order.Info.AdditionalValidationData), + ) + if err != nil { + return common.Hash{}, errors.Errorf("hash order info: %w", err) + } + outputHashes := make([]byte, 0, common.HashLength*len(order.BaseOutputs)) + for _, output := range order.BaseOutputs { + encodedOutput, packErr := v2HashArguments.output.Pack( + crypto.Keccak256Hash([]byte(v2OutputType)), output.Token, + output.StartAmount, output.EndAmount, output.Recipient, + ) + if packErr != nil { + return common.Hash{}, errors.Errorf("hash order output: %w", packErr) + } + outputHash := crypto.Keccak256Hash(encodedOutput) + outputHashes = append(outputHashes, outputHash.Bytes()...) + } + witness, err := v2HashArguments.witness.Pack( + crypto.Keccak256Hash([]byte(v2WitnessType)), crypto.Keccak256Hash(info), order.Cosigner, + order.BaseInput.Token, order.BaseInput.StartAmount, order.BaseInput.EndAmount, + crypto.Keccak256Hash(outputHashes), + ) + if err != nil { + return common.Hash{}, errors.Errorf("hash V2 order: %w", err) + } + return crypto.Keccak256Hash(witness), nil +} + +func validateCosignature(order v2Order, orderHash common.Hash) error { + encodedData, err := v2CosignerDataArguments.Pack(order.CosignerData) + if err != nil { + return errors.Errorf("encode cosigner data: %w", err) + } + digest := crypto.Keccak256Hash(orderHash.Bytes(), encodedData) + signature := append([]byte(nil), order.Cosignature...) + if len(signature) != crypto.SignatureLength || signature[64] < 27 || signature[64] > 28 { + return errors.New("cosignature has invalid recovery id") + } + signature[64] -= 27 + r := new(big.Int).SetBytes(signature[:32]) + s := new(big.Int).SetBytes(signature[32:64]) + if !crypto.ValidateSignatureValues(signature[64], r, s, true) { + return errors.New("cosignature is not canonical") + } + publicKey, err := crypto.SigToPub(digest.Bytes(), signature) + if err != nil || crypto.PubkeyToAddress(*publicKey) != order.Cosigner { + return errors.New("cosignature signer mismatch") + } + return nil +} + +func sameAddress(value string, expected common.Address) bool { + return common.IsHexAddress(value) && common.HexToAddress(value) == expected +} + +func parseHash(value string) (common.Hash, error) { + b, err := hexutil.Decode(value) + if err != nil || len(b) != common.HashLength { + return common.Hash{}, errors.New("orderHash is malformed") + } + return common.BytesToHash(b), nil +} + +func uint32Value(value *big.Int) (uint32, bool) { + if value == nil || !value.IsUint64() || value.Uint64() > uint64(^uint32(0)) { + return 0, false + } + return uint32(value.Uint64()), true +} + +func uint64Value(value *big.Int) (uint64, bool) { + if value == nil || !value.IsUint64() { + return 0, false + } + return value.Uint64(), true +} diff --git a/internal/solvers/uniswapx/order_test.go b/internal/solvers/uniswapx/order_test.go new file mode 100644 index 00000000..be90d959 --- /dev/null +++ b/internal/solvers/uniswapx/order_test.go @@ -0,0 +1,446 @@ +package uniswapx + +import ( + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" + "github.com/symbioticfi/vault-solver/internal/tokenpolicy" +) + +const goldenV2OrderHash = "0x4bd12c75e25c9601d854988baadbdd0ad1a147b3cb55b0899e143f8db9f3be6e" + +func TestV2OrderHashMatchesApitypesAndGolden(t *testing.T) { + order := v2Order{ + Info: v2OrderInfo{ + Reactor: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Swapper: common.HexToAddress("0x2222222222222222222222222222222222222222"), + Nonce: big.NewInt(7), Deadline: big.NewInt(1_900_000_000), + AdditionalValidationContract: common.HexToAddress("0x3333333333333333333333333333333333333333"), + AdditionalValidationData: hexutil.MustDecode("0x123456"), + }, + Cosigner: common.HexToAddress("0x4444444444444444444444444444444444444444"), + BaseInput: v2Input{ + Token: common.HexToAddress("0x5555555555555555555555555555555555555555"), + StartAmount: big.NewInt(1_000_000), EndAmount: big.NewInt(1_100_000), + }, + BaseOutputs: []v2Output{{ + Token: common.HexToAddress("0x6666666666666666666666666666666666666666"), + StartAmount: big.NewInt(2_000_000), EndAmount: big.NewInt(1_800_000), + Recipient: common.HexToAddress("0x7777777777777777777777777777777777777777"), + }}, + } + + got, err := v2OrderHash(order) + if err != nil { + t.Fatal(err) + } + if got.Hex() != goldenV2OrderHash { + t.Fatalf("V2 order hash = %s, want golden %s", got.Hex(), goldenV2OrderHash) + } + + typed := apitypes.TypedData{ + Types: apitypes.Types{ + "EIP712Domain": {{Name: "name", Type: "string"}}, + "V2DutchOrder": { + {Name: "info", Type: "OrderInfo"}, {Name: "cosigner", Type: "address"}, + {Name: "baseInputToken", Type: "address"}, {Name: "baseInputStartAmount", Type: "uint256"}, + {Name: "baseInputEndAmount", Type: "uint256"}, {Name: "baseOutputs", Type: "DutchOutput[]"}, + }, + "OrderInfo": { + {Name: "reactor", Type: "address"}, {Name: "swapper", Type: "address"}, + {Name: "nonce", Type: "uint256"}, {Name: "deadline", Type: "uint256"}, + {Name: "additionalValidationContract", Type: "address"}, + {Name: "additionalValidationData", Type: "bytes"}, + }, + "DutchOutput": { + {Name: "token", Type: "address"}, {Name: "startAmount", Type: "uint256"}, + {Name: "endAmount", Type: "uint256"}, {Name: "recipient", Type: "address"}, + }, + }, + PrimaryType: "V2DutchOrder", + Domain: apitypes.TypedDataDomain{Name: "unused"}, + Message: apitypes.TypedDataMessage{ + "info": map[string]any{ + "reactor": order.Info.Reactor.Hex(), "swapper": order.Info.Swapper.Hex(), + "nonce": order.Info.Nonce.String(), "deadline": order.Info.Deadline.String(), + "additionalValidationContract": order.Info.AdditionalValidationContract.Hex(), + "additionalValidationData": hexutil.Encode(order.Info.AdditionalValidationData), + }, + "cosigner": order.Cosigner.Hex(), "baseInputToken": order.BaseInput.Token.Hex(), + "baseInputStartAmount": order.BaseInput.StartAmount.String(), + "baseInputEndAmount": order.BaseInput.EndAmount.String(), + "baseOutputs": []any{map[string]any{ + "token": order.BaseOutputs[0].Token.Hex(), + "startAmount": order.BaseOutputs[0].StartAmount.String(), + "endAmount": order.BaseOutputs[0].EndAmount.String(), + "recipient": order.BaseOutputs[0].Recipient.Hex(), + }}, + }, + } + want, err := typed.HashStruct(typed.PrimaryType, typed.Message) + if err != nil { + t.Fatalf("hash V2 order with apitypes: %v", err) + } + if got != common.BytesToHash(want) { + t.Fatalf("V2 hash mismatch:\n manual %s\n apitypes %s", got.Hex(), common.BytesToHash(want).Hex()) + } +} + +func TestParseAndResolveOrder(t *testing.T) { + reactor := common.HexToAddress("0x1111111111111111111111111111111111111111") + executor := common.HexToAddress("0x2222222222222222222222222222222222222222") + cosignerKey, err := crypto.HexToECDSA("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + if err != nil { + t.Fatal(err) + } + cosigner := crypto.PubkeyToAddress(cosignerKey.PublicKey) + tokenIn := common.HexToAddress("0x4444444444444444444444444444444444444444") + tokenOut := common.HexToAddress("0x5555555555555555555555555555555555555555") + recipient := common.HexToAddress("0x6666666666666666666666666666666666666666") + policy, err := tokenpolicy.New(tokenpolicy.All, nil) + if err != nil { + t.Fatal(err) + } + cfg := &Config{Reactor: reactor, Executor: executor, TokenPolicy: policy} + order := v2Order{ + Info: v2OrderInfo{ + Reactor: reactor, Swapper: recipient, Nonce: big.NewInt(1), Deadline: big.NewInt(1_200), + AdditionalValidationData: []byte{}, + }, + Cosigner: cosigner, + BaseInput: v2Input{Token: tokenIn, StartAmount: big.NewInt(100), EndAmount: big.NewInt(100)}, + BaseOutputs: []v2Output{{Token: tokenOut, StartAmount: big.NewInt(220), EndAmount: big.NewInt(200), Recipient: recipient}}, + CosignerData: v2CosignerData{ + DecayStartTime: big.NewInt(1_000), DecayEndTime: big.NewInt(1_100), ExclusiveFiller: executor, + ExclusivityOverrideBps: big.NewInt(0), InputOverride: big.NewInt(0), OutputOverrides: []*big.Int{big.NewInt(0)}, + }, + Cosignature: make([]byte, 65), + } + hash, err := v2OrderHash(order) + if err != nil { + t.Fatal(err) + } + cosignerData, err := v2CosignerDataArguments.Pack(order.CosignerData) + if err != nil { + t.Fatal(err) + } + order.Cosignature, err = crypto.Sign(crypto.Keccak256(hash.Bytes(), cosignerData), cosignerKey) + if err != nil { + t.Fatal(err) + } + order.Cosignature[64] += 27 + encoded, err := v2OrderArguments.Pack(order) + if err != nil { + t.Fatalf("pack order: %v", err) + } + entry := orderEntry{ + Type: "Dutch_V2", EncodedOrder: hexutil.Encode(encoded), Signature: "0x01", OrderHash: hash.Hex(), + OrderStatus: "open", ChainID: 1, QuoteID: "quote-1", + Input: orderToken{Token: tokenIn.Hex(), StartAmount: "100", EndAmount: "100"}, + Outputs: []orderOutput{{Token: tokenOut.Hex(), StartAmount: "220", EndAmount: "200", Recipient: recipient.Hex()}}, + } + resolved, err := parseAndResolveOrder(entry, orderSourceExclusiveV2, cfg, 1, time.Unix(1_050, 0)) + if err != nil { + t.Fatalf("parseAndResolveOrder: %v", err) + } + if resolved.AmountIn.Cmp(big.NewInt(100)) != 0 || resolved.AmountOut.Cmp(big.NewInt(210)) != 0 { + t.Fatalf("resolved amounts = %s/%s, want 100/210", resolved.AmountIn, resolved.AmountOut) + } + + t.Run("accepts the cosigner authorized by each order", func(t *testing.T) { + rotatedKey, keyErr := crypto.HexToECDSA( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + ) + if keyErr != nil { + t.Fatal(keyErr) + } + rotated := order + rotated.Cosigner = crypto.PubkeyToAddress(rotatedKey.PublicKey) + rotatedHash, hashErr := v2OrderHash(rotated) + if hashErr != nil { + t.Fatal(hashErr) + } + encodedCosignerData, packErr := v2CosignerDataArguments.Pack(rotated.CosignerData) + if packErr != nil { + t.Fatal(packErr) + } + rotated.Cosignature, packErr = crypto.Sign( + crypto.Keccak256(rotatedHash.Bytes(), encodedCosignerData), + rotatedKey, + ) + if packErr != nil { + t.Fatal(packErr) + } + rotated.Cosignature[64] += 27 + body, packErr := v2OrderArguments.Pack(rotated) + if packErr != nil { + t.Fatal(packErr) + } + rotatedEntry := entry + rotatedEntry.EncodedOrder = hexutil.Encode(body) + rotatedEntry.OrderHash = rotatedHash.Hex() + if _, parseErr := parseAndResolveOrder( + rotatedEntry, orderSourceExclusiveV2, cfg, 1, time.Unix(1_050, 0), + ); parseErr != nil { + t.Fatalf("order-authorized cosigner rejected: %v", parseErr) + } + }) + + t.Run("public V2 applies active exclusivity override with ceil rounding", func(t *testing.T) { + publicOrder := order + publicOrder.CosignerData.ExclusiveFiller = recipient + publicOrder.CosignerData.ExclusivityOverrideBps = big.NewInt(100) + publicCosignerData, packErr := v2CosignerDataArguments.Pack(publicOrder.CosignerData) + if packErr != nil { + t.Fatal(packErr) + } + publicOrder.Cosignature, packErr = crypto.Sign(crypto.Keccak256(hash.Bytes(), publicCosignerData), cosignerKey) + if packErr != nil { + t.Fatal(packErr) + } + publicOrder.Cosignature[64] += 27 + body, packErr := v2OrderArguments.Pack(publicOrder) + if packErr != nil { + t.Fatal(packErr) + } + publicEntry := entry + publicEntry.EncodedOrder = hexutil.Encode(body) + public, parseErr := parseAndResolveOrder(publicEntry, orderSourcePublicV2, cfg, 1, time.Unix(1_000, 0)) + if parseErr != nil { + t.Fatal(parseErr) + } + if public.AmountOut.Cmp(big.NewInt(223)) != 0 { + t.Fatalf("override amount = %s, want 223", public.AmountOut) + } + + publicOrder.CosignerData.ExclusivityOverrideBps = big.NewInt(0) + publicCosignerData, _ = v2CosignerDataArguments.Pack(publicOrder.CosignerData) + publicOrder.Cosignature, _ = crypto.Sign(crypto.Keccak256(hash.Bytes(), publicCosignerData), cosignerKey) + publicOrder.Cosignature[64] += 27 + body, _ = v2OrderArguments.Pack(publicOrder) + publicEntry.EncodedOrder = hexutil.Encode(body) + if _, parseErr = parseAndResolveOrder(publicEntry, orderSourcePublicV2, cfg, 1, time.Unix(1_000, 0)); parseErr == nil { + t.Fatal("expected active strict exclusivity rejection") + } + }) + + t.Run("rejects envelope mismatch", func(t *testing.T) { + tampered := entry + tampered.Outputs = append([]orderOutput(nil), entry.Outputs...) + tampered.Outputs[0].EndAmount = "199" + if _, err := parseAndResolveOrder(tampered, orderSourceExclusiveV2, cfg, 1, time.Unix(1_050, 0)); err == nil { + t.Fatal("expected envelope mismatch") + } + }) + + t.Run("accepts exact-output input decay and cosigner override", func(t *testing.T) { + inputDecay := order + inputDecay.BaseInput.EndAmount = big.NewInt(140) + inputDecay.BaseOutputs = []v2Output{{ + Token: tokenOut, StartAmount: big.NewInt(200), EndAmount: big.NewInt(200), Recipient: recipient, + }} + inputDecay.CosignerData.InputOverride = big.NewInt(80) + inputHash, hashErr := v2OrderHash(inputDecay) + if hashErr != nil { + t.Fatal(hashErr) + } + encodedCosignerData, packErr := v2CosignerDataArguments.Pack(inputDecay.CosignerData) + if packErr != nil { + t.Fatal(packErr) + } + inputDecay.Cosignature, packErr = crypto.Sign( + crypto.Keccak256(inputHash.Bytes(), encodedCosignerData), + cosignerKey, + ) + if packErr != nil { + t.Fatal(packErr) + } + inputDecay.Cosignature[64] += 27 + body, packErr := v2OrderArguments.Pack(inputDecay) + if packErr != nil { + t.Fatal(packErr) + } + decayingEntry := entry + decayingEntry.Outputs = append([]orderOutput(nil), entry.Outputs...) + decayingEntry.EncodedOrder = hexutil.Encode(body) + decayingEntry.OrderHash = inputHash.Hex() + decayingEntry.Input.EndAmount = "140" + decayingEntry.Outputs[0].StartAmount = "200" + resolvedExactOutput, parseErr := parseAndResolveOrder( + decayingEntry, + orderSourceExclusiveV2, + cfg, + 1, + time.Unix(1_050, 0), + ) + if parseErr != nil { + t.Fatalf("exact-output order rejected: %v", parseErr) + } + if resolvedExactOutput.AmountIn.Cmp(big.NewInt(110)) != 0 || + resolvedExactOutput.AmountOut.Cmp(big.NewInt(200)) != 0 { + t.Fatalf("resolved exact-output amounts = %s/%s, want 110/200", + resolvedExactOutput.AmountIn, resolvedExactOutput.AmountOut) + } + }) + + t.Run("rejects cosigner overrides outside signed base bounds", func(t *testing.T) { + tests := []struct { + name string + mutate func(*v2Order) + wantErr string + }{ + { + name: "input above base start", + mutate: func(candidate *v2Order) { + candidate.CosignerData.InputOverride = big.NewInt(101) + }, + wantErr: "input override exceeds base start", + }, + { + name: "output below base start", + mutate: func(candidate *v2Order) { + candidate.CosignerData.OutputOverrides = []*big.Int{big.NewInt(219)} + }, + wantErr: "output override 0 is below base start", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := order + test.mutate(&candidate) + encodedCosignerData, packErr := v2CosignerDataArguments.Pack(candidate.CosignerData) + if packErr != nil { + t.Fatal(packErr) + } + candidate.Cosignature, packErr = crypto.Sign( + crypto.Keccak256(hash.Bytes(), encodedCosignerData), cosignerKey, + ) + if packErr != nil { + t.Fatal(packErr) + } + candidate.Cosignature[64] += 27 + body, packErr := v2OrderArguments.Pack(candidate) + if packErr != nil { + t.Fatal(packErr) + } + candidateEntry := entry + candidateEntry.EncodedOrder = hexutil.Encode(body) + if _, parseErr := parseAndResolveOrder( + candidateEntry, orderSourceExclusiveV2, cfg, 1, time.Unix(1_050, 0), + ); parseErr == nil || !strings.Contains(parseErr.Error(), test.wantErr) { + t.Fatalf("err = %v, want %q", parseErr, test.wantErr) + } + }) + } + }) + + t.Run("accepts same-token fee outputs and sums their resolved amounts", func(t *testing.T) { + multi := order + multi.BaseOutputs = append(append([]v2Output(nil), order.BaseOutputs...), v2Output{ + Token: tokenOut, StartAmount: big.NewInt(20), EndAmount: big.NewInt(10), Recipient: recipient, + }) + multi.CosignerData.OutputOverrides = append( + append([]*big.Int(nil), order.CosignerData.OutputOverrides...), big.NewInt(0), + ) + multiHash, hashErr := v2OrderHash(multi) + if hashErr != nil { + t.Fatal(hashErr) + } + encodedCosignerData, packErr := v2CosignerDataArguments.Pack(multi.CosignerData) + if packErr != nil { + t.Fatal(packErr) + } + multi.Cosignature, packErr = crypto.Sign(crypto.Keccak256(multiHash.Bytes(), encodedCosignerData), cosignerKey) + if packErr != nil { + t.Fatal(packErr) + } + multi.Cosignature[64] += 27 + body, packErr := v2OrderArguments.Pack(multi) + if packErr != nil { + t.Fatal(packErr) + } + multiEntry := entry + multiEntry.EncodedOrder = hexutil.Encode(body) + multiEntry.OrderHash = multiHash.Hex() + multiEntry.Outputs = append(append([]orderOutput(nil), entry.Outputs...), orderOutput{ + Token: tokenOut.Hex(), StartAmount: "20", EndAmount: "10", Recipient: recipient.Hex(), + }) + resolvedMulti, parseErr := parseAndResolveOrder( + multiEntry, orderSourceExclusiveV2, cfg, 1, time.Unix(1_050, 0), + ) + if parseErr != nil { + t.Fatalf("multi-output order rejected: %v", parseErr) + } + if resolvedMulti.AmountOut.Cmp(big.NewInt(225)) != 0 { + t.Fatalf("multi-output amount = %s, want 225", resolvedMulti.AmountOut) + } + }) + + t.Run("rejects another filler", func(t *testing.T) { + tamperedOrder := order + tamperedOrder.CosignerData.ExclusiveFiller = recipient + body, packErr := v2OrderArguments.Pack(tamperedOrder) + if packErr != nil { + t.Fatal(packErr) + } + tampered := entry + tampered.EncodedOrder = hexutil.Encode(body) + if _, err := parseAndResolveOrder(tampered, orderSourceExclusiveV2, cfg, 1, time.Unix(1_050, 0)); err == nil { + t.Fatal("expected exclusive filler mismatch") + } + }) + + t.Run("rejects zero cosigner", func(t *testing.T) { + tamperedOrder := order + tamperedOrder.Cosigner = common.Address{} + body, packErr := v2OrderArguments.Pack(tamperedOrder) + if packErr != nil { + t.Fatal(packErr) + } + tampered := entry + tampered.EncodedOrder = hexutil.Encode(body) + if _, err := parseAndResolveOrder( + tampered, orderSourceExclusiveV2, cfg, 1, time.Unix(1_050, 0), + ); err == nil || !strings.Contains(err.Error(), "cosigner must be non-zero") { + t.Fatalf("err = %v", err) + } + }) + + t.Run("rejects backend hash mismatch", func(t *testing.T) { + tampered := entry + tampered.OrderHash = common.HexToHash("0x01").Hex() + if _, err := parseAndResolveOrder(tampered, orderSourceExclusiveV2, cfg, 1, time.Unix(1_050, 0)); err == nil { + t.Fatal("expected order hash mismatch") + } + }) + + t.Run("rejects cosignature mismatch", func(t *testing.T) { + tamperedOrder := order + tamperedOrder.Cosignature = append([]byte(nil), order.Cosignature...) + tamperedOrder.Cosignature[10] ^= 0xff + body, packErr := v2OrderArguments.Pack(tamperedOrder) + if packErr != nil { + t.Fatal(packErr) + } + tampered := entry + tampered.EncodedOrder = hexutil.Encode(body) + if _, err := parseAndResolveOrder(tampered, orderSourceExclusiveV2, cfg, 1, time.Unix(1_050, 0)); err == nil { + t.Fatal("expected cosignature mismatch") + } + }) +} + +func TestDecayMatchesDutchLinearRounding(t *testing.T) { + if got := decay(big.NewInt(11), big.NewInt(0), 100, 103, 101); got.Cmp(big.NewInt(8)) != 0 { + t.Fatalf("descending decay = %s, want 8", got) + } + if got := decay(big.NewInt(0), big.NewInt(11), 100, 103, 101); got.Cmp(big.NewInt(3)) != 0 { + t.Fatalf("ascending decay = %s, want 3", got) + } +} diff --git a/internal/solvers/uniswapx/orderclient.go b/internal/solvers/uniswapx/orderclient.go new file mode 100644 index 00000000..b53d4630 --- /dev/null +++ b/internal/solvers/uniswapx/orderclient.go @@ -0,0 +1,374 @@ +package uniswapx + +import ( + "context" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/api/uniswapxservice" +) + +const ( + maxOrderResponseBytes = 4 << 20 + orderPageLimit = 1000 + maxOrderPages = 10 + maxOrderHashBatch = 50 + minOrderRequestGap = time.Second / 6 + betaRFQHeaderValue = "true" +) + +type orderClient struct { + client *uniswapxservice.APIClient + + requestMu sync.Mutex + lastRequest time.Time + requestGap time.Duration +} + +func newOrderClient(cfg OrderServerConfig, apiKey string) *orderClient { + generatedConfig := uniswapxservice.NewConfiguration() + generatedConfig.Servers = uniswapxservice.ServerConfigurations{{URL: strings.TrimRight(cfg.BaseURL, "/")}} + generatedConfig.DefaultHeader["x-api-key"] = apiKey + if cfg.Beta { + generatedConfig.DefaultHeader["x-beta-rfq"] = betaRFQHeaderValue + } + generatedConfig.HTTPClient = &http.Client{ + Timeout: cfg.HTTPTimeout, + Transport: responseLimitTransport{next: http.DefaultTransport, limit: maxOrderResponseBytes}, + } + return &orderClient{ + client: uniswapxservice.NewAPIClient(generatedConfig), + requestGap: minOrderRequestGap, + } +} + +func (c *orderClient) openOrders(ctx context.Context, chainID int64, filler *common.Address) ([]orderEntry, error) { + var orders []orderEntry + var cursor string + seenCursors := make(map[string]bool) + for range maxOrderPages { + page, err := c.orderPage(ctx, chainID, filler, cursor) + if err != nil { + return orders, err + } + orders = append(orders, page.Orders...) + if page.Cursor == "" { + return orders, nil + } + if seenCursors[page.Cursor] { + return orders, errors.New("orders response repeated its cursor") + } + seenCursors[page.Cursor] = true + cursor = page.Cursor + } + return orders, errors.Errorf("orders response exceeds %d pages", maxOrderPages) +} + +func (c *orderClient) ordersByHash( + ctx context.Context, + chainID int64, + hashes []common.Hash, +) (map[common.Hash]orderTerminal, error) { + requested := make(map[common.Hash]struct{}, len(hashes)) + for _, hash := range hashes { + if hash == (common.Hash{}) { + return nil, errors.New("GET /orders by hash: zero order hash") + } + if _, duplicate := requested[hash]; duplicate { + return nil, errors.Errorf("GET /orders by hash: duplicate requested hash %s", hash.Hex()) + } + requested[hash] = struct{}{} + } + + terminals := make(map[common.Hash]orderTerminal, len(hashes)) + for start := 0; start < len(hashes); start += maxOrderHashBatch { + end := min(start+maxOrderHashBatch, len(hashes)) + if err := c.fetchOrderHashBatch(ctx, chainID, hashes[start:end], terminals); err != nil { + return nil, err + } + } + for hash := range requested { + if _, ok := terminals[hash]; !ok { + return nil, errors.Errorf("GET /orders by hash: missing order %s", hash.Hex()) + } + } + return terminals, nil +} + +func (c *orderClient) fetchOrderHashBatch( + ctx context.Context, + chainID int64, + hashes []common.Hash, + terminals map[common.Hash]orderTerminal, +) error { + if err := c.waitForRequestSlot(ctx); err != nil { + return errors.Errorf("wait for orders rate limit: %w", err) + } + hashValues := make([]string, len(hashes)) + batch := make(map[common.Hash]struct{}, len(hashes)) + for i, hash := range hashes { + hashValues[i] = hash.Hex() + batch[hash] = struct{}{} + } + request := c.client.OrdersAPI.OrdersGet(ctx). + ChainId(uniswapxservice.ChainId(chainID)). + Limit(float32(len(hashes))). + OrderHashes(strings.Join(hashValues, ",")). + OrderType(uniswapxservice.DUTCH_V2) + response, httpResponse, err := request.Execute() + if httpResponse != nil && httpResponse.Body != nil { + defer httpResponse.Body.Close() + } + if err != nil { + return errors.Errorf("GET /orders by hash: %w", err) + } + if response == nil { + return errors.New("GET /orders by hash: empty response") + } + if response.GetCursor() != "" { + return errors.New("GET /orders by hash: unexpected paginated response") + } + for i := range response.Orders { + order := response.Orders[i].DutchV2OrderEntity + if order == nil { + return errors.Errorf("GET /orders by hash: order %d is not Dutch_V2", i) + } + hash, terminal, convertErr := orderTerminalFromAPI(order, chainID) + if convertErr != nil { + return errors.Errorf("GET /orders by hash: order %d: %w", i, convertErr) + } + if _, ok := batch[hash]; !ok { + return errors.Errorf("GET /orders by hash: unexpected order %s", hash.Hex()) + } + if _, duplicate := terminals[hash]; duplicate { + return errors.Errorf("GET /orders by hash: duplicate order %s", hash.Hex()) + } + terminals[hash] = terminal + } + for hash := range batch { + if _, ok := terminals[hash]; !ok { + return errors.Errorf("GET /orders by hash: missing order %s", hash.Hex()) + } + } + return nil +} + +func (c *orderClient) orderPage( + ctx context.Context, + chainID int64, + filler *common.Address, + cursor string, +) (orderPage, error) { + if err := c.waitForRequestSlot(ctx); err != nil { + return orderPage{}, errors.Errorf("wait for orders rate limit: %w", err) + } + response, err := c.executeOrderRequest(ctx, chainID, filler, cursor) + if err != nil { + return orderPage{}, err + } + if response == nil { + return orderPage{}, errors.New("GET /orders: empty response") + } + if len(response.Orders) > orderPageLimit { + return orderPage{}, errors.Errorf( + "GET /orders: response contains %d orders, max %d", + len(response.Orders), + orderPageLimit, + ) + } + orders := make([]orderEntry, 0, len(response.Orders)) + for i := range response.Orders { + order := response.Orders[i].DutchV2OrderEntity + if order == nil { + return orderPage{}, errors.Errorf("GET /orders: order %d is not Dutch_V2", i) + } + entry, err := orderEntryFromAPI(order) + if err != nil { + return orderPage{}, errors.Errorf("GET /orders: order %d: %w", i, err) + } + orders = append(orders, entry) + } + return orderPage{Orders: orders, Cursor: response.GetCursor()}, nil +} + +func (c *orderClient) executeOrderRequest( + ctx context.Context, + chainID int64, + filler *common.Address, + cursor string, +) (*uniswapxservice.GetOrdersResponse, error) { + request := c.client.OrdersAPI.OrdersGet(ctx). + ChainId(uniswapxservice.ChainId(chainID)). + Limit(orderPageLimit). + OrderStatus(uniswapxservice.OPEN). + SortKey(uniswapxservice.CREATED_AT). + Sort("gt(0)"). + Desc(true). + OrderType(uniswapxservice.DUTCH_V2) + if filler != nil { + request = request.Filler(filler.Hex()) + } + if cursor != "" { + request = request.Cursor(cursor) + } + response, httpResponse, err := request.Execute() + if httpResponse != nil && httpResponse.Body != nil { + defer httpResponse.Body.Close() + } + if err != nil { + return nil, errors.Errorf("GET /orders: %w", err) + } + return response, nil +} + +func orderTerminalFromAPI( + order *uniswapxservice.DutchV2OrderEntity, + chainID int64, +) (common.Hash, orderTerminal, error) { + if order.Type != orderTypeDutchV2 { + return common.Hash{}, orderTerminal{}, errors.Errorf("unexpected order type %q", order.Type) + } + if int64(order.ChainId) != chainID { + return common.Hash{}, orderTerminal{}, errors.Errorf( + "order chain id %d does not match %d", + int64(order.ChainId), + chainID, + ) + } + orderHash, err := decodeHash(order.OrderHash) + if err != nil || orderHash == (common.Hash{}) { + return common.Hash{}, orderTerminal{}, errors.Errorf("invalid order hash %q", order.OrderHash) + } + if !order.OrderStatus.IsValid() { + return common.Hash{}, orderTerminal{}, errors.Errorf("invalid order status %q", order.OrderStatus) + } + + terminal := orderTerminal{Status: string(order.OrderStatus)} + txHashValue, hasTxHash := order.GetTxHashOk() + if hasTxHash { + txHash, decodeErr := decodeHash(*txHashValue) + if decodeErr != nil || txHash == (common.Hash{}) { + return common.Hash{}, orderTerminal{}, errors.Errorf("invalid transaction hash %q", *txHashValue) + } + terminal.TxHash = txHash + } + if terminal.Status == orderStatusFilled { + if !hasTxHash { + return common.Hash{}, orderTerminal{}, errors.New("filled order has no transaction hash") + } + } else if hasTxHash { + return common.Hash{}, orderTerminal{}, errors.Errorf( + "status %q unexpectedly has transaction hash", + terminal.Status, + ) + } + return orderHash, terminal, nil +} + +func decodeHash(value string) (common.Hash, error) { + decoded, err := hexutil.Decode(value) + if err != nil { + return common.Hash{}, err + } + if len(decoded) != common.HashLength { + return common.Hash{}, errors.Errorf("got %d bytes, want %d", len(decoded), common.HashLength) + } + return common.BytesToHash(decoded), nil +} + +func orderEntryFromAPI(order *uniswapxservice.DutchV2OrderEntity) (orderEntry, error) { + if order.Type != orderTypeDutchV2 { + return orderEntry{}, errors.Errorf("unexpected order type %q", order.Type) + } + if order.Input == nil { + return orderEntry{}, errors.New("input is missing") + } + outputs := make([]orderOutput, 0, len(order.Outputs)) + for i := range order.Outputs { + output := &order.Outputs[i] + outputs = append(outputs, orderOutput{ + Token: output.GetToken(), StartAmount: output.StartAmount, + EndAmount: output.EndAmount, Recipient: output.Recipient, + }) + } + return orderEntry{ + Type: order.Type, EncodedOrder: order.EncodedOrder, Signature: order.Signature, + OrderHash: order.OrderHash, OrderStatus: string(order.OrderStatus), ChainID: int64(order.ChainId), + QuoteID: order.GetQuoteId(), + Input: orderToken{ + Token: order.Input.Token, StartAmount: order.Input.GetStartAmount(), EndAmount: order.Input.GetEndAmount(), + }, + Outputs: outputs, + }, nil +} + +type responseLimitTransport struct { + next http.RoundTripper + limit int64 +} + +func (t responseLimitTransport) RoundTrip(request *http.Request) (*http.Response, error) { + response, err := t.next.RoundTrip(request) + if err != nil || response == nil || response.Body == nil { + return response, err + } + response.Body = &limitedResponseBody{ + Reader: &errorLimitReader{reader: response.Body, remaining: t.limit}, + Closer: response.Body, + } + return response, nil +} + +type limitedResponseBody struct { + io.Reader + io.Closer +} + +type errorLimitReader struct { + reader io.Reader + remaining int64 +} + +func (r *errorLimitReader) Read(data []byte) (int, error) { + if r.remaining <= 0 { + var probe [1]byte + n, err := r.reader.Read(probe[:]) + if n > 0 { + return 0, errors.New("order response exceeds size limit") + } + return 0, err + } + if int64(len(data)) > r.remaining { + data = data[:r.remaining] + } + n, err := r.reader.Read(data) + r.remaining -= int64(n) + return n, err +} + +func (c *orderClient) waitForRequestSlot(ctx context.Context) error { + c.requestMu.Lock() + defer c.requestMu.Unlock() + if c.requestGap <= 0 { + return nil + } + delay := time.Until(c.lastRequest.Add(c.requestGap)) + if delay > 0 { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + } + } + c.lastRequest = time.Now() + return nil +} diff --git a/internal/solvers/uniswapx/orderclient_test.go b/internal/solvers/uniswapx/orderclient_test.go new file mode 100644 index 00000000..89987b2c --- /dev/null +++ b/internal/solvers/uniswapx/orderclient_test.go @@ -0,0 +1,298 @@ +package uniswapx + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +func TestOrderClientPagesOpenOrders(t *testing.T) { + filler := common.HexToAddress("0x1111111111111111111111111111111111111111") + firstHash := "0x" + strings.Repeat("1", 64) + secondHash := "0x" + strings.Repeat("2", 64) + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Header.Get("x-api-key") != "secret" || r.Header.Get("x-beta-rfq") != "true" { + t.Error("missing Uniswap API headers") + } + query := r.URL.Query() + if query.Get("chainId") != "1" || query.Get("filler") != filler.Hex() || + query.Get("orderType") != orderTypeDutchV2 || + query.Get("orderStatus") != "open" || query.Get("sortKey") != "createdAt" || + query.Get("desc") != "true" || query.Get("sort") != "gt(0)" || + query.Get("limit") != "1000" { + t.Errorf("unexpected query: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + if query.Get("cursor") == "" { + _ = json.NewEncoder(w).Encode(map[string]any{"orders": []any{testAPIOrder(firstHash)}, "cursor": "next"}) + return + } + if query.Get("cursor") != "next" { + t.Errorf("unexpected cursor %q", query.Get("cursor")) + } + _ = json.NewEncoder(w).Encode(map[string]any{"orders": []any{testAPIOrder(secondHash)}}) + })) + defer server.Close() + + client := newOrderClient(OrderServerConfig{ + BaseURL: server.URL, PollInterval: time.Second, HTTPTimeout: time.Second, Beta: true, + }, "secret") + client.requestGap = 0 + orders, err := client.openOrders(context.Background(), 1, &filler) + if err != nil { + t.Fatal(err) + } + if requests != 2 || len(orders) != 2 || orders[0].OrderHash != firstHash || orders[1].OrderHash != secondHash { + t.Fatalf("requests/orders = %d/%+v", requests, orders) + } +} + +func TestOrderClientDecodesCurrentDutchV2Response(t *testing.T) { + fixture, err := os.ReadFile("testdata/orders-v2-current.json") + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fixture) + })) + defer server.Close() + + client := newOrderClient(OrderServerConfig{BaseURL: server.URL, HTTPTimeout: time.Second}, "secret") + client.requestGap = 0 + orders, err := client.openOrders(t.Context(), 1, nil) + if err != nil { + t.Fatal(err) + } + if len(orders) != 1 { + t.Fatalf("orders = %d, want 1", len(orders)) + } + order := orders[0] + if order.Type != orderTypeDutchV2 || order.QuoteID != "quote-1" || + order.Input.StartAmount != "100" || len(order.Outputs) != 1 || order.Outputs[0].EndAmount != "200" { + t.Fatalf("unexpected order: %+v", order) + } +} + +func TestOrderClientRejectsWrongVariant(t *testing.T) { + order := map[string]any{ + "type": "Dutch_V3", "encodedOrder": "0x01", "signature": "0x" + strings.Repeat("1", 130), + "orderHash": "0x" + strings.Repeat("3", 64), "orderStatus": "open", "chainId": 1, + "swapper": "0x1111111111111111111111111111111111111111", + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"orders": []any{order}}) + })) + defer server.Close() + + client := newOrderClient(OrderServerConfig{BaseURL: server.URL, HTTPTimeout: time.Second}, "secret") + client.requestGap = 0 + _, err := client.openOrders(t.Context(), 1, nil) + if err == nil || !strings.Contains(err.Error(), "order 0 is not Dutch_V2") { + t.Fatalf("err = %v", err) + } +} + +func TestOrderClientOrdersByHashBatches(t *testing.T) { + hashes := make([]common.Hash, maxOrderHashBatch+1) + txHashes := make(map[common.Hash]common.Hash, len(hashes)) + for i := range hashes { + hashes[i] = common.BytesToHash([]byte{byte(i + 1)}) + txHashes[hashes[i]] = common.BytesToHash([]byte{byte(i + 101)}) + } + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + query := r.URL.Query() + requested := strings.Split(query.Get("orderHashes"), ",") + if len(requested) > maxOrderHashBatch || query.Get("chainId") != "1" || + query.Get("orderType") != orderTypeDutchV2 || + query.Get("limit") != strconv.Itoa(len(requested)) || + query.Has("orderStatus") || query.Has("filler") || query.Has("sortKey") || + query.Has("sort") || query.Has("desc") || query.Has("cursor") { + t.Errorf("unexpected hash query: %s", r.URL.RawQuery) + } + orders := make([]any, len(requested)) + for i, rawHash := range requested { + hash := common.HexToHash(rawHash) + orders[i] = testTerminalAPIOrder(hash, orderStatusFilled, txHashes[hash]) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"orders": orders}) + })) + defer server.Close() + + client := newOrderClient(OrderServerConfig{BaseURL: server.URL, HTTPTimeout: time.Second}, "secret") + client.requestGap = 0 + terminals, err := client.ordersByHash(t.Context(), 1, hashes) + if err != nil { + t.Fatal(err) + } + if requests != 2 || len(terminals) != len(hashes) { + t.Fatalf("requests/terminals = %d/%d, want 2/%d", requests, len(terminals), len(hashes)) + } + for _, hash := range hashes { + if terminal := terminals[hash]; terminal.Status != orderStatusFilled || terminal.TxHash != txHashes[hash] { + t.Fatalf("terminal for %s = %+v", hash.Hex(), terminal) + } + } +} + +func TestOrderClientOrdersByHashRejectsInvalidResponses(t *testing.T) { + orderHash := common.HexToHash("0x1") + txHash := common.HexToHash("0x2") + tests := []struct { + name string + orders func() []any + }{ + {name: "missing", orders: func() []any { return nil }}, + {name: "duplicate", orders: func() []any { + order := testTerminalAPIOrder(orderHash, orderStatusFilled, txHash) + return []any{order, order} + }}, + {name: "wrong variant", orders: func() []any { + return []any{map[string]any{ + "type": "Dutch_V3", "encodedOrder": "0x01", "signature": "0x" + strings.Repeat("1", 130), + "orderHash": orderHash.Hex(), "orderStatus": "open", "chainId": 1, + "swapper": "0x1111111111111111111111111111111111111111", + }} + }}, + {name: "invalid order hash", orders: func() []any { + order := testTerminalAPIOrder(orderHash, orderStatusFilled, txHash) + order["orderHash"] = "0x1234" + return []any{order} + }}, + {name: "invalid status", orders: func() []any { + order := testTerminalAPIOrder(orderHash, orderStatusFilled, txHash) + order["orderStatus"] = "unknown" + return []any{order} + }}, + {name: "filled without transaction", orders: func() []any { + return []any{testTerminalAPIOrder(orderHash, orderStatusFilled, common.Hash{})} + }}, + {name: "open with transaction", orders: func() []any { + return []any{testTerminalAPIOrder(orderHash, orderStatusOpen, txHash)} + }}, + {name: "invalid transaction hash", orders: func() []any { + order := testTerminalAPIOrder(orderHash, orderStatusFilled, txHash) + order["txHash"] = "0x1234" + return []any{order} + }}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"orders": tc.orders()}) + })) + defer server.Close() + + client := newOrderClient(OrderServerConfig{BaseURL: server.URL, HTTPTimeout: time.Second}, "secret") + client.requestGap = 0 + if _, err := client.ordersByHash(t.Context(), 1, []common.Hash{orderHash}); err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestOrderClientLeavesFillerUnsetForPublicSources(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/orders" || r.URL.Query().Has("filler") { + t.Errorf("unexpected public query: %s", r.URL.String()) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"orders": []any{}}) + })) + defer server.Close() + client := newOrderClient(OrderServerConfig{BaseURL: server.URL, HTTPTimeout: time.Second}, "secret") + client.requestGap = 0 + if _, err := client.openOrders(context.Background(), 1, nil); err != nil { + t.Fatal(err) + } +} + +func TestOrderClientRateLimitsEveryPage(t *testing.T) { + var requestCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("cursor") == "" { + _ = json.NewEncoder(w).Encode(map[string]any{"orders": []any{}, "cursor": "next"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"orders": []any{}}) + })) + defer server.Close() + + client := newOrderClient(OrderServerConfig{BaseURL: server.URL, HTTPTimeout: time.Second}, "secret") + client.requestGap = 20 * time.Millisecond + started := time.Now() + client.lastRequest = started + if _, err := client.openOrders(context.Background(), 1, nil); err != nil { + t.Fatal(err) + } + if requestCount != 2 { + t.Fatalf("request count = %d, want 2", requestCount) + } + if elapsed := client.lastRequest.Sub(started); elapsed < 2*client.requestGap { + t.Fatalf("request slots elapsed = %s, want at least %s", elapsed, 2*client.requestGap) + } +} + +func TestOrderResponseLimitReturnsError(t *testing.T) { + reader := &errorLimitReader{reader: strings.NewReader("abc"), remaining: 2} + if _, err := io.ReadAll(reader); err == nil || !strings.Contains(err.Error(), "exceeds size limit") { + t.Fatalf("err = %v", err) + } +} + +func testAPIOrder(hash string) map[string]any { + return map[string]any{ + "type": "Dutch_V2", "encodedOrder": "0x01", "signature": "0x" + strings.Repeat("1", 130), "nonce": "1", + "orderHash": hash, "orderStatus": "open", "chainId": 1, + "swapper": "0x1111111111111111111111111111111111111111", + "input": map[string]any{ + "token": "0x3333333333333333333333333333333333333333", + "startAmount": "1", "endAmount": "1", + }, + "outputs": []any{map[string]any{ + "token": "0x4444444444444444444444444444444444444444", + "startAmount": "1", "endAmount": "1", + "recipient": "0x5555555555555555555555555555555555555555", + }}, + "cosignerData": map[string]any{ + "decayStartTime": 1, "decayEndTime": 2, + "exclusiveFiller": "0x6666666666666666666666666666666666666666", + "inputOverride": "1", "outputOverrides": []string{"1"}, + }, + "cosignature": "0x" + strings.Repeat("2", 130), + "createdAt": 1, + "quoteId": "quote-1", "requestId": "request-1", + } +} + +func testTerminalAPIOrder(hash common.Hash, status string, txHash common.Hash) map[string]any { + order := map[string]any{ + "type": "Dutch_V2", "encodedOrder": "0x01", "signature": "0x" + strings.Repeat("1", 130), + "orderHash": hash.Hex(), "orderStatus": status, "chainId": 1, + "swapper": "0x1111111111111111111111111111111111111111", + } + if txHash != (common.Hash{}) { + order["txHash"] = txHash.Hex() + } + return order +} diff --git a/internal/solvers/uniswapx/polling.go b/internal/solvers/uniswapx/polling.go new file mode 100644 index 00000000..db47023d --- /dev/null +++ b/internal/solvers/uniswapx/polling.go @@ -0,0 +1,188 @@ +package uniswapx + +import ( + "context" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" +) + +func (s *Solver) orderLoop(ctx context.Context, out chan<- *resolvedOrder) error { + defer close(out) + ticker := time.NewTicker(s.cfg.OrderServer.PollInterval) + defer ticker.Stop() + for { + if err := s.pollOrders(ctx, out); err != nil && !errors.Is(err, context.Canceled) { + s.log.Error(err, "order poll failed") + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func (s *Solver) pollOrders(ctx context.Context, out chan<- *resolvedOrder) error { + var pollErrs []error + if s.cfg.OrderServer.Sources.ExclusiveV2 { + now, err := s.pollSource(ctx, orderSourceExclusiveV2, &s.cfg.Executor, out) + if err != nil { + s.markExclusivePollFailure() + s.observePoll(string(orderSourceExclusiveV2), "failed") + pollErrs = append(pollErrs, err) + } else if err := s.sweepExclusive(ctx, now); err != nil { + s.markExclusiveStateUnknown() + s.observePoll(string(orderSourceExclusiveV2), "failed") + pollErrs = append(pollErrs, errors.Errorf("reconcile exclusive orders: %w", err)) + } else { + s.recordExclusivePollSuccess(time.Now()) + s.observePoll(string(orderSourceExclusiveV2), "ok") + } + } + if s.cfg.OrderServer.Sources.PublicV2 { + if _, err := s.pollSource(ctx, orderSourcePublicV2, nil, out); err != nil { + pollErrs = append(pollErrs, err) + s.observePoll(string(orderSourcePublicV2), "failed") + } else { + s.observePoll(string(orderSourcePublicV2), "ok") + } + } + return errors.Join(pollErrs...) +} + +func (s *Solver) pollSource( + ctx context.Context, + source orderSource, + filler *common.Address, + out chan<- *resolvedOrder, +) (time.Time, error) { + entries, err := s.orders.openOrders(ctx, s.chainID, filler) + if err != nil && len(entries) == 0 { + return time.Time{}, errors.Errorf("poll %s orders: %w", source, err) + } + s.log.V(1).Info( + "orders polled", + "source", source, + "orders", len(entries), + "partialError", err != nil, + ) + now, nowErr := s.reader.latestBlockTime(ctx) + if nowErr != nil { + return time.Time{}, errors.Errorf("read chain time for %s orders: %w", source, nowErr) + } + for _, entry := range entries { + order, parseErr := parseAndResolveOrder(entry, source, s.cfg, s.chainID, now) + if parseErr != nil { + s.log.V(1).Info("order rejected", "error", parseErr, "source", source, + "orderHash", entry.OrderHash, "quoteId", entry.QuoteID) + continue + } + s.trackExclusive(order, now) + if !s.claim(order.Hash, now) { + s.log.V(1).Info( + "order skipped: already handled or awaiting retry", + "source", source, + "orderHash", order.Hash.Hex(), + "quoteId", order.QuoteID, + ) + continue + } + s.log.V(1).Info( + "order queued for fill", + "source", source, + "orderHash", order.Hash.Hex(), + "quoteId", order.QuoteID, + "tokenIn", order.TokenIn.Hex(), + "tokenOut", order.TokenOut.Hex(), + "amountIn", order.AmountIn.String(), + "amountOut", order.AmountOut.String(), + "deadline", order.Deadline, + ) + select { + case out <- order: + case <-ctx.Done(): + s.retry(order.Hash, now, false) + return time.Time{}, ctx.Err() + } + } + if err != nil { + return time.Time{}, errors.Errorf("poll %s orders: %w", source, err) + } + return now, nil +} + +func (s *Solver) recordExclusivePollSuccess(now time.Time) { + wasUnknown := s.exclusiveStateUnknown.Swap(false) + timestamp := now.Unix() + s.lastExclusivePoll.Store(timestamp) + if s.metrics != nil { + s.metrics.exclusivePoll.Set(float64(timestamp)) + } + if wasUnknown { + s.requestQuoteRefresh() + } +} + +func (s *Solver) claim(hash common.Hash, now time.Time) bool { + s.stateMu.Lock() + defer s.stateMu.Unlock() + for key, filledAt := range s.filled { + if now.Sub(filledAt) > time.Hour { + delete(s.filled, key) + } + } + for key, retryAt := range s.retryAt { + if now.Sub(retryAt) > time.Hour { + delete(s.retryAt, key) + delete(s.attempts, key) + } + } + if _, exists := s.filled[hash]; exists { + return false + } + if s.inFlight[hash] { + return false + } + if retryAt, exists := s.retryAt[hash]; exists && retryAt.After(now) { + return false + } + delete(s.retryAt, hash) + s.inFlight[hash] = true + return true +} + +func (s *Solver) retry(hash common.Hash, now time.Time, failed bool) { + s.stateMu.Lock() + delete(s.inFlight, hash) + backoff := s.cfg.OrderServer.PollInterval + attempt := s.attempts[hash] + if failed { + attempt++ + s.attempts[hash] = attempt + shift := min(attempt-1, 5) + backoff *= time.Duration(1 << shift) + backoff = min(backoff, 30*time.Second) + } + retryAt := now.Add(backoff) + s.retryAt[hash] = retryAt + s.stateMu.Unlock() + s.log.V(1).Info( + "order retry scheduled", + "orderHash", hash.Hex(), + "failed", failed, + "attempt", attempt, + "backoff", backoff, + "retryAt", retryAt.Unix(), + ) +} + +func (s *Solver) complete(hash common.Hash, now time.Time) { + s.stateMu.Lock() + delete(s.retryAt, hash) + delete(s.inFlight, hash) + delete(s.attempts, hash) + s.filled[hash] = now + s.stateMu.Unlock() +} diff --git a/internal/solvers/uniswapx/quote_refresh.go b/internal/solvers/uniswapx/quote_refresh.go new file mode 100644 index 00000000..e8bf41d3 --- /dev/null +++ b/internal/solvers/uniswapx/quote_refresh.go @@ -0,0 +1,112 @@ +package uniswapx + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" +) + +type quoteState struct { + epoch uint64 + inventory []liquidlane.Inventory + gasSnapshot *liquidlanegas.Snapshot + gasPrices *liquidlanegas.PriceSnapshot + maxFeePerGas *big.Int + chainTime time.Time + expiresAt time.Time + singleRouteFor map[common.Address]bool +} + +func (s *Solver) refreshLoop(ctx context.Context, routes []liquidlane.Route) error { + ticker := time.NewTicker(s.cfg.QuoteServer.RefreshInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-s.refreshCh: + if err := s.refreshQuoteState(ctx, routes); err != nil { + s.log.Error(err, "requested quote state refresh failed") + } + case <-ticker.C: + if err := s.refreshQuoteState(ctx, routes); err != nil { + s.log.Error(err, "quote state refresh failed") + } + } + } +} + +func (s *Solver) refreshQuoteState(ctx context.Context, routes []liquidlane.Route) error { + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + + epoch := s.quoteEpoch.Load() + now, err := s.reader.latestBlockTime(ctx) + if err != nil { + return err + } + s.chainTime.Store(now.Unix()) + decisionRoutes, listed, discountErr := s.quoteRoutesWithDiscounts(ctx, routes, now) + if discountErr != nil { + s.log.Error(discountErr, "refresh advertised discount routes") + } + current, err := s.reader.quoteSnapshot(ctx, decisionRoutes, s.cfg.Executor, now) + if err != nil { + return err + } + if s.cfg.usesDiscounts() { + current.Direct = directInventoriesForAdapters(current.Direct, s.cfg.Adapters) + if listed != nil { + current.Direct = append(current.Direct, s.discountInventories(listed, current.Physical, now)...) + } + } + maxFee := new(big.Int) + if s.cfg.Gas != nil { + maxFee, err = s.txm.MaxFeePerGas(ctx) + if err != nil { + return err + } + } + serverNow := time.Now() + if s.publishQuoteState(epoch, "eState{ + inventory: current.Direct, + gasSnapshot: current.GasSnapshot, gasPrices: current.GasPrices, + maxFeePerGas: maxFee, chainTime: now, expiresAt: serverNow.Add(s.cfg.QuoteServer.QuoteTTL), + singleRouteFor: s.cfg.TokenPolicy.SingleRouteTokens(), + }) { + if s.metrics != nil { + s.metrics.quoteRefresh.Set(float64(time.Now().Unix())) + } + s.log.V(1).Info( + "quote state refreshed", + "epoch", epoch, + "routes", len(decisionRoutes), + "inventory", len(current.Direct), + "physicalInventory", len(current.Physical), + "gasAccounting", s.cfg.Gas != nil, + "maxFeePerGas", maxFee.String(), + "expiresAt", serverNow.Add(s.cfg.QuoteServer.QuoteTTL), + ) + } else { + s.log.V(1).Info("quote state refresh discarded", "epoch", epoch) + } + return nil +} + +func (s *Solver) publishQuoteState(epoch uint64, state *quoteState) bool { + if s.planningFills.Load() != 0 || s.quoteEpoch.Load() != epoch { + return false + } + state.epoch = epoch + s.quoteState.Store(state) + if s.planningFills.Load() != 0 || s.quoteEpoch.Load() != epoch { + s.quoteState.CompareAndSwap(state, nil) + return false + } + return true +} diff --git a/internal/solvers/uniswapx/quote_refresh_test.go b/internal/solvers/uniswapx/quote_refresh_test.go new file mode 100644 index 00000000..21b39146 --- /dev/null +++ b/internal/solvers/uniswapx/quote_refresh_test.go @@ -0,0 +1,326 @@ +package uniswapx + +import ( + "context" + "math/big" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquiddiscounts "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" +) + +type quoteModeReader struct { + chainReader + + now time.Time + resolved []liquidlane.Route + resolveErr error + resolveFn func([]common.Address) ([]liquidlane.Route, error) + adapters []common.Address + snapshotRoutes []liquidlane.Route + snapshot snapshot + gasErr error +} + +func (r *quoteModeReader) latestBlockTime(context.Context) (time.Time, error) { + return r.now, nil +} + +func (r *quoteModeReader) resolveRoutes( + _ context.Context, + adapters []common.Address, +) ([]liquidlane.Route, error) { + r.adapters = append(r.adapters, adapters...) + if r.resolveFn != nil { + return r.resolveFn(adapters) + } + return append([]liquidlane.Route(nil), r.resolved...), r.resolveErr +} + +func (r *quoteModeReader) validateGasTokens([]liquidlane.Route) error { + return r.gasErr +} + +func (r *quoteModeReader) quoteSnapshot( + _ context.Context, + routes []liquidlane.Route, + _ common.Address, + _ time.Time, +) (snapshot, error) { + r.snapshotRoutes = append([]liquidlane.Route(nil), routes...) + return r.snapshot, nil +} + +func TestRefreshQuoteStateInternalDiscountScopes(t *testing.T) { + now := time.Unix(1_000, 0) + advertised := testDiscountRoute() + configured := liquidlane.NewRoute( + 1, + common.HexToAddress("0x9999999999999999999999999999999999999999"), + common.HexToAddress("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + advertised.TokenIn, + advertised.TokenOut, + advertised.TokenInDecimals, + advertised.TokenOutDecimals, + ) + inventory := func(route liquidlane.Route) liquidlane.Inventory { + item := liquidlane.DirectInventory(route, big.NewInt(100), big.NewInt(100)) + item.AdapterMinDiscount = new(big.Int) + return item + } + + t.Run("no configured adapters is discount only", func(t *testing.T) { + listed := &liquiddiscounts.List{Discounts: []liquiddiscounts.ListItem{ + testDiscountOffer(advertised, now.Add(time.Minute), "100", "100"), + }} + provider := &fakeDiscountProvider{list: listed} + reader := "eModeReader{ + now: now, resolved: []liquidlane.Route{advertised}, + snapshot: snapshot{ + Direct: []liquidlane.Inventory{inventory(advertised)}, + Physical: []liquidlane.Inventory{inventory(advertised)}, + }, + } + solver := quoteModeSolver(reader, provider) + + if err := solver.refreshQuoteState(t.Context(), nil); err != nil { + t.Fatalf("refreshQuoteState: %v", err) + } + state := solver.quoteState.Load() + if state == nil || len(state.inventory) != 1 || state.inventory[0].DiscountID == nil || + state.inventory[0].Adapter != advertised.Adapter { + t.Fatalf("discount-only quote state = %+v", state) + } + if len(reader.adapters) != 1 || reader.adapters[0] != advertised.Adapter || + len(reader.snapshotRoutes) != 1 { + t.Fatalf("dynamic quote routes: adapters=%+v routes=%+v", reader.adapters, reader.snapshotRoutes) + } + if reads := solver.txm.(*executionTestTxManager).maxFeeReads; reads != 0 { + t.Fatalf("max fee reads = %d, want 0 with gas accounting disabled", reads) + } + }) + + t.Run("configured adapters scope quotes", func(t *testing.T) { + listed := &liquiddiscounts.List{Discounts: []liquiddiscounts.ListItem{ + testDiscountOffer(configured, now.Add(time.Minute), "100", "100"), + testDiscountOffer(advertised, now.Add(time.Minute), "100", "100"), + }} + provider := &fakeDiscountProvider{list: listed} + reader := "eModeReader{ + now: now, + snapshot: snapshot{ + Direct: []liquidlane.Inventory{inventory(configured)}, + Physical: []liquidlane.Inventory{inventory(configured)}, + }, + } + solver := quoteModeSolver(reader, provider) + solver.cfg.Adapters = []common.Address{configured.Adapter} + + if err := solver.refreshQuoteState(t.Context(), []liquidlane.Route{configured}); err != nil { + t.Fatalf("refreshQuoteState: %v", err) + } + state := solver.quoteState.Load() + if state == nil || len(state.inventory) != 2 { + t.Fatalf("configured quote state = %+v", state) + } + for _, item := range state.inventory { + if item.Adapter != configured.Adapter { + t.Fatalf("unconfigured adapter reached quote state: %+v", item) + } + } + if len(reader.adapters) != 0 || len(reader.snapshotRoutes) != 1 || + reader.snapshotRoutes[0].Adapter != configured.Adapter { + t.Fatalf("configured quote scope: adapters=%+v routes=%+v", reader.adapters, reader.snapshotRoutes) + } + }) + + t.Run("advertised token must be active on chain", func(t *testing.T) { + inactive := advertised + active := advertised + active.TokenIn = common.HexToAddress("0x7777777777777777777777777777777777777777") + active.ID = liquidlane.NewRouteID(1, active.Adapter, active.TokenIn, active.TokenOut) + provider := &fakeDiscountProvider{list: &liquiddiscounts.List{Discounts: []liquiddiscounts.ListItem{ + testDiscountOffer(inactive, now.Add(time.Minute), "100", "100"), + }}} + reader := "eModeReader{now: now, resolved: []liquidlane.Route{active}} + solver := quoteModeSolver(reader, provider) + + if err := solver.refreshQuoteState(t.Context(), nil); err != nil { + t.Fatalf("refreshQuoteState: %v", err) + } + state := solver.quoteState.Load() + if state == nil || len(state.inventory) != 0 || len(reader.snapshotRoutes) != 0 { + t.Fatalf("inactive advertised token reached quote state: state=%+v routes=%+v", state, reader.snapshotRoutes) + } + }) + + t.Run("dynamic resolution failure keeps configured discounts", func(t *testing.T) { + dynamic := configured + dynamic.TokenIn = common.HexToAddress("0x8888888888888888888888888888888888888888") + dynamic.ID = liquidlane.NewRouteID(1, dynamic.Adapter, dynamic.TokenIn, dynamic.TokenOut) + provider := &fakeDiscountProvider{list: &liquiddiscounts.List{Discounts: []liquiddiscounts.ListItem{ + testDiscountOffer(configured, now.Add(time.Minute), "100", "100"), + testDiscountOffer(dynamic, now.Add(time.Minute), "100", "100"), + }}} + reader := "eModeReader{ + now: now, resolveErr: errors.New("dynamic adapter resolution failed"), + snapshot: snapshot{ + Direct: []liquidlane.Inventory{inventory(configured)}, + Physical: []liquidlane.Inventory{inventory(configured)}, + }, + } + solver := quoteModeSolver(reader, provider) + solver.cfg.Adapters = []common.Address{configured.Adapter} + + if err := solver.refreshQuoteState(t.Context(), []liquidlane.Route{configured}); err != nil { + t.Fatalf("refreshQuoteState: %v", err) + } + state := solver.quoteState.Load() + if state == nil || len(state.inventory) != 2 || state.inventory[1].DiscountID == nil { + t.Fatalf("configured discount was lost after dynamic resolution failure: %+v", state) + } + }) +} + +func TestRefreshQuoteStateInternalDiscountFailureFallsBackToDirect(t *testing.T) { + now := time.Unix(1_000, 0) + route := testDiscountRoute() + direct := liquidlane.DirectInventory(route, big.NewInt(100), big.NewInt(100)) + provider := &fakeDiscountProvider{listErr: errors.New("discount backend unavailable")} + reader := "eModeReader{ + now: now, + snapshot: snapshot{ + Direct: []liquidlane.Inventory{direct}, Physical: []liquidlane.Inventory{direct}, + }, + } + solver := quoteModeSolver(reader, provider) + solver.cfg.Adapters = []common.Address{route.Adapter} + + if err := solver.refreshQuoteState(t.Context(), []liquidlane.Route{route}); err != nil { + t.Fatalf("refreshQuoteState: %v", err) + } + state := solver.quoteState.Load() + if state == nil || len(state.inventory) != 1 || state.inventory[0].DiscountID != nil { + t.Fatalf("direct fallback state = %+v", state) + } +} + +func TestResolveAdvertisedRoutesIsolatesInvalidAdapter(t *testing.T) { + now := time.Unix(1_000, 0) + good := testDiscountRoute() + bad := good + bad.Adapter = common.HexToAddress("0x9999999999999999999999999999999999999999") + bad.ID = liquidlane.NewRouteID(1, bad.Adapter, bad.TokenIn, bad.TokenOut) + reader := "eModeReader{now: now} + reader.resolveFn = func(adapters []common.Address) ([]liquidlane.Route, error) { + if len(adapters) > 1 { + return nil, errors.New("batch contains invalid adapter") + } + if adapters[0] == bad.Adapter { + return nil, errors.New("invalid adapter") + } + return []liquidlane.Route{good}, nil + } + solver := quoteModeSolver(reader, &fakeDiscountProvider{}) + listed := &liquiddiscounts.List{Discounts: []liquiddiscounts.ListItem{ + testDiscountOffer(good, now.Add(time.Minute), "100", "100"), + testDiscountOffer(bad, now.Add(time.Minute), "100", "100"), + }} + + routes := solver.resolveAdvertisedRoutes(t.Context(), listed, nil, now, advertisedRouteFilter{}) + if len(routes) != 1 || routes[0].ID != good.ID { + t.Fatalf("resolved routes = %+v, want only good adapter", routes) + } +} + +func TestRefreshQuoteStateInternalWithoutRoutesPublishesEmptyUnreadyState(t *testing.T) { + now := time.Unix(1_000, 0) + reader := "eModeReader{now: now} + solver := quoteModeSolver(reader, &fakeDiscountProvider{list: &liquiddiscounts.List{}}) + + if err := solver.refreshQuoteState(t.Context(), nil); err != nil { + t.Fatalf("refreshQuoteState: %v", err) + } + state := solver.quoteState.Load() + if state == nil || len(state.inventory) != 0 { + t.Fatalf("empty quote state = %+v", state) + } + solver.lastExclusivePoll.Store(time.Now().Unix()) + if solver.ready() { + t.Fatal("solver with empty quote inventory should not be ready") + } +} + +func TestRefreshQuoteStateSkipsDynamicRouteWithoutGasFeed(t *testing.T) { + now := time.Unix(1_000, 0) + route := testDiscountRoute() + provider := &fakeDiscountProvider{list: &liquiddiscounts.List{Discounts: []liquiddiscounts.ListItem{ + testDiscountOffer(route, now.Add(time.Minute), "100", "100"), + }}} + reader := "eModeReader{ + now: now, resolved: []liquidlane.Route{route}, + gasErr: errors.New("missing token USD feed"), + } + solver := quoteModeSolver(reader, provider) + + if err := solver.refreshQuoteState(t.Context(), nil); err != nil { + t.Fatalf("refreshQuoteState: %v", err) + } + state := solver.quoteState.Load() + if state == nil || len(state.inventory) != 0 || len(reader.snapshotRoutes) != 0 { + t.Fatalf("missing-feed quote state=%+v routes=%+v", state, reader.snapshotRoutes) + } +} + +func TestPublishQuoteStateDoesNotRetainConcurrentlyInvalidatedState(t *testing.T) { + const iterations = 10_000 + + solver := &Solver{} + expiresAt := time.Now().Add(time.Minute) + for range iterations { + epoch := solver.quoteEpoch.Load() + candidate := "eState{ + inventory: []liquidlane.Inventory{{}}, + expiresAt: expiresAt, + } + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Go(func() { + <-start + solver.publishQuoteState(epoch, candidate) + }) + wg.Go(func() { + <-start + solver.invalidateQuotes() + }) + close(start) + wg.Wait() + + currentEpoch := solver.quoteEpoch.Load() + if current := solver.quoteState.Load(); current != nil && current.epoch != currentEpoch { + t.Fatalf("published stale epoch %d while current epoch is %d", current.epoch, currentEpoch) + } + } +} + +func quoteModeSolver(reader chainReader, provider liquiddiscounts.Provider) *Solver { + return &Solver{ + cfg: &Config{ + SolverMode: solverModeInternal, + Discounts: &DiscountConfig{HTTPTimeout: time.Second}, + QuoteServer: QuoteServerConfig{ + QuoteTTL: time.Minute, + }, + }, + reader: reader, + txm: &executionTestTxManager{}, + discounts: provider, + log: logr.Discard(), + } +} diff --git a/internal/solvers/uniswapx/server.go b/internal/solvers/uniswapx/server.go new file mode 100644 index 00000000..ef99d9ef --- /dev/null +++ b/internal/solvers/uniswapx/server.go @@ -0,0 +1,276 @@ +package uniswapx + +import ( + "bytes" + "context" + "encoding/json" + "io" + "math/big" + "net/http" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + strategytypes "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/types" +) + +const maxQuoteRequestBytes = 32 << 10 + +const ( + quoteTypeExactInput = "EXACT_INPUT" + quoteTypeExactOutput = "EXACT_OUTPUT" +) + +func (s *Solver) newQuoteHTTPServer() *http.Server { + mux := http.NewServeMux() + healthHandler := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } + mux.HandleFunc("POST /quote", s.quoteHandler) + mux.HandleFunc("GET /health", healthHandler) + mux.HandleFunc("GET /healthz", healthHandler) + mux.HandleFunc("GET /ready", s.readyHandler) + return &http.Server{ + Addr: s.cfg.QuoteServer.ListenAddress, Handler: recoverQuoteServer(mux, s.log), + ReadHeaderTimeout: 2 * time.Second, ReadTimeout: s.cfg.QuoteServer.HTTPTimeout, + WriteTimeout: s.cfg.QuoteServer.HTTPTimeout, IdleTimeout: 30 * time.Second, + } +} + +func (s *Solver) quoteHandler(w http.ResponseWriter, r *http.Request) { + started := time.Now() + defer func() { + if s.metrics != nil { + s.metrics.observeQuoteLatency(time.Since(started)) + } + }() + body, err := io.ReadAll(io.LimitReader(r.Body, maxQuoteRequestBytes+1)) + if err != nil { + s.log.V(1).Info("quote request rejected", "reason", "read-body", "error", err.Error()) + s.observeQuote("invalid") + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if len(body) > maxQuoteRequestBytes { + s.log.V(1).Info("quote request rejected", "reason", "body-too-large", "bytes", len(body)) + s.observeQuote("invalid") + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + var request quoteRequest + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + s.log.V(1).Info("quote request rejected", "reason", "invalid-json", "error", err.Error()) + s.observeQuote("invalid") + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + s.log.V(1).Info( + "quote request rejected", + "reason", "trailing-json", + "requestId", request.RequestID, + "quoteId", request.QuoteID, + ) + s.observeQuote("invalid") + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if request.RequestID == "" { + if request.BlockUntilTimestamp == nil || *request.BlockUntilTimestamp < 0 { + s.log.V(1).Info("quote request rejected", "reason", "invalid-breaker-notification") + s.observeQuote("invalid") + http.Error(w, "invalid blockUntilTimestamp", http.StatusBadRequest) + return + } + s.log.V(1).Info( + "quote breaker notification received", + "blockUntilTimestamp", *request.BlockUntilTimestamp, + ) + s.setBlockUntil(*request.BlockUntilTimestamp) + s.observeQuote("breaker-notification") + w.WriteHeader(http.StatusNoContent) + return + } + s.log.V(1).Info( + "quote request received", + "requestId", request.RequestID, + "quoteId", request.QuoteID, + "type", request.Type, + "protocol", request.Protocol, + "tokenIn", request.TokenIn, + "tokenOut", request.TokenOut, + "amount", request.Amount, + ) + response, err := s.quote(r.Context(), request) + if err != nil { + s.observeQuote("error") + s.log.Error(err, "quote failed", "requestId", request.RequestID, "quoteId", request.QuoteID) + http.Error(w, "quote unavailable", http.StatusServiceUnavailable) + return + } + if response.AmountOut == "0" { + s.observeQuote("declined") + s.log.V(1).Info( + "quote declined", + "requestId", request.RequestID, + "quoteId", request.QuoteID, + "type", request.Type, + "reason", response.declineReason, + "blockUntil", s.blockUntil.Load(), + "localBlockUntil", s.localBlockUntil.Load(), + "exclusiveBlockUntil", s.exclusiveBlockUntil.Load(), + "warmupUntil", s.warmupUntil.Load(), + "planningFills", s.planningFills.Load(), + ) + w.WriteHeader(http.StatusNoContent) + return + } + s.observeQuote("quoted") + s.log.V(1).Info( + "quote returned", + "requestId", request.RequestID, + "quoteId", request.QuoteID, + "type", request.Type, + "amountIn", response.AmountIn, + "amountOut", response.AmountOut, + ) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + s.log.Error(err, "write quote response", "requestId", request.RequestID, "quoteId", request.QuoteID) + } +} + +func (s *Solver) quote(ctx context.Context, request quoteRequest) (quoteResponse, error) { + response := quoteResponse{ + ChainID: s.chainID, RequestID: request.RequestID, Swapper: request.Swapper, TokenIn: request.TokenIn, + AmountIn: "0", TokenOut: request.TokenOut, AmountOut: "0", + Filler: s.cfg.Executor.Hex(), QuoteID: request.QuoteID, + } + if request.Type == quoteTypeExactInput { + response.AmountIn = request.Amount + } + now := s.currentTime() + if s.quoteBlocked(now) { + return declinedQuote(response, "blocked"), nil + } + if request.RequestID == "" || request.QuoteID == "" || !supportedQuoteType(request.Type) || request.NumOutputs < 1 || + !supportedQuoteProtocol(request.Protocol) || request.TokenInChainID != s.chainID || request.TokenOutChainID != s.chainID || + !common.IsHexAddress(request.Swapper) || + !common.IsHexAddress(request.TokenIn) || !common.IsHexAddress(request.TokenOut) { + return declinedQuote(response, "invalid-request"), nil + } + tokenIn := common.HexToAddress(request.TokenIn) + tokenOut := common.HexToAddress(request.TokenOut) + if tokenIn == tokenOut || tokenOut == (common.Address{}) || !s.cfg.TokenPolicy.Allows(tokenIn) { + return declinedQuote(response, "pair-out-of-scope"), nil + } + requestAmount, amountOK := new(big.Int).SetString(request.Amount, 10) + if !amountOK || requestAmount.Sign() <= 0 { + return declinedQuote(response, "invalid-amount"), nil + } + epoch := s.quoteEpoch.Load() + state := s.quoteState.Load() + if state == nil || state.epoch != epoch || !state.expiresAt.After(time.Unix(now, 0)) { + return declinedQuote(response, "quote-state-unavailable"), nil + } + input := strategytypes.QuoteInput{ + RequestID: request.RequestID, QuoteID: request.QuoteID, + TokenIn: tokenIn, TokenOut: tokenOut, + RequireSingleRoute: state.singleRouteFor[tokenIn], + Inventory: state.inventory, + Reservations: s.capacity.Snapshot(), + GasSnapshot: state.gasSnapshot, GasPrices: state.gasPrices, + MaxFeePerGas: state.maxFeePerGas, ChainTime: state.chainTime, QuoteExpiresAt: state.expiresAt, + Trace: s.decisionTrace( + "requestId", request.RequestID, + "quoteId", request.QuoteID, + "quoteType", request.Type, + ), + } + if request.Type == quoteTypeExactInput { + input.AmountIn = requestAmount + } else { + input.AmountOut = requestAmount + } + quote, err := s.strategy.DecideQuote(ctx, input) + if err != nil { + return response, err + } + if quote == nil { + return declinedQuote(response, "strategy-declined"), nil + } + if err := validateStrategyQuote(input, quote); err != nil { + return response, err + } + if s.quoteEpoch.Load() != epoch || s.quoteState.Load() != state || s.quoteBlocked(s.currentTime()) { + return declinedQuote(response, "state-changed"), nil + } + response.AmountIn = quote.AmountIn.String() + response.AmountOut = quote.AmountOut.String() + return response, nil +} + +func declinedQuote(response quoteResponse, reason string) quoteResponse { + response.declineReason = reason + return response +} + +func (s *Solver) quoteBlocked(now int64) bool { + return s.blockUntil.Load() > now || + s.localBlockUntil.Load() > now || + s.exclusiveBlockUntil.Load() > now || + s.warmupUntil.Load() > now || + s.planningFills.Load() != 0 || + !s.exclusiveDeliveryHealthy() +} + +func supportedQuoteType(value string) bool { + return value == quoteTypeExactInput || value == quoteTypeExactOutput +} + +func supportedQuoteProtocol(value string) bool { + return value == "v1" || value == "v2" +} + +func validateStrategyQuote(input strategytypes.QuoteInput, quote *strategytypes.Quote) error { + if quote.AmountIn == nil || quote.AmountIn.Sign() <= 0 || quote.AmountOut == nil || quote.AmountOut.Sign() <= 0 { + return errors.New("strategy returned invalid quote amounts") + } + if input.AmountIn != nil && quote.AmountIn.Cmp(input.AmountIn) != 0 { + return errors.New("strategy changed exact-input amount") + } + if input.AmountOut != nil && quote.AmountOut.Cmp(input.AmountOut) != 0 { + return errors.New("strategy changed exact-output amount") + } + return nil +} + +func (s *Solver) currentTime() int64 { + now := time.Now().Unix() + if chainTime := s.chainTime.Load(); chainTime > now { + return chainTime + } + return now +} + +func (s *Solver) setBlockUntil(timestamp int64) { + s.blockUntil.Store(timestamp) + if timestamp > s.currentTime() { + s.invalidateQuotes() + } + s.requestQuoteRefresh() + if s.metrics != nil { + s.updateBlockUntilMetric() + } +} + +func (s *Solver) updateBlockUntilMetric() { + if s.metrics != nil { + s.metrics.blockUntil.Set(float64(max( + s.blockUntil.Load(), + s.localBlockUntil.Load(), + s.exclusiveBlockUntil.Load(), + ))) + } +} diff --git a/internal/solvers/uniswapx/server_test.go b/internal/solvers/uniswapx/server_test.go new file mode 100644 index 00000000..09759f3e --- /dev/null +++ b/internal/solvers/uniswapx/server_test.go @@ -0,0 +1,346 @@ +package uniswapx + +import ( + "bytes" + "context" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + strategytypes "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/types" + "github.com/symbioticfi/vault-solver/internal/tokenpolicy" +) + +func TestQuoteHTTPServerRoutesHealth(t *testing.T) { + solver := &Solver{ + cfg: &Config{QuoteServer: QuoteServerConfig{HTTPTimeout: time.Second}}, + log: logr.Discard(), + } + server := solver.newQuoteHTTPServer() + + for _, path := range []string{"/health", "/healthz"} { + t.Run(path, func(t *testing.T) { + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil) + response := httptest.NewRecorder() + + server.Handler.ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent) + } + }) + } +} + +func TestQuoteDelegatesOneRequestedAmountToStrategy(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + strategy := "eTestStrategy{quote: &strategytypes.Quote{AmountIn: big.NewInt(100), AmountOut: big.NewInt(90)}} + solver := newQuoteTestSolver(t, tokenIn, strategy) + request := validQuoteRequest(tokenIn, tokenOut) + + response, err := solver.quote(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if response.AmountIn != "100" || response.AmountOut != "90" { + t.Fatalf("response = %+v", response) + } + if len(strategy.inputs) != 1 || strategy.inputs[0].AmountIn.String() != "100" || strategy.inputs[0].AmountOut != nil { + t.Fatalf("strategy inputs = %+v", strategy.inputs) + } + if strategy.inputs[0].TokenIn != tokenIn || strategy.inputs[0].TokenOut != tokenOut { + t.Fatalf("strategy pair = %s -> %s", strategy.inputs[0].TokenIn, strategy.inputs[0].TokenOut) + } + + request.Type = quoteTypeExactOutput + request.Amount = "70" + strategy.quote = &strategytypes.Quote{AmountIn: big.NewInt(80), AmountOut: big.NewInt(70)} + response, err = solver.quote(t.Context(), request) + if err != nil || response.AmountIn != "80" || response.AmountOut != "70" { + t.Fatalf("exact-output response = %+v, err %v", response, err) + } + if strategy.inputs[1].AmountIn != nil || strategy.inputs[1].AmountOut.String() != "70" { + t.Fatalf("exact-output strategy input = %+v", strategy.inputs[1]) + } +} + +func TestQuoteRejectsStrategyThatChangesRequestedSide(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + strategy := "eTestStrategy{quote: &strategytypes.Quote{AmountIn: big.NewInt(99), AmountOut: big.NewInt(90)}} + solver := newQuoteTestSolver(t, tokenIn, strategy) + + if _, err := solver.quote(t.Context(), validQuoteRequest(tokenIn, tokenOut)); err == nil { + t.Fatal("quote error = nil, want changed exact-input rejection") + } +} + +func TestQuoteDoesNotSelfBlockIndicativeThenHardRound(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + strategy := "eTestStrategy{quote: &strategytypes.Quote{AmountIn: big.NewInt(100), AmountOut: big.NewInt(90)}} + solver := newQuoteTestSolver(t, tokenIn, strategy) + request := validQuoteRequest(tokenIn, tokenOut) + + for _, quoteID := range []string{"indicative", "indicative", "hard"} { + request.QuoteID = quoteID + response, err := solver.quote(t.Context(), request) + if err != nil || response.AmountOut != "90" { + t.Fatalf("quote %s = %+v, err %v", quoteID, response, err) + } + } + if reservations := solver.capacity.Snapshot(); len(reservations) != 0 { + t.Fatalf("quotes unexpectedly reserved capacity: %v", reservations) + } +} + +func TestQuoteDeclinesExpiredState(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + strategy := "eTestStrategy{quote: &strategytypes.Quote{AmountIn: big.NewInt(100), AmountOut: big.NewInt(90)}} + solver := newQuoteTestSolver(t, tokenIn, strategy) + state := solver.quoteState.Load() + state.expiresAt = time.Now().Add(-time.Second) + + response, err := solver.quote(t.Context(), validQuoteRequest(tokenIn, tokenOut)) + if err != nil || response.AmountOut != "0" || response.declineReason != "quote-state-unavailable" || + len(strategy.inputs) != 0 { + t.Fatalf("expired response = %+v, inputs = %d, err %v", response, len(strategy.inputs), err) + } +} + +func TestQuoteDeclinesStatePublishedForOldEpoch(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + strategy := "eTestStrategy{quote: &strategytypes.Quote{AmountIn: big.NewInt(100), AmountOut: big.NewInt(90)}} + solver := newQuoteTestSolver(t, tokenIn, strategy) + solver.quoteEpoch.Store(1) + + response, err := solver.quote(t.Context(), validQuoteRequest(tokenIn, tokenOut)) + + if err != nil || response.AmountOut != "0" || len(strategy.inputs) != 0 { + t.Fatalf("stale-epoch response = %+v, inputs = %d, err %v", response, len(strategy.inputs), err) + } +} + +func TestQuoteDeclinesWhenStateChangesDuringStrategy(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + tokenOut := common.HexToAddress("0x2222222222222222222222222222222222222222") + + tests := []struct { + name string + invalidate func(*Solver) + }{ + { + name: "fill planning", + invalidate: func(s *Solver) { + s.beginFillPlanning() + s.endFillPlanning() + }, + }, + { + name: "reservation", + invalidate: func(s *Solver) { + s.setPendingReservations(common.HexToHash("0x1"), liquidlane.CapacityReservations{ + "capacity-1": big.NewInt(1), + }) + }, + }, + { + name: "breaker notification", + invalidate: func(s *Solver) { + s.setBlockUntil(time.Now().Add(time.Minute).Unix()) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + strategy := &blockingQuoteStrategy{ + entered: make(chan struct{}), release: make(chan struct{}), + quote: &strategytypes.Quote{AmountIn: big.NewInt(100), AmountOut: big.NewInt(90)}, + } + solver := newBlockingQuoteTestSolver(t, tokenIn, strategy) + result := make(chan quoteResponse, 1) + errs := make(chan error, 1) + go func() { + response, err := solver.quote(t.Context(), validQuoteRequest(tokenIn, tokenOut)) + result <- response + errs <- err + }() + + <-strategy.entered + tc.invalidate(solver) + close(strategy.release) + + if err := <-errs; err != nil { + t.Fatal(err) + } + if response := <-result; response.AmountOut != "0" { + t.Fatalf("invalidated quote = %+v", response) + } + }) + } +} + +func TestQuoteDeclinesNativeOutput(t *testing.T) { + tokenIn := common.HexToAddress("0x1111111111111111111111111111111111111111") + strategy := "eTestStrategy{quote: &strategytypes.Quote{AmountIn: big.NewInt(10), AmountOut: big.NewInt(10)}} + solver := newQuoteTestSolver(t, tokenIn, strategy) + request := validQuoteRequest(tokenIn, common.Address{}) + response, err := solver.quote(t.Context(), request) + if err != nil || response.AmountOut != "0" || len(strategy.inputs) != 0 { + t.Fatalf("native quote = %+v, inputs = %d, err %v", response, len(strategy.inputs), err) + } +} + +func TestQuoteHandlerHonorsCircuitBreaker(t *testing.T) { + solver := &Solver{cfg: &Config{Executor: common.HexToAddress("0x1111111111111111111111111111111111111111")}} + solver.chainTime.Store(100) + + notification := httptest.NewRequestWithContext( + t.Context(), http.MethodPost, "/quote", bytes.NewBufferString(`{"blockUntilTimestamp":4000000000}`), + ) + notificationResponse := httptest.NewRecorder() + solver.quoteHandler(notificationResponse, notification) + if notificationResponse.Code != http.StatusNoContent || solver.blockUntil.Load() != 4_000_000_000 { + t.Fatalf("notification response/block = %d/%d", notificationResponse.Code, solver.blockUntil.Load()) + } + + response, err := solver.quote(t.Context(), quoteRequest{RequestID: "request", QuoteID: "quote"}) + if err != nil || response.AmountOut != "0" { + t.Fatalf("blocked quote = %+v, err %v", response, err) + } + + clearRequest := httptest.NewRequestWithContext( + t.Context(), http.MethodPost, "/quote", bytes.NewBufferString(`{"blockUntilTimestamp":0}`), + ) + clearResponse := httptest.NewRecorder() + solver.quoteHandler(clearResponse, clearRequest) + if clearResponse.Code != http.StatusNoContent || solver.blockUntil.Load() != 0 { + t.Fatalf("clear response/block = %d/%d", clearResponse.Code, solver.blockUntil.Load()) + } +} + +func TestQuoteHandlerRejectsMissingBreakerTimestamp(t *testing.T) { + solver := &Solver{cfg: &Config{Executor: common.HexToAddress("0x1111111111111111111111111111111111111111")}} + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/quote", bytes.NewBufferString(`{}`)) + response := httptest.NewRecorder() + solver.quoteHandler(response, request) + if response.Code != http.StatusBadRequest || solver.blockUntil.Load() != 0 { + t.Fatalf("response/block = %d/%d, want 400/0", response.Code, solver.blockUntil.Load()) + } +} + +func TestQuoteHandlerRejectsTrailingJSON(t *testing.T) { + solver := &Solver{cfg: &Config{Executor: common.HexToAddress("0x1111111111111111111111111111111111111111")}} + request := httptest.NewRequestWithContext( + t.Context(), http.MethodPost, "/quote", + bytes.NewBufferString(`{"blockUntilTimestamp":4000000000} {"blockUntilTimestamp":5000000000}`), + ) + response := httptest.NewRecorder() + solver.quoteHandler(response, request) + if response.Code != http.StatusBadRequest || solver.blockUntil.Load() != 0 { + t.Fatalf("response/block = %d/%d, want 400/0", response.Code, solver.blockUntil.Load()) + } +} + +func newQuoteTestSolver(t *testing.T, tokenIn common.Address, strategy *quoteTestStrategy) *Solver { + t.Helper() + policy, err := tokenpolicy.New(tokenpolicy.All, nil) + if err != nil { + t.Fatal(err) + } + now := time.Now() + solver := &Solver{ + chainID: 1, + cfg: &Config{ + Executor: common.HexToAddress("0x3333333333333333333333333333333333333333"), + TokenPolicy: policy, + }, + strategy: strategy, + } + solver.quoteState.Store("eState{ + maxFeePerGas: big.NewInt(1), chainTime: now, expiresAt: now.Add(time.Minute), + singleRouteFor: map[common.Address]bool{tokenIn: true}, + }) + return solver +} + +func newBlockingQuoteTestSolver(t *testing.T, tokenIn common.Address, strategy strategytypes.Strategy) *Solver { + t.Helper() + policy, err := tokenpolicy.New(tokenpolicy.All, nil) + if err != nil { + t.Fatal(err) + } + now := time.Now() + solver := &Solver{ + chainID: 1, + cfg: &Config{ + Executor: common.HexToAddress("0x3333333333333333333333333333333333333333"), + TokenPolicy: policy, + }, + strategy: strategy, + } + solver.quoteState.Store("eState{ + maxFeePerGas: big.NewInt(1), chainTime: now, expiresAt: now.Add(time.Minute), + singleRouteFor: map[common.Address]bool{tokenIn: true}, + }) + return solver +} + +func validQuoteRequest(tokenIn, tokenOut common.Address) quoteRequest { + return quoteRequest{ + RequestID: "request-1", QuoteID: "quote-1", TokenInChainID: 1, TokenOutChainID: 1, + Swapper: common.HexToAddress("0x4444444444444444444444444444444444444444").Hex(), + TokenIn: tokenIn.Hex(), TokenOut: tokenOut.Hex(), Amount: "100", + Type: quoteTypeExactInput, NumOutputs: 1, Protocol: "v1", + } +} + +type quoteTestStrategy struct { + quote *strategytypes.Quote + inputs []strategytypes.QuoteInput +} + +func (s *quoteTestStrategy) DecideQuote(_ context.Context, input strategytypes.QuoteInput) (*strategytypes.Quote, error) { + s.inputs = append(s.inputs, input) + return s.quote, nil +} + +func (s *quoteTestStrategy) DecideFill(context.Context, strategytypes.FillInput) (*strategytypes.FillPlan, error) { + return nil, nil +} + +type blockingQuoteStrategy struct { + entered chan struct{} + release chan struct{} + quote *strategytypes.Quote +} + +func (s *blockingQuoteStrategy) DecideQuote( + ctx context.Context, + _ strategytypes.QuoteInput, +) (*strategytypes.Quote, error) { + close(s.entered) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-s.release: + return s.quote, nil + } +} + +func (s *blockingQuoteStrategy) DecideFill( + context.Context, + strategytypes.FillInput, +) (*strategytypes.FillPlan, error) { + return nil, nil +} diff --git a/internal/solvers/uniswapx/solver.go b/internal/solvers/uniswapx/solver.go new file mode 100644 index 00000000..e1b13abe --- /dev/null +++ b/internal/solvers/uniswapx/solver.go @@ -0,0 +1,264 @@ +// Package uniswapx implements UniswapX RFQ and public V2 filling backed by LiquidLane. +package uniswapx + +import ( + "context" + "math/big" + "net/http" + "os" + "sync" + "sync/atomic" + "time" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + "golang.org/x/sync/errgroup" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquiddiscounts "github.com/symbioticfi/vault-solver/internal/liquidlane/discounts" + "github.com/symbioticfi/vault-solver/internal/solver" + strategytypes "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/types" + "github.com/symbioticfi/vault-solver/internal/txmanager" +) + +const Name = "uniswapx-filler" + +const orderQueueCapacity = 256 + +//nolint:gochecknoinits // solver registration follows the framework plugin convention. +func init() { solver.Register(Name, factory) } + +type Solver struct { + cfg *Config + chainID int64 + solverAddress common.Address + chain contractCaller + reader chainReader + strategy strategytypes.Strategy + txm transactionManager + orders orderPoller + discounts liquiddiscounts.Provider + log logr.Logger + + // refreshMu serializes chain snapshots. quoteState is immutable after publication and is + // replaced atomically. Quote requests are stateless because Uniswap intentionally hides + // whether each request is indicative or hard. + refreshMu sync.Mutex + quoteState atomic.Pointer[quoteState] + quoteEpoch atomic.Uint64 + planningFills atomic.Int64 + chainTime atomic.Int64 + blockUntil atomic.Int64 + localBlockUntil atomic.Int64 + exclusiveBlockUntil atomic.Int64 + exclusiveStateUnknown atomic.Bool + warmupUntil atomic.Int64 + lastExclusivePoll atomic.Int64 + refreshCh chan struct{} + // stateMu guards order retry/dedup and breaker history. + stateMu sync.Mutex + filled map[common.Hash]time.Time + retryAt map[common.Hash]time.Time + inFlight map[common.Hash]bool + attempts map[common.Hash]int + capacity liquidlane.CapacityLedger + exclusiveUntil map[common.Hash]time.Time + exclusiveTerminal map[common.Hash]time.Time + failureTimes []time.Time + metrics *uniswapXMetrics +} + +type chainReader interface { + resolveRoutes(ctx context.Context, adapters []common.Address) ([]liquidlane.Route, error) + validateExecutorCode(ctx context.Context, executor common.Address) error + validateExecutorCaller(ctx context.Context, executor, caller common.Address) error + unauthorizedAdapters( + ctx context.Context, + executor common.Address, + routes []liquidlane.Route, + ) ([]common.Address, error) + validateGasTokens(routes []liquidlane.Route) error + quoteSnapshot(ctx context.Context, routes []liquidlane.Route, executor common.Address, now time.Time) (snapshot, error) + fillSnapshot( + ctx context.Context, + routes []liquidlane.Route, + executor common.Address, + tokenIn common.Address, + amountIn *big.Int, + now time.Time, + ) (fillSnapshot, error) + physicalFillQuotes( + ctx context.Context, + routes []liquidlane.Route, + tokenIn common.Address, + amountIn *big.Int, + ) ([]liquidlane.FillQuote, error) + latestBlockTime(ctx context.Context) (time.Time, error) + transactionBlockTime(ctx context.Context, txHash common.Hash) (time.Time, error) +} + +type orderPoller interface { + openOrders(ctx context.Context, chainID int64, filler *common.Address) ([]orderEntry, error) + ordersByHash( + ctx context.Context, + chainID int64, + hashes []common.Hash, + ) (map[common.Hash]orderTerminal, error) +} + +type transactionManager interface { + MaxFeePerGas(ctx context.Context) (*big.Int, error) + SendAsync(ctx context.Context, request txmanager.Request) (<-chan txmanager.Result, bool) +} + +type contractCaller interface { + CallContract(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) +} + +func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { + cfg, err := parseConfig(raw) + if err != nil { + return nil, err + } + orderKey := os.Getenv(cfg.OrderServer.APIKeyEnv) + if orderKey == "" { + return nil, errors.New("UniswapX order API key env must be non-empty") + } + log := deps.Log.WithName(Name) + reader, err := newReader(deps.Chain, log, cfg.Gas, cfg.LiquidityLens) + if err != nil { + return nil, err + } + strategy, err := newStrategy(cfg.Strategy) + if err != nil { + return nil, err + } + var metrics *uniswapXMetrics + if deps.Metrics != nil { + metrics, err = newUniswapXMetrics(deps.Metrics.Registerer()) + if err != nil { + return nil, err + } + } + var discountClient liquiddiscounts.Provider + if cfg.usesDiscounts() { + discountClient = liquiddiscounts.NewClient(cfg.Discounts.BaseURL) + } + return &Solver{ + cfg: cfg, + chainID: deps.Chain.ChainID().Int64(), + solverAddress: deps.Signer.Address(), + chain: deps.Chain, + reader: reader, + strategy: strategy, + txm: deps.TxManager, + orders: newOrderClient(cfg.OrderServer, orderKey), + discounts: discountClient, + log: log, + refreshCh: make(chan struct{}, 1), + filled: make(map[common.Hash]time.Time), + retryAt: make(map[common.Hash]time.Time), + inFlight: make(map[common.Hash]bool), + attempts: make(map[common.Hash]int), + metrics: metrics, + exclusiveUntil: make(map[common.Hash]time.Time), + exclusiveTerminal: make(map[common.Hash]time.Time), + }, nil +} + +func (s *Solver) Name() string { return Name } + +func (s *Solver) Run(ctx context.Context) error { + routes, err := s.reader.resolveRoutes(ctx, s.cfg.Adapters) + if err != nil { + startupErr := errors.Errorf("resolve routes: %w", err) + s.log.Error(startupErr, "adapter resolution failed", + "solverMode", s.cfg.SolverMode, "executor", s.cfg.Executor.Hex(), "adapters", s.cfg.Adapters) + return startupErr + } + if len(routes) == 0 && s.cfg.restrictsToAdapters() { + startupErr := errors.New("no LiquidLane routes resolved") + s.log.Error(startupErr, "adapter resolution failed", + "solverMode", s.cfg.SolverMode, "executor", s.cfg.Executor.Hex(), "adapters", s.cfg.Adapters) + return startupErr + } + if err := s.reader.validateExecutorCode(ctx, s.cfg.Executor); err != nil { + startupErr := errors.Errorf("validate executor: %w", err) + s.log.Error(startupErr, "executor validation failed", "executor", s.cfg.Executor.Hex()) + return startupErr + } + if err := s.reader.validateExecutorCaller(ctx, s.cfg.Executor, s.solverAddress); err != nil { + startupErr := errors.Errorf("validate executor caller: %w", err) + s.log.Error( + startupErr, + "executor caller validation failed", + "executor", s.cfg.Executor.Hex(), + "caller", s.solverAddress.Hex(), + ) + return startupErr + } + if s.cfg.restrictsToAdapters() { + unauthorized, err := s.reader.unauthorizedAdapters(ctx, s.cfg.Executor, routes) + if err != nil { + startupErr := errors.Errorf("validate adapters: %w", err) + s.log.Error(startupErr, "adapter validation failed", + "solverMode", s.cfg.SolverMode, "executor", s.cfg.Executor.Hex(), "adapters", s.cfg.Adapters) + return startupErr + } + if len(unauthorized) > 0 { + startupErr := errors.Errorf( + "validate adapters: executor %s is not authorized as direct filler for configured adapters: %v", + s.cfg.Executor.Hex(), unauthorized, + ) + s.log.Error(startupErr, "adapter validation failed", + "solverMode", s.cfg.SolverMode, "executor", s.cfg.Executor.Hex(), "adapters", s.cfg.Adapters) + return startupErr + } + } + if err := s.reader.validateGasTokens(routes); err != nil { + startupErr := errors.Errorf("validate adapter gas tokens: %w", err) + s.log.Error(startupErr, "adapter validation failed", "executor", s.cfg.Executor.Hex(), "adapters", s.cfg.Adapters) + return startupErr + } + if _, err := s.orders.openOrders(ctx, s.chainID, &s.cfg.Executor); err != nil { + startupErr := errors.Errorf("validate exclusive order delivery: %w", err) + s.log.Error(startupErr, "exclusive order delivery validation failed", + "executor", s.cfg.Executor.Hex(), "orderApi", s.cfg.OrderServer.BaseURL) + return startupErr + } + s.recordExclusivePollSuccess(time.Now()) + s.warmupUntil.Store(time.Now().Add(s.cfg.QuoteServer.QuoteTTL).Unix()) + if err := s.refreshQuoteState(ctx, routes); err != nil { + startupErr := errors.Errorf("initial quote refresh: %w", err) + s.log.Error(startupErr, "initial quote refresh failed", "routes", len(routes)) + return startupErr + } + s.log.Info("starting", "chainId", s.chainID, "solverMode", s.cfg.SolverMode, + "reactor", s.cfg.Reactor.Hex(), "executor", s.cfg.Executor.Hex(), + "routes", len(routes), "gasAccounting", s.cfg.Gas != nil, + "listen", s.cfg.QuoteServer.ListenAddress, "orderApi", s.cfg.OrderServer.BaseURL) + + server := s.newQuoteHTTPServer() + g, groupCtx := errgroup.WithContext(ctx) + g.Go(func() error { + err := server.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil + }) + g.Go(func() error { + <-groupCtx.Done() + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(groupCtx), 2*time.Second) + defer cancel() + return server.Shutdown(shutdownCtx) + }) + g.Go(func() error { return s.refreshLoop(groupCtx, routes) }) + orders := make(chan *resolvedOrder, orderQueueCapacity) + g.Go(func() error { return s.orderLoop(groupCtx, orders) }) + g.Go(func() error { return s.fillLoop(groupCtx, routes, orders) }) + return g.Wait() +} diff --git a/internal/solvers/uniswapx/solver_test.go b/internal/solvers/uniswapx/solver_test.go new file mode 100644 index 00000000..0d27648d --- /dev/null +++ b/internal/solvers/uniswapx/solver_test.go @@ -0,0 +1,256 @@ +package uniswapx + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + "github.com/go-logr/logr" + "github.com/go-logr/logr/funcr" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +type orderPollerFunc func(context.Context, int64, *common.Address) ([]orderEntry, error) + +func (f orderPollerFunc) openOrders(ctx context.Context, chainID int64, filler *common.Address) ([]orderEntry, error) { + return f(ctx, chainID, filler) +} + +func (f orderPollerFunc) ordersByHash( + context.Context, + int64, + []common.Hash, +) (map[common.Hash]orderTerminal, error) { + return nil, errors.New("terminal lookup is not configured") +} + +type countingChainReader struct { + chainReader + + latestCalls int +} + +type startupChainReader struct { + chainReader + + routes []liquidlane.Route + executorErr error + adapterErr error + callerErr error + unauthorized []common.Address + executor common.Address + caller common.Address + + authorizationCalls int +} + +func (r *startupChainReader) resolveRoutes(context.Context, []common.Address) ([]liquidlane.Route, error) { + return r.routes, nil +} + +func (r *startupChainReader) validateExecutorCode( + _ context.Context, + executor common.Address, +) error { + r.executor = executor + return r.executorErr +} + +func (r *startupChainReader) validateExecutorCaller( + _ context.Context, + executor, caller common.Address, +) error { + r.executor = executor + r.caller = caller + return r.callerErr +} + +func (r *startupChainReader) unauthorizedAdapters( + _ context.Context, + executor common.Address, + _ []liquidlane.Route, +) ([]common.Address, error) { + r.authorizationCalls++ + r.executor = executor + return r.unauthorized, r.adapterErr +} + +func (r *startupChainReader) validateGasTokens([]liquidlane.Route) error { return nil } + +func TestRunLogsStartupValidationFailures(t *testing.T) { + adapter := common.HexToAddress("0x1111111111111111111111111111111111111111") + executor := common.HexToAddress("0x2222222222222222222222222222222222222222") + + tests := []struct { + name string + executorErr error + callerErr error + adapterErr error + unauthorized []common.Address + wantError string + wantMessage string + }{ + { + name: "executor", + executorErr: errors.New("executor has no bytecode"), + wantError: "validate executor: executor has no bytecode", wantMessage: "executor validation failed", + }, + { + name: "caller", + callerErr: errors.New("caller is not authorized"), + wantError: "validate executor caller: caller is not authorized", + wantMessage: "executor caller validation failed", + }, + { + name: "adapter", + unauthorized: []common.Address{adapter}, + wantError: "is not authorized as direct filler", wantMessage: "adapter validation failed", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var logs []string + reader := &startupChainReader{ + routes: []liquidlane.Route{{Adapter: adapter}}, executorErr: tc.executorErr, + callerErr: tc.callerErr, adapterErr: tc.adapterErr, unauthorized: tc.unauthorized, + } + s := &Solver{ + cfg: &Config{ + Executor: executor, Adapters: []common.Address{adapter}, + SolverMode: solverModeExternal, + }, + solverAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + reader: reader, + log: funcr.NewJSON(func(entry string) { logs = append(logs, entry) }, funcr.Options{}), + } + + err := s.Run(t.Context()) + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("Run() error = %v, want %q", err, tc.wantError) + } + logged := strings.Join(logs, "\n") + if !strings.Contains(logged, tc.wantMessage) || + !strings.Contains(logged, tc.wantError) || + !strings.Contains(logged, executor.Hex()) || + !strings.Contains(logged, `"error"`) { + t.Fatalf("startup failure was not logged with its reason: %s", logged) + } + if reader.executor != executor { + t.Fatalf("executor validation address = %s, want %s", reader.executor, executor) + } + if tc.callerErr != nil && reader.caller != s.solverAddress { + t.Fatalf("caller validation address = %s, want %s", reader.caller, s.solverAddress) + } + if tc.callerErr != nil && !strings.Contains(logged, s.solverAddress.Hex()) { + t.Fatalf("caller validation log omitted caller %s: %s", s.solverAddress, logged) + } + }) + } +} + +func TestRunInternalModeAllowsNoAdaptersAndSkipsDirectAuthorizationGate(t *testing.T) { + executor := common.HexToAddress("0x2222222222222222222222222222222222222222") + stop := errors.New("stop after startup authorization") + reader := &startupChainReader{ + adapterErr: errors.New("direct authorization is unavailable"), + } + solver := &Solver{ + cfg: &Config{ + Executor: executor, SolverMode: solverModeInternal, Discounts: &DiscountConfig{}, + }, + solverAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + reader: reader, + orders: orderPollerFunc(func(context.Context, int64, *common.Address) ([]orderEntry, error) { + return nil, stop + }), + log: logr.Discard(), + } + + err := solver.Run(t.Context()) + if err == nil || !strings.Contains(err.Error(), stop.Error()) { + t.Fatalf("Run() error = %v, want later order-service failure", err) + } + if reader.authorizationCalls != 0 { + t.Fatalf("direct authorization calls = %d, want 0 in internal mode", reader.authorizationCalls) + } +} + +func (r *countingChainReader) latestBlockTime(context.Context) (time.Time, error) { + r.latestCalls++ + return time.Unix(1_000, 0), nil +} + +func TestPollOrdersProcessesExclusiveBeforePublicFailure(t *testing.T) { + reader := &countingChainReader{} + executor := common.HexToAddress("0x1111111111111111111111111111111111111111") + solver := &Solver{ + cfg: &Config{ + Executor: executor, + OrderServer: OrderServerConfig{Sources: OrderSourcesConfig{ExclusiveV2: true, PublicV2: true}}, + }, + chainID: 1, + reader: reader, + orders: orderPollerFunc(func(_ context.Context, _ int64, filler *common.Address) ([]orderEntry, error) { + if filler != nil { + return []orderEntry{{OrderHash: "exclusive-was-processed"}}, nil + } + return nil, errors.New("public unavailable") + }), + log: logr.Discard(), + filled: make(map[common.Hash]time.Time), + retryAt: make(map[common.Hash]time.Time), + } + err := solver.pollOrders(t.Context(), make(chan *resolvedOrder, 1)) + if err == nil || !strings.Contains(err.Error(), "poll public-v2 orders: public unavailable") { + t.Fatalf("pollOrders() error = %v", err) + } + if reader.latestCalls != 1 { + t.Fatalf("exclusive latestBlockTime calls = %d, want 1", reader.latestCalls) + } +} + +func TestPollOrdersKeepsUnknownExclusivePendingAndStopsQuotes(t *testing.T) { + hash := common.HexToHash("0x1234") + now := time.Unix(1_000, 0) + solver := &Solver{ + cfg: &Config{ + OrderServer: OrderServerConfig{ + PollInterval: time.Second, + Sources: OrderSourcesConfig{ExclusiveV2: true}, + }, + Breaker: BreakerConfig{Window: time.Minute}, + }, + chainID: 1, + reader: &countingChainReader{}, + orders: &stateTestOrderPoller{terminals: map[common.Hash]orderTerminal{}}, + log: logr.Discard(), + filled: make(map[common.Hash]time.Time), + retryAt: make(map[common.Hash]time.Time), + inFlight: make(map[common.Hash]bool), + attempts: make(map[common.Hash]int), + } + solver.trackExclusive(&resolvedOrder{ + Hash: hash, Source: orderSourceExclusiveV2, ExclusiveUntil: uint64(now.Add(-time.Second).Unix()), + }, now.Add(-2*time.Second)) + solver.quoteState.Store("eState{expiresAt: now.Add(time.Minute)}) + + err := solver.pollOrders(t.Context(), make(chan *resolvedOrder, 1)) + + if err == nil || !strings.Contains(err.Error(), "missing result") { + t.Fatalf("pollOrders() error = %v, want terminal lookup failure", err) + } + if !solver.exclusiveStateUnknown.Load() || solver.quoteState.Load() != nil { + t.Fatal("unknown exclusive state did not stop quotes") + } + if solver.exclusiveBlockUntil.Load() != 0 { + t.Fatal("unknown exclusive state was counted as a fade") + } + if _, pending := solver.exclusiveUntil[hash]; !pending { + t.Fatal("unknown exclusive obligation was not retained for retry") + } +} diff --git a/internal/solvers/uniswapx/state.go b/internal/solvers/uniswapx/state.go new file mode 100644 index 00000000..c5ba36b3 --- /dev/null +++ b/internal/solvers/uniswapx/state.go @@ -0,0 +1,317 @@ +package uniswapx + +import ( + "context" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +const ( + orderStatusCancelled = "cancelled" + orderStatusError = "error" + orderStatusExpired = "expired" + orderStatusFilled = "filled" + orderStatusInsufficientFunds = "insufficient-funds" +) + +type orderTerminal struct { + Status string + TxHash common.Hash +} + +type exclusiveObligation struct { + hash common.Hash + deadline time.Time +} + +type exclusiveDecision struct { + exclusiveObligation + + settledInTime bool + txHash common.Hash + filledAt time.Time + status string +} + +func (s *Solver) requestQuoteRefresh() { + if s.refreshCh == nil { + return + } + select { + case s.refreshCh <- struct{}{}: + default: + } +} + +func (s *Solver) setPendingReservations(hash common.Hash, reservations liquidlane.CapacityReservations) { + if !s.capacity.Set(hash.Hex(), reservations) { + return + } + if s.metrics != nil { + s.metrics.pendingFills.Set(float64(s.capacity.Len())) + } + s.log.V(1).Info( + "fill capacity reserved", + "orderHash", hash.Hex(), + "capacityGroups", len(reservations), + "pendingFills", s.capacity.Len(), + ) + s.invalidateQuotes() + s.requestQuoteRefresh() +} + +func (s *Solver) clearPendingReservations(hash common.Hash) { + // Stop quotes before releasing capacity. The next snapshot must observe the fill outcome + // before the released capacity can be advertised again. + s.invalidateQuotes() + if !s.capacity.Delete(hash.Hex()) { + return + } + if s.metrics != nil { + s.metrics.pendingFills.Set(float64(s.capacity.Len())) + } + s.log.V(1).Info( + "fill capacity released", + "orderHash", hash.Hex(), + "pendingFills", s.capacity.Len(), + ) + s.requestQuoteRefresh() +} + +func (s *Solver) recordFillFailure(now time.Time) { + s.stateMu.Lock() + cutoff := now.Add(-s.cfg.Breaker.Window) + kept := s.failureTimes[:0] + for _, failure := range s.failureTimes { + if failure.After(cutoff) { + kept = append(kept, failure) + } + } + s.failureTimes = append(kept, now) + tripped := len(s.failureTimes) >= s.cfg.Breaker.MaxFailures + if tripped { + s.failureTimes = nil + s.localBlockUntil.Store(now.Add(s.cfg.Breaker.Window).Unix()) + } + s.stateMu.Unlock() + if tripped { + s.invalidateQuotes() + s.updateBlockUntilMetric() + s.log.Info("local fade breaker opened", "until", s.localBlockUntil.Load()) + } +} + +func (s *Solver) recordFillSuccess() { + s.stateMu.Lock() + hadFailures := len(s.failureTimes) > 0 + s.failureTimes = nil + s.stateMu.Unlock() + blockedUntil := s.localBlockUntil.Swap(0) + s.updateBlockUntilMetric() + if hadFailures || blockedUntil != 0 { + s.log.V(1).Info( + "local fill breaker cleared", + "hadFailures", hadFailures, + "previousBlockUntil", blockedUntil, + ) + } +} + +func (s *Solver) trackExclusive(order *resolvedOrder, now time.Time) { + if order.Source != orderSourceExclusiveV2 || order.ExclusiveUntil == 0 { + return + } + s.stateMu.Lock() + s.cleanupExclusiveLocked(now) + tracked := false + var deadline time.Time + if _, terminal := s.exclusiveTerminal[order.Hash]; !terminal { + deadline = time.Unix(int64(order.ExclusiveUntil), 0) + if current, exists := s.exclusiveUntil[order.Hash]; !exists || deadline.Before(current) { + s.exclusiveUntil[order.Hash] = deadline + tracked = true + } + } + s.stateMu.Unlock() + if tracked { + s.log.V(1).Info( + "exclusive obligation tracked", + "orderHash", order.Hash.Hex(), + "quoteId", order.QuoteID, + "exclusiveUntil", deadline.Unix(), + ) + } +} + +func (s *Solver) sweepExclusive(ctx context.Context, now time.Time) error { + s.stateMu.Lock() + s.cleanupExclusiveLocked(now) + expired := make([]exclusiveObligation, 0, len(s.exclusiveUntil)) + for hash, deadline := range s.exclusiveUntil { + if now.After(deadline) { + expired = append(expired, exclusiveObligation{hash: hash, deadline: deadline}) + } + } + s.stateMu.Unlock() + if len(expired) == 0 { + return nil + } + s.log.V(1).Info( + "exclusive obligations reconciliation started", + "obligations", len(expired), + "chainTime", now.Unix(), + ) + + hashes := make([]common.Hash, len(expired)) + for i := range expired { + hashes[i] = expired[i].hash + } + terminals, err := s.orders.ordersByHash(ctx, s.chainID, hashes) + if err != nil { + return errors.Errorf("lookup expired obligations: %w", err) + } + decisions := make([]exclusiveDecision, 0, len(expired)) + for _, obligation := range expired { + terminal, ok := terminals[obligation.hash] + if !ok { + return errors.Errorf("lookup expired obligation %s: missing result", obligation.hash.Hex()) + } + decision := exclusiveDecision{ + exclusiveObligation: obligation, + txHash: terminal.TxHash, + status: terminal.Status, + } + switch terminal.Status { + case orderStatusFilled: + if terminal.TxHash == (common.Hash{}) { + return errors.Errorf("lookup expired obligation %s: filled order has no transaction", obligation.hash.Hex()) + } + filledAt, readErr := s.reader.transactionBlockTime(ctx, terminal.TxHash) + if readErr != nil { + return errors.Errorf("lookup expired obligation %s fill time: %w", obligation.hash.Hex(), readErr) + } + decision.filledAt = filledAt + // Uniswap counts the original quoter as faded whenever exclusivity expires + // unfilled, even if our executor later wins the public Dutch auction. + decision.settledInTime = !filledAt.After(obligation.deadline) + case orderStatusOpen: + return errors.Errorf( + "lookup expired obligation %s: order is still open", + obligation.hash.Hex(), + ) + case orderStatusExpired, orderStatusError, orderStatusCancelled, orderStatusInsufficientFunds: + // Only a successful on-chain fill before exclusivity ends discharges the obligation. + // Every other known lifecycle state means the awarded fill was not delivered in time. + if terminal.TxHash != (common.Hash{}) { + return errors.Errorf( + "lookup expired obligation %s: status %q unexpectedly has transaction %s", + obligation.hash.Hex(), + terminal.Status, + terminal.TxHash.Hex(), + ) + } + default: + return errors.Errorf( + "lookup expired obligation %s: unknown status %q", + obligation.hash.Hex(), + terminal.Status, + ) + } + decisions = append(decisions, decision) + } + + var missed []exclusiveDecision + var settled []exclusiveDecision + s.stateMu.Lock() + s.cleanupExclusiveLocked(now) + for _, decision := range decisions { + deadline, tracked := s.exclusiveUntil[decision.hash] + if !tracked || !deadline.Equal(decision.deadline) { + continue + } + delete(s.exclusiveUntil, decision.hash) + s.exclusiveTerminal[decision.hash] = now + if decision.settledInTime { + settled = append(settled, decision) + } else { + missed = append(missed, decision) + } + } + s.stateMu.Unlock() + + for _, decision := range settled { + s.observeFill("exclusive-settled-in-time") + s.log.Info( + "exclusive order settled before exclusivity ended", + "orderHash", decision.hash.Hex(), + "tx", decision.txHash.Hex(), + "filledAt", decision.filledAt.Unix(), + "exclusiveUntil", decision.deadline.Unix(), + ) + } + s.openExclusiveBreaker(missed, now) + return nil +} + +func (s *Solver) openExclusiveBreaker(missed []exclusiveDecision, now time.Time) { + if len(missed) == 0 { + return + } + blockedUntil := now.Add(s.cfg.Breaker.Window).Unix() + if blockedUntil > s.exclusiveBlockUntil.Load() { + s.exclusiveBlockUntil.Store(blockedUntil) + } + s.invalidateQuotes() + s.updateBlockUntilMetric() + for _, decision := range missed { + s.observeFill("missed-exclusive") + fields := []any{ + "orderHash", decision.hash.Hex(), + "status", decision.status, + "exclusiveUntil", decision.deadline.Unix(), + "blockUntil", s.exclusiveBlockUntil.Load(), + } + if decision.txHash != (common.Hash{}) { + fields = append(fields, "tx", decision.txHash.Hex(), "filledAt", decision.filledAt.Unix()) + } + s.log.Error(errors.New("exclusive fill missed decay start"), "exclusive obligation missed", fields...) + } +} + +func (s *Solver) cleanupExclusiveLocked(now time.Time) { + if s.exclusiveUntil == nil { + s.exclusiveUntil = make(map[common.Hash]time.Time) + } + if s.exclusiveTerminal == nil { + s.exclusiveTerminal = make(map[common.Hash]time.Time) + } + for hash, terminalAt := range s.exclusiveTerminal { + if now.Sub(terminalAt) > time.Hour { + delete(s.exclusiveTerminal, hash) + } + } +} + +func (s *Solver) invalidateQuotes() { + s.quoteEpoch.Add(1) + s.quoteState.Store(nil) +} + +func (s *Solver) beginFillPlanning() { + s.planningFills.Add(1) + s.quoteEpoch.Add(1) + s.quoteState.Store(nil) +} + +func (s *Solver) endFillPlanning() { + remaining := s.planningFills.Add(-1) + s.quoteEpoch.Add(1) + if remaining < 0 { + panic("uniswapx: negative planning fill count") + } + s.requestQuoteRefresh() +} diff --git a/internal/solvers/uniswapx/state_test.go b/internal/solvers/uniswapx/state_test.go new file mode 100644 index 00000000..4301ff04 --- /dev/null +++ b/internal/solvers/uniswapx/state_test.go @@ -0,0 +1,263 @@ +package uniswapx + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" +) + +type stateTestOrderPoller struct { + terminals map[common.Hash]orderTerminal + err error +} + +func (p *stateTestOrderPoller) openOrders( + context.Context, + int64, + *common.Address, +) ([]orderEntry, error) { + return nil, nil +} + +func (p *stateTestOrderPoller) ordersByHash( + _ context.Context, + _ int64, + _ []common.Hash, +) (map[common.Hash]orderTerminal, error) { + return p.terminals, p.err +} + +type stateTestChainReader struct { + chainReader + + transactionTimes map[common.Hash]time.Time + err error +} + +func (r *stateTestChainReader) transactionBlockTime( + _ context.Context, + hash common.Hash, +) (time.Time, error) { + return r.transactionTimes[hash], r.err +} + +func TestLocalBreakerInvalidatesQuotes(t *testing.T) { + now := time.Now() + solver := &Solver{ + cfg: &Config{Breaker: BreakerConfig{MaxFailures: 2, Window: time.Minute}}, + log: logr.Discard(), + } + solver.quoteState.Store("eState{expiresAt: now.Add(time.Minute)}) + solver.recordFillFailure(now) + if solver.localBlockUntil.Load() != 0 || solver.quoteState.Load() == nil { + t.Fatal("breaker opened before threshold") + } + solver.recordFillFailure(now.Add(time.Second)) + if solver.localBlockUntil.Load() <= now.Unix() || solver.quoteState.Load() != nil { + t.Fatal("breaker did not open and invalidate quotes") + } +} + +func TestMissedExclusiveObligationOpensIndependentBreaker(t *testing.T) { + now := time.Unix(1_000, 0) + hash := common.HexToHash("0x1234") + solver := &Solver{ + cfg: &Config{Breaker: BreakerConfig{Window: 15 * time.Minute}}, log: logr.Discard(), + orders: &stateTestOrderPoller{terminals: map[common.Hash]orderTerminal{ + hash: {Status: orderStatusExpired}, + }}, + } + solver.quoteState.Store("eState{expiresAt: now.Add(time.Minute)}) + solver.trackExclusive(&resolvedOrder{ + Hash: hash, Source: orderSourceExclusiveV2, ExclusiveUntil: uint64(now.Add(time.Second).Unix()), + }, now) + if err := solver.sweepExclusive(t.Context(), now.Add(2*time.Second)); err != nil { + t.Fatal(err) + } + + if solver.exclusiveBlockUntil.Load() != now.Add(15*time.Minute+2*time.Second).Unix() { + t.Fatalf("exclusive block until = %d", solver.exclusiveBlockUntil.Load()) + } + if solver.quoteState.Load() != nil { + t.Fatal("missed exclusive obligation did not invalidate quotes") + } + if _, pending := solver.exclusiveUntil[hash]; pending { + t.Fatal("missed obligation remained pending") + } + if _, terminal := solver.exclusiveTerminal[hash]; !terminal { + t.Fatal("missed obligation was not marked terminal") + } + + // A later unrelated fill may reset the ordinary failure breaker, but never the fade breaker. + solver.recordFillSuccess() + if solver.exclusiveBlockUntil.Load() == 0 { + t.Fatal("ordinary fill success cleared the exclusive fade breaker") + } +} + +func TestExclusiveSettlementAtDeadlineDoesNotTripBreaker(t *testing.T) { + now := time.Unix(1_000, 0) + deadline := now.Add(time.Second) + hash := common.HexToHash("0x1234") + txHash := common.HexToHash("0xabcd") + solver := &Solver{ + cfg: &Config{Breaker: BreakerConfig{Window: time.Minute}}, + log: logr.Discard(), + orders: &stateTestOrderPoller{terminals: map[common.Hash]orderTerminal{ + hash: {Status: orderStatusFilled, TxHash: txHash}, + }}, + reader: &stateTestChainReader{transactionTimes: map[common.Hash]time.Time{ + txHash: deadline, + }}, + } + solver.trackExclusive(&resolvedOrder{ + Hash: hash, Source: orderSourceExclusiveV2, ExclusiveUntil: uint64(deadline.Unix()), + }, now) + + if err := solver.sweepExclusive(t.Context(), deadline.Add(time.Second)); err != nil { + t.Fatal(err) + } + + if solver.exclusiveBlockUntil.Load() != 0 { + t.Fatal("in-time settlement tripped the exclusive breaker") + } + if _, pending := solver.exclusiveUntil[hash]; pending { + t.Fatal("settled obligation remained pending") + } +} + +func TestAnyLateFillTripsExclusiveBreaker(t *testing.T) { + now := time.Unix(1_000, 0) + deadline := now.Add(time.Second) + hash := common.HexToHash("0x1234") + txHash := common.HexToHash("0xabcd") + solver := &Solver{ + cfg: &Config{Breaker: BreakerConfig{Window: time.Minute}}, + log: logr.Discard(), + orders: &stateTestOrderPoller{terminals: map[common.Hash]orderTerminal{ + hash: {Status: orderStatusFilled, TxHash: txHash}, + }}, + reader: &stateTestChainReader{transactionTimes: map[common.Hash]time.Time{ + txHash: deadline.Add(time.Second), + }}, + } + solver.trackExclusive(&resolvedOrder{ + Hash: hash, Source: orderSourceExclusiveV2, ExclusiveUntil: uint64(deadline.Unix()), + }, now) + + if err := solver.sweepExclusive(t.Context(), deadline.Add(2*time.Second)); err != nil { + t.Fatal(err) + } + + if solver.exclusiveBlockUntil.Load() == 0 { + t.Fatal("late fill did not trip the exclusive breaker") + } +} + +func TestKnownUnfilledTerminalStatusesTripExclusiveBreaker(t *testing.T) { + now := time.Unix(1_000, 0) + for _, status := range []string{ + orderStatusExpired, + orderStatusError, + orderStatusCancelled, + orderStatusInsufficientFunds, + } { + t.Run(status, func(t *testing.T) { + hash := common.HexToHash("0x1234") + solver := &Solver{ + cfg: &Config{Breaker: BreakerConfig{Window: time.Minute}}, + log: logr.Discard(), + orders: &stateTestOrderPoller{terminals: map[common.Hash]orderTerminal{ + hash: {Status: status}, + }}, + } + solver.trackExclusive(&resolvedOrder{ + Hash: hash, Source: orderSourceExclusiveV2, ExclusiveUntil: uint64(now.Add(time.Second).Unix()), + }, now) + + if err := solver.sweepExclusive(t.Context(), now.Add(2*time.Second)); err != nil { + t.Fatal(err) + } + if solver.exclusiveBlockUntil.Load() == 0 { + t.Fatalf("terminal status %q did not trip the exclusive breaker", status) + } + }) + } +} + +func TestUnresolvedExclusiveStateKeepsObligationPending(t *testing.T) { + now := time.Unix(1_000, 0) + deadline := now.Add(time.Second) + hash := common.HexToHash("0x1234") + for _, tc := range []struct { + name string + terminals map[common.Hash]orderTerminal + }{ + {name: "missing", terminals: map[common.Hash]orderTerminal{}}, + {name: "still open", terminals: map[common.Hash]orderTerminal{ + hash: {Status: orderStatusOpen}, + }}, + } { + t.Run(tc.name, func(t *testing.T) { + solver := &Solver{ + cfg: &Config{Breaker: BreakerConfig{Window: time.Minute}}, + log: logr.Discard(), + orders: &stateTestOrderPoller{terminals: tc.terminals}, + } + solver.trackExclusive(&resolvedOrder{ + Hash: hash, Source: orderSourceExclusiveV2, ExclusiveUntil: uint64(deadline.Unix()), + }, now) + + if err := solver.sweepExclusive(t.Context(), deadline.Add(time.Second)); err == nil { + t.Fatal("unresolved terminal result was accepted") + } + if solver.exclusiveBlockUntil.Load() != 0 { + t.Fatal("unresolved terminal result tripped the exclusive breaker") + } + if _, pending := solver.exclusiveUntil[hash]; !pending { + t.Fatal("unresolved obligation was removed instead of retried") + } + }) + } +} + +func TestClearPendingReservationsInvalidatesQuoteState(t *testing.T) { + hash := common.HexToHash("0x1234") + solver := &Solver{} + if !solver.capacity.Set(hash.Hex(), liquidlane.CapacityReservations{"capacity-1": big.NewInt(1)}) { + t.Fatal("set reservation") + } + solver.quoteState.Store("eState{expiresAt: time.Now().Add(time.Minute)}) + + solver.clearPendingReservations(hash) + + if solver.quoteState.Load() != nil { + t.Fatal("released capacity remained quotable through the old snapshot") + } + if solver.capacity.Len() != 0 { + t.Fatal("reservation was not released") + } +} + +func TestClaimTracksInflightAndBackoff(t *testing.T) { + now := time.Now() + hash := common.HexToHash("0x1") + solver := &Solver{ + cfg: &Config{OrderServer: OrderServerConfig{PollInterval: time.Second}}, + filled: make(map[common.Hash]time.Time), retryAt: make(map[common.Hash]time.Time), + inFlight: make(map[common.Hash]bool), attempts: make(map[common.Hash]int), + } + if !solver.claim(hash, now) || solver.claim(hash, now) { + t.Fatal("claim did not enforce in-flight deduplication") + } + solver.retry(hash, now, true) + if solver.claim(hash, now.Add(500*time.Millisecond)) || !solver.claim(hash, now.Add(time.Second)) { + t.Fatal("retry backoff was not enforced") + } +} diff --git a/internal/solvers/uniswapx/strategies/default/fill.go b/internal/solvers/uniswapx/strategies/default/fill.go new file mode 100644 index 00000000..685a7822 --- /dev/null +++ b/internal/solvers/uniswapx/strategies/default/fill.go @@ -0,0 +1,82 @@ +package defaultstrategy + +import ( + "context" + + "github.com/go-errors/errors" + + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" + liquidgreedy "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies/greedy" + "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/types" +) + +func (s *Strategy) DecideFill(_ context.Context, input types.FillInput) (*types.FillPlan, error) { + if input.AmountIn == nil || input.AmountIn.Sign() <= 0 { + return nil, errors.New("amountIn: must be positive") + } + if input.OutputAmount == nil || input.OutputAmount.Sign() <= 0 { + return nil, errors.New("outputAmount: must be positive") + } + if input.AmountIn.Cmp(s.minAmount) < 0 { + input.Trace.Decline( + "fill", "amount-below-minimum", + "amountIn", input.AmountIn.String(), + "minAmount", s.minAmount.String(), + ) + return nil, nil + } + validAfter := input.ChainTime.Add(s.executionBuffer) + if input.Deadline != 0 && int64(input.Deadline) <= validAfter.Unix() { + input.Trace.Decline( + "fill", "deadline-too-close", + "deadline", input.Deadline, + "validAfter", validAfter, + ) + return nil, nil + } + maxRoutes := types.MaxRoutes + if input.RequireSingleRoute { + maxRoutes = 1 + } + gasPricing, err := liquidstrategies.NewGasPricing( + input.MaxFeePerGas, + input.TokenOut, + input.GasPrices, + input.GasSnapshot, + s.cfg.InventoryReserveBps, + types.LiquidLaneGasEnvelope(), + ) + if err != nil { + return nil, err + } + allocation, err := liquidgreedy.SolveFill(liquidgreedy.FillTask{ + TokenIn: input.TokenIn, TokenOut: input.TokenOut, AmountIn: input.AmountIn, + Quotes: input.Quotes, Reservations: input.Reservations, ValidAfter: validAfter, + MaxRoutes: maxRoutes, PriceBufferBps: s.cfg.PriceBufferBps, + InventoryReserveBps: s.cfg.InventoryReserveBps, + GasPricing: &gasPricing, + Trace: input.Trace, + }) + if err != nil || allocation == nil { + return nil, err + } + maxAmountOut := allocation.MaxAmountOut() + if input.OutputAmount.Cmp(maxAmountOut) > 0 { + input.Trace.Decline( + "fill", "required-output-exceeds-capacity", + "requiredAmountOut", input.OutputAmount.String(), + "maxAmountOut", maxAmountOut.String(), + ) + return nil, nil + } + routes := allocation.Finalize(input.OutputAmount) + if len(routes) == 0 { + input.Trace.Decline( + "fill", "finalization-failed", + "requiredAmountOut", input.OutputAmount.String(), + "maxAmountOut", maxAmountOut.String(), + ) + return nil, nil + } + return &types.FillPlan{Routes: routes}, nil +} diff --git a/internal/solvers/uniswapx/strategies/default/quote.go b/internal/solvers/uniswapx/strategies/default/quote.go new file mode 100644 index 00000000..8d0b7966 --- /dev/null +++ b/internal/solvers/uniswapx/strategies/default/quote.go @@ -0,0 +1,88 @@ +package defaultstrategy + +import ( + "context" + + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" + liquidgreedy "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies/greedy" + "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/types" +) + +func (s *Strategy) DecideQuote(_ context.Context, input types.QuoteInput) (*types.Quote, error) { + if !input.QuoteExpiresAt.After(input.ChainTime) { + return nil, errors.New("quoteExpiresAt must be after chainTime") + } + if (input.AmountIn == nil) == (input.AmountOut == nil) { + return nil, errors.New("exactly one quote amount must be set") + } + requestedAmount := input.AmountIn + if requestedAmount == nil { + requestedAmount = input.AmountOut + } + if requestedAmount.Sign() <= 0 { + return nil, errors.New("quote amount must be positive") + } + + validAfter := input.QuoteExpiresAt.Add(s.executionBuffer) + liveInventory := liquidgreedy.FilterLiveInventory(input.Inventory, validAfter) + inventory := liquidgreedy.AllocateInventoryCapacity( + liveInventory, + input.Reservations, + s.cfg.InventoryReserveBps, + ) + candidates := make([]liquidlane.QuoteCandidate, 0, len(inventory)) + for _, item := range inventory { + if item.TokenIn != input.TokenIn || item.TokenOut != input.TokenOut { + continue + } + candidate := liquidgreedy.NewQuoteCandidate( + item, + liquidgreedy.QuoteCapacity(item, s.cfg.PriceBufferBps), + ) + if candidate != nil { + candidates = append(candidates, *candidate) + } + } + if len(candidates) == 0 { + input.Trace.Decline( + "quote", "no-matching-routes", + "tokenIn", input.TokenIn.Hex(), + "tokenOut", input.TokenOut.Hex(), + "inventory", len(input.Inventory), + "liveInventory", len(liveInventory), + "allocatedInventory", len(inventory), + "reservations", len(input.Reservations), + ) + return nil, nil + } + + pricing, err := liquidstrategies.NewGasPricing( + input.MaxFeePerGas, + input.TokenOut, + input.GasPrices, + input.GasSnapshot, + s.cfg.InventoryReserveBps, + types.LiquidLaneGasEnvelope(), + ) + if err != nil { + return nil, err + } + maxRoutes := types.MaxRoutes + if input.RequireSingleRoute { + maxRoutes = 1 + } + solution, err := liquidgreedy.SolveQuote(liquidgreedy.QuoteTask{ + ExactInput: input.AmountIn, ExactOutput: input.AmountOut, + Candidates: candidates, MaxRoutes: maxRoutes, MinInput: s.minAmount, + OutputBufferBps: 2 * s.cfg.PriceBufferBps, + GasPricing: &pricing, + Trace: input.Trace, + }) + if err != nil || solution == nil { + return nil, err + } + return &types.Quote{AmountIn: solution.AmountIn, AmountOut: solution.AmountOut}, nil +} diff --git a/internal/solvers/uniswapx/strategies/default/strategy.go b/internal/solvers/uniswapx/strategies/default/strategy.go new file mode 100644 index 00000000..122ca405 --- /dev/null +++ b/internal/solvers/uniswapx/strategies/default/strategy.go @@ -0,0 +1,89 @@ +package defaultstrategy + +import ( + "math/big" + "time" + + "github.com/go-errors/errors" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/parse" + "github.com/symbioticfi/vault-solver/internal/solver" + "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies" + "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/types" +) + +const Name = "default" + +const ( + bpsDenominator = 10_000 + defaultExecutionBuffer = 12 * time.Second +) + +var defaultMinAmount = big.NewInt(1) + +type Config struct { + PriceBufferBps int `yaml:"priceBufferBps"` + MinAmount string `yaml:"minAmount"` + InventoryReserveBps int `yaml:"inventoryReserveBps"` + ExecutionDeadlineBuffer string `yaml:"executionDeadlineBuffer"` +} + +type Strategy struct { + cfg Config + + minAmount *big.Int + executionBuffer time.Duration +} + +//nolint:gochecknoinits // solver-local strategy self-registration mirrors solver registration. +func init() { + strategies.Register(Name, NewFromConfig) +} + +func NewFromConfig(raw yaml.Node) (types.Strategy, error) { + var cfg Config + if err := decodeConfig(raw, &cfg); err != nil { + return nil, err + } + return New(cfg) +} + +func New(cfg Config) (*Strategy, error) { + if cfg.PriceBufferBps < 0 || cfg.PriceBufferBps >= bpsDenominator { + return nil, errors.Errorf("priceBufferBps: must be in [0,%d), got %d", bpsDenominator, cfg.PriceBufferBps) + } + if 2*cfg.PriceBufferBps >= bpsDenominator { + return nil, errors.Errorf("2 * priceBufferBps: must be < %d", bpsDenominator) + } + if cfg.InventoryReserveBps < 0 || cfg.InventoryReserveBps >= bpsDenominator { + return nil, errors.Errorf("inventoryReserveBps: must be in [0,%d), got %d", bpsDenominator, cfg.InventoryReserveBps) + } + minAmount := new(big.Int).Set(defaultMinAmount) + if cfg.MinAmount != "" { + var err error + minAmount, err = parse.Big(cfg.MinAmount, "minAmount") + if err != nil { + return nil, err + } + if minAmount.Sign() <= 0 { + return nil, errors.New("minAmount: must be positive") + } + } + executionBuffer, err := parse.Duration( + cfg.ExecutionDeadlineBuffer, defaultExecutionBuffer, "executionDeadlineBuffer", + ) + if err != nil { + return nil, err + } + return &Strategy{ + cfg: cfg, minAmount: minAmount, executionBuffer: executionBuffer, + }, nil +} + +func decodeConfig(node yaml.Node, out any) error { + if node.Kind == 0 { + node = yaml.Node{Kind: yaml.MappingNode} + } + return solver.DecodeStrict(node, out) +} diff --git a/internal/solvers/uniswapx/strategies/default/strategy_test.go b/internal/solvers/uniswapx/strategies/default/strategy_test.go new file mode 100644 index 00000000..0893670c --- /dev/null +++ b/internal/solvers/uniswapx/strategies/default/strategy_test.go @@ -0,0 +1,336 @@ +package defaultstrategy + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" + "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/types" +) + +var quoteRateScale = new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil) + +func TestDefaultExecutionBufferIsOneBlock(t *testing.T) { + strategy, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + if strategy.executionBuffer != 12*time.Second { + t.Fatalf("execution buffer = %s", strategy.executionBuffer) + } +} + +func TestDecideQuoteRequiresOneRequestedAmountAndFreshState(t *testing.T) { + strategy, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + now := time.Unix(1_800_000_000, 0) + for _, input := range []types.QuoteInput{ + {ChainTime: now, QuoteExpiresAt: now}, + {ChainTime: now, QuoteExpiresAt: now.Add(time.Minute)}, + {ChainTime: now, QuoteExpiresAt: now.Add(time.Minute), AmountIn: big.NewInt(1), AmountOut: big.NewInt(1)}, + } { + if _, quoteErr := strategy.DecideQuote(context.Background(), input); quoteErr == nil { + t.Fatalf("input %+v: error = nil", input) + } + } +} + +func TestDecideQuoteReturnsOneExactInputAmountWithBuffer(t *testing.T) { + strategy, err := New(Config{PriceBufferBps: 100}) + if err != nil { + t.Fatal(err) + } + input := directQuoteInput(1_000, 1_000, quoteRateScale) + quote, err := strategy.DecideQuote(context.Background(), input) + if err != nil { + t.Fatal(err) + } + if quote == nil || quote.AmountIn.String() != "1000" || quote.AmountOut.String() != "980" { + t.Fatalf("quote = %+v", quote) + } +} + +func TestDecideQuoteSubtractsCompleteFillGas(t *testing.T) { + strategy, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + input := directQuoteInput(1_000_000, 2_000_000, new(big.Int).Mul(big.NewInt(2), quoteRateScale)) + route := input.Inventory[0].Route + input.MaxFeePerGas = big.NewInt(1) + input.GasPrices = testGasPrices(route.TokenOut, 1_000_000_000_000_000_000) + input.GasSnapshot = acquireGasSnapshot(route, 2_000_000) + + quote, err := strategy.DecideQuote(context.Background(), input) + if err != nil { + t.Fatal(err) + } + if quote == nil || quote.AmountOut.String() != "1450000" { + t.Fatalf("quote = %+v, want output 1450000", quote) + } +} + +func TestDecideQuoteExactOutputFindsInputIncludingGas(t *testing.T) { + strategy, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + input := directQuoteInput(1, 2_000_000, new(big.Int).Mul(big.NewInt(2), quoteRateScale)) + input.AmountIn = nil + input.AmountOut = big.NewInt(900_000) + route := input.Inventory[0].Route + input.MaxFeePerGas = big.NewInt(1) + input.GasPrices = testGasPrices(route.TokenOut, 1_000_000_000_000_000_000) + input.GasSnapshot = acquireGasSnapshot(route, 2_000_000) + + quote, err := strategy.DecideQuote(context.Background(), input) + if err != nil { + t.Fatal(err) + } + if quote == nil || quote.AmountIn.String() != "725000" || quote.AmountOut.String() != "900000" { + t.Fatalf("quote = %+v", quote) + } +} + +func TestDecideQuoteAggregatesRoutesOnlyWhenAllowed(t *testing.T) { + strategy, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + input := directQuoteInput(900, 500, quoteRateScale) + second := input.Inventory[0] + second.ID = "route-2" + second.CapacityID = "capacity-2" + second.Adapter = common.HexToAddress("0x00000000000000000000000000000000000000a2") + input.Inventory = append(input.Inventory, second) + + quote, err := strategy.DecideQuote(context.Background(), input) + if err != nil || quote == nil || quote.AmountOut.String() != "900" { + t.Fatalf("multi-route quote = %+v, err %v", quote, err) + } + input.RequireSingleRoute = true + quote, err = strategy.DecideQuote(context.Background(), input) + if err != nil || quote != nil { + t.Fatalf("single-route quote = %+v, err %v", quote, err) + } +} + +func TestDecideQuoteUsesCurrentCapacityAndReservations(t *testing.T) { + strategy, err := New(Config{InventoryReserveBps: 1_000}) + if err != nil { + t.Fatal(err) + } + input := directQuoteInput(50, 100, quoteRateScale) + input.Reservations = liquidlane.CapacityReservations{"capacity-1": big.NewInt(50)} + quote, err := strategy.DecideQuote(context.Background(), input) + if err != nil || quote != nil { + t.Fatalf("reserved quote = %+v, err %v", quote, err) + } + input.Reservations = nil + input.AmountIn = big.NewInt(90) + quote, err = strategy.DecideQuote(context.Background(), input) + if err != nil || quote == nil || quote.AmountOut.String() != "90" { + t.Fatalf("reserve boundary quote = %+v, err %v", quote, err) + } + input.AmountIn = big.NewInt(91) + quote, err = strategy.DecideQuote(context.Background(), input) + if err != nil || quote != nil { + t.Fatalf("above reserve quote = %+v, err %v", quote, err) + } +} + +func TestDecideQuoteChoosesFreshPrivateAlternative(t *testing.T) { + strategy, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + input := directQuoteInput(100, 200, quoteRateScale) + discountID := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + private := input.Inventory[0] + private.DiscountID = &discountID + private.MaxRate = new(big.Int).Mul(big.NewInt(2), quoteRateScale) + private.ValidUntil = input.QuoteExpiresAt.Add(time.Minute) + input.Inventory = append(input.Inventory, private) + + quote, err := strategy.DecideQuote(context.Background(), input) + if err != nil || quote == nil || quote.AmountOut.String() != "200" { + t.Fatalf("private quote = %+v, err %v", quote, err) + } + input.Inventory[1].ValidUntil = input.QuoteExpiresAt + quote, err = strategy.DecideQuote(context.Background(), input) + if err != nil || quote == nil || quote.AmountOut.String() != "100" { + t.Fatalf("expired private fallback = %+v, err %v", quote, err) + } +} + +func TestDecideFillBuildsCurrentMultiRoutePlan(t *testing.T) { + strategy, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + tokenIn, tokenOut := testPair() + quotes := []liquidlane.FillQuote{ + directFillQuote(testRoute("route-1", "capacity-1", 1, tokenIn, tokenOut), 1_000, 500, 1_000), + directFillQuote(testRoute("route-2", "capacity-2", 2, tokenIn, tokenOut), 1_000, 500, 1_000), + } + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(1_000), OutputAmount: big.NewInt(900), + ChainTime: time.Unix(1_800_000_000, 0), MaxFeePerGas: new(big.Int), Quotes: quotes, + }) + if err != nil || plan == nil || len(plan.Routes) != 2 { + t.Fatalf("plan = %+v, err %v", plan, err) + } + totalIn := new(big.Int) + totalMinOut := new(big.Int) + for _, route := range plan.Routes { + totalIn.Add(totalIn, route.AmountIn) + totalMinOut.Add(totalMinOut, route.MinAmountOut) + } + if totalIn.String() != "1000" || totalMinOut.String() != "900" { + t.Fatalf("totals = %s/%s", totalIn, totalMinOut) + } +} + +func TestDecideFillDoesNotDoubleSpendSharedCapacity(t *testing.T) { + strategy, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + tokenIn, tokenOut := testPair() + quotes := []liquidlane.FillQuote{ + directFillQuote(testRoute("route-1", "shared", 1, tokenIn, tokenOut), 1_000, 600, 1_000), + directFillQuote(testRoute("route-2", "shared", 2, tokenIn, tokenOut), 1_000, 600, 1_000), + } + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(1_000), OutputAmount: big.NewInt(900), + MaxFeePerGas: new(big.Int), Quotes: quotes, + }) + if err != nil || plan != nil { + t.Fatalf("plan = %+v, err %v", plan, err) + } +} + +func TestDecideFillSelectsBestCurrentRoute(t *testing.T) { + strategy, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + tokenIn, tokenOut := testPair() + first := testRoute("route-1", "capacity-1", 1, tokenIn, tokenOut) + best := testRoute("route-2", "capacity-2", 2, tokenIn, tokenOut) + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(1_000), OutputAmount: big.NewInt(900), + MaxFeePerGas: new(big.Int), Quotes: []liquidlane.FillQuote{ + directFillQuote(first, 1_000, 2_000, 1_000), + directFillQuote(best, 1_000, 2_000, 1_100), + }, + }) + if err != nil || plan == nil || len(plan.Routes) != 1 || plan.Routes[0].Adapter != best.Adapter { + t.Fatalf("plan = %+v, err %v", plan, err) + } +} + +func TestDecideFillHonorsPendingReservationAndDeadline(t *testing.T) { + strategy, err := New(Config{ExecutionDeadlineBuffer: "30s"}) + if err != nil { + t.Fatal(err) + } + tokenIn, tokenOut := testPair() + now := time.Unix(1_800_000_000, 0) + quote := directFillQuote(testRoute("route-1", "capacity-1", 1, tokenIn, tokenOut), 100, 100, 100) + base := types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(100), OutputAmount: big.NewInt(90), + ChainTime: now, MaxFeePerGas: new(big.Int), Quotes: []liquidlane.FillQuote{quote}, + Reservations: liquidlane.CapacityReservations{"capacity-1": big.NewInt(60)}, + } + plan, err := strategy.DecideFill(context.Background(), base) + if err != nil || plan != nil { + t.Fatalf("reserved plan = %+v, err %v", plan, err) + } + base.Reservations = nil + base.Deadline = uint32(now.Add(30 * time.Second).Unix()) + plan, err = strategy.DecideFill(context.Background(), base) + if err != nil || plan != nil { + t.Fatalf("near-deadline plan = %+v, err %v", plan, err) + } +} + +func TestDecideFillCommitsSelectedPrivateDiscount(t *testing.T) { + strategy, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + tokenIn, tokenOut := testPair() + route := testRoute("route-1", "capacity-1", 1, tokenIn, tokenOut) + discountID := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + direct := directFillQuote(route, 1_000, 1_000, 900) + private := directFillQuote(route, 1_000, 1_000, 950) + private.DiscountID = &discountID + private.MinDiscount = big.NewInt(100_000) + plan, err := strategy.DecideFill(context.Background(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(1_000), OutputAmount: big.NewInt(850), + MaxFeePerGas: new(big.Int), Quotes: []liquidlane.FillQuote{direct, private}, + }) + if err != nil || plan == nil || plan.Routes[0].DiscountID == nil || *plan.Routes[0].DiscountID != discountID { + t.Fatalf("plan = %+v, err %v", plan, err) + } +} + +func directQuoteInput(amountIn, maxAssets int64, rate *big.Int) types.QuoteInput { + tokenIn, tokenOut := testPair() + now := time.Unix(1_800_000_000, 0) + return types.QuoteInput{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(amountIn), + Inventory: []liquidlane.Inventory{liquidlane.DirectInventory( + testRoute("route-1", "capacity-1", 1, tokenIn, tokenOut), big.NewInt(maxAssets), rate, + )}, + MaxFeePerGas: new(big.Int), ChainTime: now, QuoteExpiresAt: now.Add(time.Minute), + } +} + +func testPair() (tokenIn, tokenOut common.Address) { + return common.HexToAddress("0x1111111111111111111111111111111111111111"), + common.HexToAddress("0x2222222222222222222222222222222222222222") +} + +func testRoute( + id liquidlane.RouteID, + capacityID liquidlane.CapacityID, + adapterByte byte, + tokenIn, tokenOut common.Address, +) liquidlane.Route { + return liquidlane.Route{ + ID: id, CapacityID: capacityID, + Adapter: common.BytesToAddress([]byte{adapterByte}), Vault: common.BytesToAddress([]byte{adapterByte + 10}), + TokenIn: tokenIn, TokenOut: tokenOut, TokenInDecimals: 18, TokenOutDecimals: 18, + } +} + +func directFillQuote(route liquidlane.Route, amountIn, maxAssets, maxAmountOut int64) liquidlane.FillQuote { + return liquidlane.FillQuote{ + Inventory: liquidlane.Inventory{Route: route, MaxAssets: big.NewInt(maxAssets)}, + AmountIn: big.NewInt(amountIn), GrossAmountOut: big.NewInt(maxAmountOut), MaxAmountOut: big.NewInt(maxAmountOut), + } +} + +func acquireGasSnapshot(route liquidlane.Route, amount int64) *liquidlanegas.Snapshot { + return &liquidlanegas.Snapshot{ + Adapters: map[common.Address]*liquidlanegas.AdapterState{ + route.Adapter: {Vault: route.Vault, Acquire: map[common.Address]*big.Int{route.TokenIn: big.NewInt(amount)}}, + }, + Vaults: map[common.Address]*liquidlanegas.VaultState{ + route.Vault: {FreeAssets: new(big.Int), Withdrawable: new(big.Int)}, + }, + } +} + +func testGasPrices(token common.Address, amount int64) *liquidlanegas.PriceSnapshot { + return liquidlanegas.NewPriceSnapshot(map[common.Address]*big.Int{token: big.NewInt(amount)}) +} diff --git a/internal/solvers/uniswapx/strategies/registry.go b/internal/solvers/uniswapx/strategies/registry.go new file mode 100644 index 00000000..e7546571 --- /dev/null +++ b/internal/solvers/uniswapx/strategies/registry.go @@ -0,0 +1,54 @@ +package strategies + +import ( + "sort" + "sync" + + "github.com/go-errors/errors" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/types" +) + +type Factory func(raw yaml.Node) (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("uniswapx strategy: Register called with empty name") + } + if f == nil { + panic("uniswapx strategy: Register called with nil factory for " + name) + } + if _, dup := registry[name]; dup { + panic("uniswapx strategy: duplicate registration for " + name) + } + registry[name] = f +} + +func New(name string, raw yaml.Node) (types.Strategy, error) { + mu.RLock() + f, ok := registry[name] + mu.RUnlock() + if !ok { + return nil, errors.Errorf("unknown UniswapX strategy %q (registered: %v)", name, Registered()) + } + return f(raw) +} + +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/uniswapx/strategies/types/types.go b/internal/solvers/uniswapx/strategies/types/types.go new file mode 100644 index 00000000..c8fd7e80 --- /dev/null +++ b/internal/solvers/uniswapx/strategies/types/types.go @@ -0,0 +1,84 @@ +// Package types defines the UniswapX solver strategy contract. +package types + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + liquidlanegas "github.com/symbioticfi/vault-solver/internal/liquidlane/gas" + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" +) + +const ( + // MaxRoutes bounds the physical LiquidLane routes used by one quote or fill. + MaxRoutes = 3 + settlementGasUnits = 250_000 + privateRouteGasUnits = 75_000 +) + +// LiquidLaneGasEnvelope returns the fixed UniswapX executor overhead around route execution. +func LiquidLaneGasEnvelope() liquidstrategies.GasEnvelope { + return liquidstrategies.GasEnvelope{ + SettlementUnits: settlementGasUnits, PrivateRouteUnits: privateRouteGasUnits, + } +} + +type Strategy interface { + DecideQuote(ctx context.Context, input QuoteInput) (*Quote, error) + DecideFill(ctx context.Context, input FillInput) (*FillPlan, error) +} + +type QuoteInput struct { + RequestID string `json:"requestId"` + QuoteID string `json:"quoteId"` + + TokenIn common.Address `json:"tokenIn"` + TokenOut common.Address `json:"tokenOut"` + AmountIn *big.Int `json:"amountIn,omitempty"` + AmountOut *big.Int `json:"amountOut,omitempty"` + RequireSingleRoute bool `json:"requireSingleRoute"` + + Inventory []liquidlane.Inventory `json:"inventory"` + Reservations liquidlane.CapacityReservations `json:"reservations"` + GasSnapshot *liquidlanegas.Snapshot `json:"gasSnapshot"` + GasPrices *liquidlanegas.PriceSnapshot `json:"gasPrices"` + MaxFeePerGas *big.Int `json:"maxFeePerGas"` + ChainTime time.Time `json:"chainTime"` + QuoteExpiresAt time.Time `json:"quoteExpiresAt"` + Trace liquidstrategies.DecisionTrace `json:"-"` +} + +type Quote struct { + AmountIn *big.Int `json:"amountIn"` + AmountOut *big.Int `json:"amountOut"` +} + +type FillInput struct { + OrderID string `json:"orderId"` + QuoteID string `json:"quoteId"` + + TokenIn common.Address `json:"tokenIn"` + TokenOut common.Address `json:"tokenOut"` + AmountIn *big.Int `json:"amountIn"` + OutputAmount *big.Int `json:"outputAmount"` + Deadline uint32 `json:"deadline"` + RequireSingleRoute bool `json:"requireSingleRoute"` + + Quotes []liquidlane.FillQuote `json:"quotes"` + Reservations liquidlane.CapacityReservations `json:"reservations"` + GasSnapshot *liquidlanegas.Snapshot `json:"gasSnapshot"` + GasPrices *liquidlanegas.PriceSnapshot `json:"gasPrices"` + MaxFeePerGas *big.Int `json:"maxFeePerGas"` + ChainTime time.Time `json:"chainTime"` + Trace liquidstrategies.DecisionTrace `json:"-"` +} + +type FillPlan struct { + Routes []FillRoute `json:"routes"` +} + +type FillRoute = liquidstrategies.FillRoute diff --git a/internal/solvers/uniswapx/strategies/webhook/strategy.go b/internal/solvers/uniswapx/strategies/webhook/strategy.go new file mode 100644 index 00000000..e1daaf5d --- /dev/null +++ b/internal/solvers/uniswapx/strategies/webhook/strategy.go @@ -0,0 +1,84 @@ +package webhookstrategy + +import ( + "context" + "net/http" + + "github.com/go-errors/errors" + "gopkg.in/yaml.v3" + + "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies" + "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/types" + "github.com/symbioticfi/vault-solver/internal/webhook" +) + +const ( + Name = "webhook" + decideQuoteRoute = "/decide-quote" + decideFillRoute = "/decide-fill" +) + +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) (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.Quote, error) { + var out *types.Quote + if err := s.client.DoJSON(ctx, http.MethodPost, decideQuoteRoute, input, &out); err != nil { + return nil, err + } + if out == nil { + return nil, nil + } + if err := validateQuote(input, out); err != nil { + return nil, err + } + return out, nil +} + +func (s *Strategy) DecideFill(ctx context.Context, input types.FillInput) (*types.FillPlan, error) { + var out *types.FillPlan + if err := s.client.DoJSON(ctx, http.MethodPost, decideFillRoute, input, &out); err != nil { + return nil, err + } + if out == nil { + return nil, nil + } + return out, nil +} + +func validateQuote(input types.QuoteInput, quote *types.Quote) error { + if quote.AmountIn == nil || quote.AmountIn.Sign() <= 0 || quote.AmountOut == nil || quote.AmountOut.Sign() <= 0 { + return errors.New("webhook quote amounts must be positive") + } + if input.AmountIn != nil && quote.AmountIn.Cmp(input.AmountIn) != 0 { + return errors.New("webhook quote changed exact-input amount") + } + if input.AmountOut != nil && quote.AmountOut.Cmp(input.AmountOut) != 0 { + return errors.New("webhook quote changed exact-output amount") + } + return nil +} + +var _ types.Strategy = (*Strategy)(nil) diff --git a/internal/solvers/uniswapx/strategies/webhook/strategy_test.go b/internal/solvers/uniswapx/strategies/webhook/strategy_test.go new file mode 100644 index 00000000..ee5fdf87 --- /dev/null +++ b/internal/solvers/uniswapx/strategies/webhook/strategy_test.go @@ -0,0 +1,85 @@ +package webhookstrategy + +import ( + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/symbioticfi/vault-solver/internal/liquidlane" + "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/types" + "github.com/symbioticfi/vault-solver/internal/webhook" +) + +func TestWebhookStrategyDelegatesOneQuoteAndCurrentFill(t *testing.T) { + tokenIn := common.HexToAddress("0x2222222222222222222222222222222222222222") + tokenOut := common.HexToAddress("0x3333333333333333333333333333333333333333") + adapter := common.HexToAddress("0x4444444444444444444444444444444444444444") + route := liquidlane.Route{ + ID: "route-1", CapacityID: "capacity-1", Adapter: adapter, + TokenIn: tokenIn, TokenOut: tokenOut, TokenInDecimals: 6, TokenOutDecimals: 6, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case decideQuoteRoute: + _ = json.NewEncoder(w).Encode(types.Quote{AmountIn: big.NewInt(100), AmountOut: big.NewInt(90)}) + case decideFillRoute: + _ = json.NewEncoder(w).Encode(types.FillPlan{Routes: []types.FillRoute{{ + RouteID: route.ID, AmountIn: big.NewInt(100), ExpectedAmountOut: big.NewInt(100), + MinAmountOut: big.NewInt(90), ReservedAmountOut: big.NewInt(100), + }}}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + strategy := newWebhookTestStrategy(t, server.URL) + + quote, err := strategy.DecideQuote(t.Context(), types.QuoteInput{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(100), + }) + if err != nil || quote == nil || quote.AmountOut.String() != "90" { + t.Fatalf("quote = %+v, err %v", quote, err) + } + inventory := liquidlane.Inventory{Route: route, MaxAssets: big.NewInt(100)} + plan, err := strategy.DecideFill(t.Context(), types.FillInput{ + TokenIn: tokenIn, TokenOut: tokenOut, AmountIn: big.NewInt(100), OutputAmount: big.NewInt(90), + Quotes: []liquidlane.FillQuote{{Inventory: inventory, AmountIn: big.NewInt(100), MaxAmountOut: big.NewInt(100)}}, + }) + if err != nil || plan == nil || len(plan.Routes) != 1 || plan.Routes[0].RouteID != route.ID { + t.Fatalf("plan = %+v, err %v", plan, err) + } +} + +func TestValidateQuotePreservesRequestedSide(t *testing.T) { + tests := []struct { + name string + input types.QuoteInput + quote *types.Quote + }{ + {name: "invalid amounts", input: types.QuoteInput{AmountIn: big.NewInt(1)}, quote: &types.Quote{}}, + {name: "changed exact input", input: types.QuoteInput{AmountIn: big.NewInt(10)}, quote: &types.Quote{AmountIn: big.NewInt(9), AmountOut: big.NewInt(8)}}, + {name: "changed exact output", input: types.QuoteInput{AmountOut: big.NewInt(10)}, quote: &types.Quote{AmountIn: big.NewInt(11), AmountOut: big.NewInt(9)}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateQuote(tt.input, tt.quote); err == nil { + t.Fatal("error = nil") + } + }) + } +} + +func newWebhookTestStrategy(t *testing.T, url string) *Strategy { + t.Helper() + client, err := webhook.NewClient(webhook.Config{URL: url, Timeout: time.Second}) + if err != nil { + t.Fatal(err) + } + return New(client) +} diff --git a/internal/solvers/uniswapx/strategy.go b/internal/solvers/uniswapx/strategy.go new file mode 100644 index 00000000..4846cbbd --- /dev/null +++ b/internal/solvers/uniswapx/strategy.go @@ -0,0 +1,12 @@ +package uniswapx + +import ( + "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies" + _ "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/default" + "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/types" + _ "github.com/symbioticfi/vault-solver/internal/solvers/uniswapx/strategies/webhook" +) + +func newStrategy(spec StrategyConfig) (types.Strategy, error) { + return strategies.New(spec.Name, spec.Config) +} diff --git a/internal/solvers/uniswapx/testdata/orders-v2-current.json b/internal/solvers/uniswapx/testdata/orders-v2-current.json new file mode 100644 index 00000000..9bd280f6 --- /dev/null +++ b/internal/solvers/uniswapx/testdata/orders-v2-current.json @@ -0,0 +1,40 @@ +{ + "orders": [ + { + "type": "Dutch_V2", + "encodedOrder": "0x01", + "signature": "0x1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "nonce": "1", + "orderHash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "orderStatus": "open", + "chainId": 1, + "swapper": "0x1111111111111111111111111111111111111111", + "input": { + "token": "0x3333333333333333333333333333333333333333", + "startAmount": "100", + "endAmount": "100" + }, + "outputs": [ + { + "token": "0x4444444444444444444444444444444444444444", + "startAmount": "220", + "endAmount": "200", + "recipient": "0x5555555555555555555555555555555555555555" + } + ], + "cosignerData": { + "decayStartTime": 1710000000, + "decayEndTime": 1710000300, + "exclusiveFiller": "0x6666666666666666666666666666666666666666", + "inputOverride": "100", + "outputOverrides": [ + "220" + ] + }, + "cosignature": "0x2222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222", + "createdAt": 1710000000, + "quoteId": "quote-1", + "requestId": "request-1" + } + ] +} diff --git a/internal/solvers/uniswapx/trace.go b/internal/solvers/uniswapx/trace.go new file mode 100644 index 00000000..7131954d --- /dev/null +++ b/internal/solvers/uniswapx/trace.go @@ -0,0 +1,19 @@ +package uniswapx + +import ( + liquidstrategies "github.com/symbioticfi/vault-solver/internal/liquidlane/strategies" +) + +func (s *Solver) decisionTrace(baseFields ...any) liquidstrategies.DecisionTrace { + log := s.log.V(1) + if !log.Enabled() { + return nil + } + base := append([]any(nil), baseFields...) + return func(message string, keyValues ...any) { + fields := make([]any, 0, len(base)+len(keyValues)) + fields = append(fields, base...) + fields = append(fields, keyValues...) + log.Info(message, fields...) + } +} diff --git a/internal/solvers/uniswapx/trace_test.go b/internal/solvers/uniswapx/trace_test.go new file mode 100644 index 00000000..b0ded5ea --- /dev/null +++ b/internal/solvers/uniswapx/trace_test.go @@ -0,0 +1,35 @@ +package uniswapx + +import ( + "strings" + "testing" + + "github.com/go-logr/logr/funcr" +) + +func TestDecisionTraceRequiresDebugVerbosityAndKeepsCorrelation(t *testing.T) { + var logs []string + solver := &Solver{ + log: funcr.NewJSON(func(entry string) { logs = append(logs, entry) }, funcr.Options{}), + } + if trace := solver.decisionTrace("requestId", "request-1"); trace != nil { + t.Fatal("decision trace enabled without debug verbosity") + } + + solver.log = funcr.NewJSON( + func(entry string) { logs = append(logs, entry) }, + funcr.Options{Verbosity: 1}, + ) + trace := solver.decisionTrace("requestId", "request-1", "quoteId", "quote-1") + if trace == nil { + t.Fatal("decision trace disabled at debug verbosity") + } + trace.Log("liquidlane quote declined", "reason", "gas-exceeds-output") + + if len(logs) != 1 || + !strings.Contains(logs[0], `"requestId":"request-1"`) || + !strings.Contains(logs[0], `"quoteId":"quote-1"`) || + !strings.Contains(logs[0], `"reason":"gas-exceeds-output"`) { + t.Fatalf("logs = %v", logs) + } +} diff --git a/internal/tenderly/tenderly.go b/internal/tenderly/tenderly.go new file mode 100644 index 00000000..6633a4d2 --- /dev/null +++ b/internal/tenderly/tenderly.go @@ -0,0 +1,62 @@ +// Package tenderly builds Tenderly Simulator draft links so a failed transaction can be replayed +// with its exact calldata (no ABI needed) for debugging. +package tenderly + +import ( + "encoding/base64" + "encoding/json" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +const simulatorBaseURL = "https://dashboard.tenderly.co/simulator/new" + +// draft is the payload Tenderly's Simulator expects base64url-encoded in the `?draft=` query param: +// https://docs.tenderly.co/simulator-ui/draft-links +type draft struct { + V int `json:"v"` + Network network `json:"network"` + Row row `json:"row"` +} + +type network struct { + ID string `json:"id"` +} + +type row struct { + ContractAddress string `json:"contractAddress"` + From string `json:"from"` + InputDataType string `json:"inputDataType"` + RawFunctionInput string `json:"rawFunctionInput"` + Value string `json:"value"` +} + +// SimulatorURL returns a Tenderly Simulator draft link for a single raw call (to/from/calldata on +// chainID), or "" if chainID is nil. The draft only pre-fills the simulator; it executes nothing +// until opened and run. +func SimulatorURL(chainID *big.Int, from, to common.Address, data []byte, value *big.Int) string { + if chainID == nil { + return "" + } + amount := "0" + if value != nil { + amount = value.String() + } + payload, err := json.Marshal(draft{ + V: 1, + Network: network{ID: chainID.String()}, + Row: row{ + ContractAddress: to.Hex(), + From: from.Hex(), + InputDataType: "raw", + RawFunctionInput: hexutil.Encode(data), + Value: amount, + }, + }) + if err != nil { + return "" + } + return simulatorBaseURL + "?draft=" + base64.RawURLEncoding.EncodeToString(payload) +} diff --git a/internal/tenderly/tenderly_test.go b/internal/tenderly/tenderly_test.go new file mode 100644 index 00000000..b86c79c8 --- /dev/null +++ b/internal/tenderly/tenderly_test.go @@ -0,0 +1,72 @@ +package tenderly + +import ( + "encoding/base64" + "encoding/json" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestSimulatorURL(t *testing.T) { + from := common.HexToAddress("0x9e8EB30000000000000000000000000000000001") + to := common.HexToAddress("0x0370000000000000000000000000000000000002") + data := []byte{0xde, 0xad, 0xbe, 0xef} + + url := SimulatorURL(big.NewInt(1), from, to, data, big.NewInt(0)) + + const prefix = "https://dashboard.tenderly.co/simulator/new?draft=" + if !strings.HasPrefix(url, prefix) { + t.Fatalf("url = %q, want prefix %q", url, prefix) + } + + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(url, prefix)) + if err != nil { + t.Fatalf("draft is not base64url: %v", err) + } + var got draft + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("draft is not JSON: %v", err) + } + + want := draft{ + V: 1, + Network: network{ID: "1"}, + Row: row{ + ContractAddress: to.Hex(), + From: from.Hex(), + InputDataType: "raw", + RawFunctionInput: "0xdeadbeef", + Value: "0", + }, + } + if got != want { + t.Fatalf("draft = %+v, want %+v", got, want) + } +} + +func TestSimulatorURL_NilValueDefaultsToZero(t *testing.T) { + url := SimulatorURL(big.NewInt(11155111), common.Address{0x01}, common.Address{0x02}, nil, nil) + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(url, "https://dashboard.tenderly.co/simulator/new?draft=")) + if err != nil { + t.Fatalf("decode: %v", err) + } + var got draft + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Row.Value != "0" { + t.Fatalf("value = %q, want 0 for nil value", got.Row.Value) + } + if got.Row.RawFunctionInput != "0x" { + t.Fatalf("rawFunctionInput = %q, want 0x for nil data", got.Row.RawFunctionInput) + } +} + +func TestSimulatorURL_NilChainIDIsEmpty(t *testing.T) { + if url := SimulatorURL(nil, common.Address{}, common.Address{}, nil, nil); url != "" { + t.Fatalf("url = %q, want empty for nil chainID", url) + } +} diff --git a/internal/tokenpolicy/policy.go b/internal/tokenpolicy/policy.go new file mode 100644 index 00000000..67c891e5 --- /dev/null +++ b/internal/tokenpolicy/policy.go @@ -0,0 +1,112 @@ +// Package tokenpolicy defines the shared input-token admission policy used by solvers. +package tokenpolicy + +import ( + "strconv" + + "github.com/ethereum/go-ethereum/common" + "github.com/go-errors/errors" + + "github.com/symbioticfi/vault-solver/internal/parse" +) + +// Scope selects which input-token class a solver serves. +type Scope string + +const ( + All Scope = "all" + Permissioned Scope = "permissioned" + Permissionless Scope = "permissionless" +) + +// Policy admits input tokens and marks the permissioned-only scope as single-route. +// Its zero value is the unrestricted "all" policy. +type Policy struct { + scope Scope + permissioned map[common.Address]bool +} + +// Parse validates the YAML-facing scope and permissioned-token list. +func Parse(rawScope string, rawTokens []string) (Policy, error) { + scope := Scope(parse.OrDefault(rawScope, string(All))) + if !validScope(scope) { + return Policy{}, errors.Errorf( + "tokensToQuote: must be %q, %q or %q, got %q", + All, Permissioned, Permissionless, rawScope, + ) + } + + tokens := make([]common.Address, 0, len(rawTokens)) + for i, value := range rawTokens { + token, err := parse.NonZeroAddress(value, "permissionedTokens["+strconv.Itoa(i)+"]") + if err != nil { + return Policy{}, err + } + tokens = append(tokens, token) + } + return New(scope, tokens) +} + +// New constructs a policy from typed values. +func New(scope Scope, tokens []common.Address) (Policy, error) { + if scope == "" { + scope = All + } + if !validScope(scope) { + return Policy{}, errors.Errorf("invalid token scope %q", scope) + } + + policy := Policy{scope: scope, permissioned: make(map[common.Address]bool, len(tokens))} + for i, token := range tokens { + if token == (common.Address{}) { + return Policy{}, errors.Errorf("permissionedTokens[%d]: zero address", i) + } + if policy.permissioned[token] { + return Policy{}, errors.Errorf("permissionedTokens[%d]: duplicate token %s", i, token.Hex()) + } + policy.permissioned[token] = true + } + return policy, nil +} + +func validScope(scope Scope) bool { + return scope == All || scope == Permissioned || scope == Permissionless +} + +// Scope returns the normalized configured scope. +func (p Policy) Scope() Scope { + if p.scope == "" { + return All + } + return p.scope +} + +// Allows reports whether token is in this solver's input-token scope. +func (p Policy) Allows(token common.Address) bool { + switch p.Scope() { + case Permissioned: + return p.permissioned[token] + case Permissionless: + return !p.permissioned[token] + case All: + return true + } + return false +} + +// RequiresSingleRoute reports whether an admitted token must use one physical route. +func (p Policy) RequiresSingleRoute(token common.Address) bool { + return p.Scope() == Permissioned && p.permissioned[token] +} + +// SingleRouteTokens returns the strategy-facing single-route token set. +func (p Policy) SingleRouteTokens() map[common.Address]bool { + if p.Scope() != Permissioned || len(p.permissioned) == 0 { + return nil + } + tokens := make(map[common.Address]bool, len(p.permissioned)) + for token := range p.permissioned { + tokens[token] = true + } + return tokens +} diff --git a/internal/tokenpolicy/policy_test.go b/internal/tokenpolicy/policy_test.go new file mode 100644 index 00000000..641ce313 --- /dev/null +++ b/internal/tokenpolicy/policy_test.go @@ -0,0 +1,93 @@ +package tokenpolicy + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +var ( + permissionedToken = common.HexToAddress("0x1111111111111111111111111111111111111111") + permissionlessToken = common.HexToAddress("0x2222222222222222222222222222222222222222") +) + +func TestPolicyScopes(t *testing.T) { + tests := []struct { + name string + scope Scope + token common.Address + wantAllowed bool + wantSingleRoute bool + wantSingleRouteSet bool + }{ + {"zero defaults to all", "", permissionedToken, true, false, false}, + {"all admits permissioned", All, permissionedToken, true, false, false}, + {"all admits permissionless", All, permissionlessToken, true, false, false}, + {"permissioned admits member", Permissioned, permissionedToken, true, true, true}, + {"permissioned rejects non-member", Permissioned, permissionlessToken, false, false, true}, + {"permissionless rejects member", Permissionless, permissionedToken, false, false, false}, + {"permissionless admits non-member", Permissionless, permissionlessToken, true, false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policy, err := New(tt.scope, []common.Address{permissionedToken}) + if err != nil { + t.Fatalf("New: %v", err) + } + if got := policy.Allows(tt.token); got != tt.wantAllowed { + t.Fatalf("Allows() = %v, want %v", got, tt.wantAllowed) + } + if got := policy.RequiresSingleRoute(tt.token); got != tt.wantSingleRoute { + t.Fatalf("RequiresSingleRoute() = %v, want %v", got, tt.wantSingleRoute) + } + _, gotSingleRouteSet := policy.SingleRouteTokens()[permissionedToken] + if gotSingleRouteSet != tt.wantSingleRouteSet { + t.Fatalf("SingleRouteTokens() contains permissioned token = %v, want %v", gotSingleRouteSet, tt.wantSingleRouteSet) + } + }) + } +} + +func TestParseValidatesConfig(t *testing.T) { + address := permissionedToken.Hex() + policy, err := Parse("", []string{address}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if policy.Scope() != All || !policy.Allows(permissionlessToken) { + t.Fatalf("default policy = %q", policy.Scope()) + } + + tests := []struct { + name string + scope string + tokens []string + match string + }{ + {"invalid scope", "private", nil, "tokensToQuote"}, + {"invalid address", "all", []string{"bad"}, "invalid address"}, + {"zero address", "all", []string{common.Address{}.Hex()}, "zero address"}, + {"duplicate", "all", []string{address, address}, "duplicate token"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse(tt.scope, tt.tokens) + if err == nil || !strings.Contains(err.Error(), tt.match) { + t.Fatalf("Parse() error = %v, want match %q", err, tt.match) + } + }) + } +} + +func TestSingleRouteTokensReturnsCopy(t *testing.T) { + policy, err := New(Permissioned, []common.Address{permissionedToken}) + if err != nil { + t.Fatalf("New: %v", err) + } + tokens := policy.SingleRouteTokens() + delete(tokens, permissionedToken) + if !policy.RequiresSingleRoute(permissionedToken) { + t.Fatal("caller mutated policy through SingleRouteTokens") + } +} diff --git a/internal/txmanager/txmanager.go b/internal/txmanager/txmanager.go index abf892f5..9b7f5e2d 100644 --- a/internal/txmanager/txmanager.go +++ b/internal/txmanager/txmanager.go @@ -1,6 +1,6 @@ // Package txmanager owns the on-chain sending account and serializes all transactions through a // single worker goroutine, so multiple solvers can never race on the account nonce. Solvers build -// calldata and hand it over via Send; they never sign or broadcast directly. +// calldata and hand it over via Send, TrySend, or SendAsync; they never sign or broadcast directly. package txmanager import ( @@ -19,6 +19,7 @@ import ( "github.com/go-logr/logr" "github.com/symbioticfi/vault-solver/internal/signer" + "github.com/symbioticfi/vault-solver/internal/tenderly" ) // Backend is the subset of an EVM client the manager needs. *ethclient.Client satisfies it. @@ -34,28 +35,52 @@ type Backend interface { // Config tunes fee selection and confirmation behavior. type Config struct { - Confirmations uint64 // blocks to wait past inclusion before returning - MaxFeeGwei float64 // cap on max fee per gas; 0 => derive from base fee - TipGwei float64 // priority fee; 0 => use the node's suggestion - PollInterval time.Duration // receipt/confirmation poll cadence; 0 => 2s + Confirmations uint64 // blocks to wait past inclusion before returning + MaxFeeGwei float64 // absolute max fee per gas; app config requires a positive value + TipGwei float64 // priority fee; 0 => use the node's suggestion + PollInterval time.Duration // receipt/confirmation poll cadence; 0 => 2s + ReplacementInterval time.Duration // pending tx fee-bump cadence; 0 => 30s + PendingTimeout time.Duration // switch from replacing the call to cancelling its nonce; 0 => 5m } // Request is a transaction to send. Value nil means 0; GasLimit 0 means "estimate". type Request struct { - To common.Address - Data []byte - Value *big.Int - GasLimit uint64 - Label string // for logs/metrics, e.g. "redeem" + To common.Address + Data []byte + Value *big.Int + GasLimit uint64 + MaxFeePerGas *big.Int // optional hard EIP-1559 fee ceiling; fees are clamped to it or rejected below base fee + Confirmations *uint64 // optional wait override; nil uses Config.Confirmations + Label string // for logs/metrics, e.g. "redeem" } -// Result carries the outcome of a Send. +// Result carries the outcome of one transaction request. type Result struct { Hash common.Hash Receipt *types.Receipt Err error } +type feeQuote struct { + baseFee *big.Int + tip *big.Int + maxFee *big.Int +} + +type pendingTransaction struct { + req Request + nonce uint64 + gas uint64 + value *big.Int + fees feeQuote + attempts []txAttempt +} + +type txAttempt struct { + hash common.Hash + cancellation bool +} + // Manager is the single-writer transaction sender. type Manager struct { backend Backend @@ -64,11 +89,15 @@ type Manager struct { cfg Config log logr.Logger - queue chan job + queue chan job + blockingSlot chan struct{} mu sync.Mutex // guards the local nonce nonce uint64 nonceInit bool + + unminedMu sync.Mutex + unminedNonces map[uint64]struct{} } type job struct { @@ -77,8 +106,13 @@ type job struct { } const ( - defaultPollInterval = 2 * time.Second - maxNonceResyncs = 1 + defaultPollInterval = 2 * time.Second + defaultReplacementInterval = 30 * time.Second + defaultPendingTimeout = 5 * time.Minute + replacementBumpNumerator = 9 + replacementBumpDenominator = 8 + cancellationGasLimit = 21_000 + maxNonceResyncs = 1 ) // New constructs a Manager. Call Start to launch its worker. @@ -86,13 +120,21 @@ func New(backend Backend, s signer.Signer, chainID *big.Int, cfg Config, log log if cfg.PollInterval <= 0 { cfg.PollInterval = defaultPollInterval } + if cfg.ReplacementInterval <= 0 { + cfg.ReplacementInterval = defaultReplacementInterval + } + if cfg.PendingTimeout <= 0 { + cfg.PendingTimeout = defaultPendingTimeout + } return &Manager{ - backend: backend, - signer: s, - chainID: chainID, - cfg: cfg, - log: log.WithName("txmanager"), - queue: make(chan job), + backend: backend, + signer: s, + chainID: chainID, + cfg: cfg, + log: log.WithName("txmanager"), + queue: make(chan job), + blockingSlot: make(chan struct{}, 1), + unminedNonces: make(map[uint64]struct{}), } } @@ -105,7 +147,13 @@ func (m *Manager) Start(ctx context.Context) { m.log.Info("stopped", "reason", ctx.Err().Error()) return case j := <-m.queue: - j.res <- m.execute(ctx, j.req) + pending, err := m.broadcast(ctx, j.req) + if err != nil { + j.res <- Result{Err: err} + continue + } + m.addUnminedNonce(pending.nonce) + go m.complete(ctx, pending, j.res) } } } @@ -118,30 +166,92 @@ func (m *Manager) Start(ctx context.Context) { // context, so Send waits for and returns that real outcome — it must not report a cancellation while // the transaction still lands on-chain, which a caller would read as "not sent" (the caller's ctx is // typically an errgroup child that cancels the instant any sibling solver errors, well before -// shutdown). The worker always delivers exactly one Result, so this wait cannot hang. +// shutdown). The worker owns fee replacement and same-nonce cancellation until it can deliver the +// real receipt or the manager context ends. func (m *Manager) Send(ctx context.Context, req Request) Result { - res := make(chan Result, 1) select { - case m.queue <- job{req: req, res: res}: + case m.blockingSlot <- struct{}{}: + defer func() { <-m.blockingSlot }() case <-ctx.Done(): return Result{Err: ctx.Err()} } - return <-res + return m.sendAccepted(ctx, req) +} + +// TrySend submits only when no blocking Send or TrySend call owns the exclusive slot. Async +// transactions do not hold this slot; every accepted broadcast still receives a serialized nonce. +func (m *Manager) TrySend(ctx context.Context, req Request) (Result, bool) { + select { + case m.blockingSlot <- struct{}{}: + defer func() { <-m.blockingSlot }() + default: + return Result{}, false + } + return m.sendAccepted(ctx, req), true +} + +func (m *Manager) sendAccepted(ctx context.Context, req Request) Result { + result, accepted := m.SendAsync(ctx, req) + if !accepted { + return Result{Err: ctx.Err()} + } + return <-result +} + +// SendAsync enqueues one transaction for nonce-serialized broadcast and returns its eventual +// receipt result without waiting for it. Once accepted, the manager's long-lived context owns the +// broadcast and receipt wait, matching Send's cancellation contract. +func (m *Manager) SendAsync(ctx context.Context, req Request) (<-chan Result, bool) { + res := make(chan Result, 1) + select { + case m.queue <- job{req: cloneRequest(req), res: res}: + case <-ctx.Done(): + return nil, false + } + return res, true } -// execute runs on the worker goroutine only, so nonce access is single-threaded here; the mutex -// guards against concurrent reads from a future status API. -func (m *Manager) execute(ctx context.Context, req Request) Result { - tip, maxFee, err := m.fees(ctx) +// MaxFeePerGas returns the conservative per-gas fee cap that the next transaction would use. Solvers +// use it only for profitability calculations; Send recomputes fees immediately before signing. +func (m *Manager) MaxFeePerGas(ctx context.Context) (*big.Int, error) { + fees, err := m.currentFees(ctx) if err != nil { - return Result{Err: err} + return nil, err + } + return fees.maxFee, nil +} + +// broadcast runs on the worker goroutine only, so fee selection, signing, and nonce assignment stay +// serialized even while earlier transactions wait for receipts concurrently. +func (m *Manager) broadcast(ctx context.Context, req Request) (*pendingTransaction, error) { + fees, err := m.currentFees(ctx) + if err != nil { + return nil, err + } + if req.MaxFeePerGas != nil { + feeCap := new(big.Int).Set(req.MaxFeePerGas) + if feeCap.Sign() <= 0 { + return nil, errors.Errorf("send %q: request max fee per gas must be positive", req.Label) + } + if feeCap.Cmp(fees.baseFee) < 0 { + return nil, errors.Errorf( + "send %q: current base fee per gas %s exceeds request cap %s", req.Label, fees.baseFee, feeCap, + ) + } + if fees.maxFee.Cmp(feeCap) > 0 { + fees.maxFee.Set(feeCap) + } + maxTip := new(big.Int).Sub(feeCap, fees.baseFee) + if fees.tip.Cmp(maxTip) > 0 { + fees.tip.Set(maxTip) + } } gas := req.GasLimit if gas == 0 { gas, err = m.estimateGas(ctx, req) if err != nil { - return Result{Err: err} + return nil, err } } @@ -149,79 +259,321 @@ func (m *Manager) execute(ctx context.Context, req Request) Result { if value == nil { value = new(big.Int) } + m.log.V(1).Info( + "transaction prepared", + "label", req.Label, + "to", req.To.Hex(), + "value", value.String(), + "calldataBytes", len(req.Data), + "gasLimit", gas, + "baseFeePerGas", fees.baseFee.String(), + "maxPriorityFeePerGas", fees.tip.String(), + "maxFeePerGas", fees.maxFee.String(), + "requestMaxFeePerGas", optionalBigString(req.MaxFeePerGas), + ) var lastErr error for attempt := 0; attempt <= maxNonceResyncs; attempt++ { nonce, nErr := m.nextNonce(ctx, attempt > 0) if nErr != nil { - return Result{Err: nErr} - } - - tx := types.NewTx(&types.DynamicFeeTx{ - ChainID: m.chainID, - Nonce: nonce, - GasTipCap: tip, - GasFeeCap: maxFee, - Gas: gas, - To: &req.To, - Value: value, - Data: req.Data, - }) - - signed, sErr := m.signer.SignTx(tx, m.chainID) - if sErr != nil { - return Result{Err: sErr} + return nil, nErr } - if sendErr := m.backend.SendTransaction(ctx, signed); sendErr != nil { + hash, sendErr := m.signAndSend( + ctx, nonce, req.To, req.Data, value, gas, fees, + ) + if sendErr != nil { lastErr = sendErr if isNonceTooLow(sendErr) { m.log.Info("nonce too low; resyncing", "label", req.Label, "nonce", nonce) continue // retry with a freshly-synced nonce } - return Result{Err: errors.Errorf("send %q: %w", req.Label, sendErr)} + return nil, errors.Errorf("send %q: %w", req.Label, sendErr) } m.commitNonce(nonce) - hash := signed.Hash() m.log.Info("sent", "label", req.Label, "hash", hash.Hex(), "nonce", nonce) + return &pendingTransaction{ + req: req, + nonce: nonce, + gas: gas, + value: new(big.Int).Set(value), + fees: cloneFeeQuote(fees), + attempts: []txAttempt{{hash: hash}}, + }, nil + } + return nil, errors.Errorf("send %q: exhausted nonce resyncs: %w", req.Label, lastErr) +} + +func (m *Manager) complete(ctx context.Context, pending *pendingTransaction, result chan<- Result) { + defer m.removeUnminedNonce(pending.nonce) + result <- m.waitForPendingTransaction(ctx, pending) +} + +func (m *Manager) confirmations(req Request) uint64 { + if req.Confirmations != nil { + return *req.Confirmations + } + return m.cfg.Confirmations +} + +func (m *Manager) waitForPendingTransaction(ctx context.Context, pending *pendingTransaction) Result { + poll := time.NewTicker(m.cfg.PollInterval) + defer poll.Stop() + replace := time.NewTicker(m.cfg.ReplacementInterval) + defer replace.Stop() + timeout := time.NewTimer(m.cfg.PendingTimeout) + defer timeout.Stop() + + cancelling := false + for { + if receiptResult, done := m.receiptResult(ctx, pending); done { + return receiptResult + } + select { + case <-ctx.Done(): + return Result{Hash: pending.attempts[0].hash, Err: ctx.Err()} + case <-poll.C: + case <-replace.C: + m.tryReplace(ctx, pending, cancelling) + case <-timeout.C: + if !m.isLowestUnminedNonce(pending.nonce) { + m.log.Info("pending timeout deferred behind lower nonce", + "label", pending.req.Label, + "nonce", pending.nonce, + ) + timeout.Reset(m.cfg.PendingTimeout) + continue + } + cancelling = true + m.log.Info("pending transaction timed out; cancelling nonce", + "label", pending.req.Label, + "nonce", pending.nonce, + "timeout", m.cfg.PendingTimeout.String(), + ) + m.tryReplace(ctx, pending, true) + } + } +} + +func (m *Manager) receiptResult(ctx context.Context, pending *pendingTransaction) (Result, bool) { + for i := len(pending.attempts) - 1; i >= 0; i-- { + attempt := pending.attempts[i] + receipt, err := m.backend.TransactionReceipt(ctx, attempt.hash) + if errors.Is(err, ethereum.NotFound) { + continue + } + if err != nil { + m.log.Error(err, "pending transaction receipt unavailable", + "label", pending.req.Label, + "hash", attempt.hash.Hex(), + "nonce", pending.nonce, + ) + continue + } + m.removeUnminedNonce(pending.nonce) + if receipt.Status == types.ReceiptStatusFailed { + m.log.Error(errors.Errorf("tx %s reverted on-chain", attempt.hash.Hex()), "transaction reverted", + "label", pending.req.Label, + "hash", attempt.hash.Hex(), + "nonce", pending.nonce, + "tenderly", tenderly.SimulatorURL(m.chainID, m.signer.Address(), pending.req.To, pending.req.Data, pending.req.Value), + ) + return Result{ + Hash: attempt.hash, + Receipt: receipt, + Err: errors.Errorf("tx %s reverted on-chain", attempt.hash.Hex()), + }, true + } + if err := m.waitForConfirmations(ctx, receipt, m.confirmations(pending.req)); err != nil { + return Result{Hash: attempt.hash, Receipt: receipt, Err: err}, true + } + if attempt.cancellation { + return Result{ + Hash: attempt.hash, + Receipt: receipt, + Err: errors.Errorf( + "send %q: pending transaction cancelled at nonce %d after %s", + pending.req.Label, pending.nonce, m.cfg.PendingTimeout, + ), + }, true + } + m.log.V(1).Info( + "transaction confirmed", + "label", pending.req.Label, + "hash", attempt.hash.Hex(), + "nonce", pending.nonce, + "blockNumber", optionalBigString(receipt.BlockNumber), + "gasUsed", receipt.GasUsed, + "effectiveGasPrice", optionalBigString(receipt.EffectiveGasPrice), + "confirmations", m.confirmations(pending.req), + ) + return Result{Hash: attempt.hash, Receipt: receipt}, true + } + return Result{}, false +} + +func (m *Manager) tryReplace(ctx context.Context, pending *pendingTransaction, cancellation bool) { + limit := m.normalFeeLimit(pending.req) + if cancellation { + limit = m.globalFeeLimit() + } + fees, err := m.nextReplacementFees(ctx, pending.fees, limit) + if err != nil { + m.log.Error(err, "cannot replace pending transaction", + "label", pending.req.Label, + "nonce", pending.nonce, + "cancellation", cancellation, + ) + return + } + to := pending.req.To + data := pending.req.Data + value := pending.value + gas := pending.gas + if cancellation { + to = m.signer.Address() + data = nil + value = new(big.Int) + gas = cancellationGasLimit + } + hash, err := m.signAndSend(ctx, pending.nonce, to, data, value, gas, fees) + if err != nil { + m.log.Error(err, "pending transaction replacement failed", + "label", pending.req.Label, + "nonce", pending.nonce, + "cancellation", cancellation, + ) + return + } + pending.fees = cloneFeeQuote(fees) + pending.attempts = append(pending.attempts, txAttempt{hash: hash, cancellation: cancellation}) + m.log.Info("pending transaction replaced", + "label", pending.req.Label, + "hash", hash.Hex(), + "nonce", pending.nonce, + "cancellation", cancellation, + "maxFeePerGas", fees.maxFee.String(), + "maxPriorityFeePerGas", fees.tip.String(), + ) +} + +func (m *Manager) nextReplacementFees( + ctx context.Context, + previous feeQuote, + limit *big.Int, +) (feeQuote, error) { + current, err := m.currentFees(ctx) + if err != nil { + return feeQuote{}, err + } + next := feeQuote{ + baseFee: current.baseFee, + tip: maxBig(current.tip, bumpFee(previous.tip)), + maxFee: maxBig(current.maxFee, bumpFee(previous.maxFee)), + } + if limit != nil && next.maxFee.Cmp(limit) > 0 { + next.maxFee.Set(limit) + } + maxTip := new(big.Int).Sub(next.maxFee, next.baseFee) + if maxTip.Sign() < 0 { + return feeQuote{}, errors.Errorf( + "replacement base fee %s exceeds fee limit %s", next.baseFee, next.maxFee, + ) + } + if next.tip.Cmp(maxTip) > 0 { + next.tip.Set(maxTip) + } + if next.maxFee.Cmp(previous.maxFee) <= 0 || next.tip.Cmp(previous.tip) <= 0 { + return feeQuote{}, errors.Errorf( + "replacement fee limit reached: previous max fee %s tip %s, limit %s", + previous.maxFee, previous.tip, feeLimitString(limit), + ) + } + return next, nil +} + +func (m *Manager) normalFeeLimit(req Request) *big.Int { + limit := reserveCancellationBump(m.globalFeeLimit()) + if req.MaxFeePerGas != nil && (limit == nil || req.MaxFeePerGas.Cmp(limit) < 0) { + limit = new(big.Int).Set(req.MaxFeePerGas) + } + return limit +} - receipt, wErr := m.waitForReceipt(ctx, hash) - return Result{Hash: hash, Receipt: receipt, Err: wErr} +func (m *Manager) globalFeeLimit() *big.Int { + if m.cfg.MaxFeeGwei <= 0 { + return nil } - return Result{Err: errors.Errorf("send %q: exhausted nonce resyncs: %w", req.Label, lastErr)} + return gweiToWei(m.cfg.MaxFeeGwei) } -// fees computes the EIP-1559 tip and max-fee-per-gas. -func (m *Manager) fees(ctx context.Context) (tip, maxFee *big.Int, err error) { +func (m *Manager) addUnminedNonce(nonce uint64) { + m.unminedMu.Lock() + defer m.unminedMu.Unlock() + m.unminedNonces[nonce] = struct{}{} +} + +func (m *Manager) removeUnminedNonce(nonce uint64) { + m.unminedMu.Lock() + defer m.unminedMu.Unlock() + delete(m.unminedNonces, nonce) +} + +func (m *Manager) isLowestUnminedNonce(nonce uint64) bool { + m.unminedMu.Lock() + defer m.unminedMu.Unlock() + for unmined := range m.unminedNonces { + if unmined < nonce { + return false + } + } + return true +} + +// currentFees computes the current EIP-1559 base fee, tip, and normal-send fee cap. +func (m *Manager) currentFees(ctx context.Context) (feeQuote, error) { + var tip *big.Int if m.cfg.TipGwei > 0 { tip = gweiToWei(m.cfg.TipGwei) } else { + var err error tip, err = m.backend.SuggestGasTipCap(ctx) if err != nil { - return nil, nil, errors.Errorf("suggest gas tip: %w", err) + return feeQuote{}, errors.Errorf("suggest gas tip: %w", err) } } + if tip == nil || tip.Sign() < 0 { + return feeQuote{}, errors.New("gas tip must be non-negative") + } + tip = new(big.Int).Set(tip) head, err := m.backend.HeaderByNumber(ctx, nil) if err != nil { - return nil, nil, errors.Errorf("header by number: %w", err) + return feeQuote{}, errors.Errorf("header by number: %w", err) } - baseFee := head.BaseFee - if baseFee == nil { + var baseFee *big.Int + if head.BaseFee == nil { baseFee = new(big.Int) + } else { + baseFee = new(big.Int).Set(head.BaseFee) } - if m.cfg.MaxFeeGwei > 0 { - maxFee = gweiToWei(m.cfg.MaxFeeGwei) - } else { - // 2*baseFee + tip leaves headroom for one base-fee doubling between now and inclusion. - maxFee = new(big.Int).Add(new(big.Int).Mul(baseFee, big.NewInt(2)), tip) + // 2*baseFee + tip leaves headroom for one base-fee doubling between now and inclusion. + maxFee := new(big.Int).Add(new(big.Int).Mul(baseFee, big.NewInt(2)), tip) + if limit := m.normalFeeLimit(Request{}); limit != nil { + if maxFee.Cmp(limit) > 0 { + maxFee.Set(limit) + } + } + maxTip := new(big.Int).Sub(maxFee, baseFee) + if maxTip.Sign() < 0 { + return feeQuote{}, errors.Errorf("current base fee %s exceeds tx manager max fee %s", baseFee, maxFee) } - if maxFee.Cmp(tip) < 0 { - maxFee = new(big.Int).Set(tip) + if tip.Cmp(maxTip) > 0 { + tip.Set(maxTip) } - return tip, maxFee, nil + return feeQuote{baseFee: baseFee, tip: tip, maxFee: maxFee}, nil } func (m *Manager) estimateGas(ctx context.Context, req Request) (uint64, error) { @@ -232,12 +584,54 @@ func (m *Manager) estimateGas(ctx context.Context, req Request) (uint64, error) Data: req.Data, }) if err != nil { + // A revert here surfaces from eth_estimateGas with almost no detail; the Tenderly link replays + // the exact call so the operator can see the trace (harmless for a non-revert RPC error). + m.log.Error(err, "gas estimation failed", + "label", req.Label, + "tenderly", tenderly.SimulatorURL(m.chainID, m.signer.Address(), req.To, req.Data, req.Value), + ) return 0, errors.Errorf("estimate gas %q: %w", req.Label, err) } // 20% headroom over the estimate. return gas + gas/5, nil } +func optionalBigString(value *big.Int) string { + if value == nil { + return "0" + } + return value.String() +} + +func (m *Manager) signAndSend( + ctx context.Context, + nonce uint64, + to common.Address, + data []byte, + value *big.Int, + gas uint64, + fees feeQuote, +) (common.Hash, error) { + tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: m.chainID, + Nonce: nonce, + GasTipCap: fees.tip, + GasFeeCap: fees.maxFee, + Gas: gas, + To: &to, + Value: value, + Data: data, + }) + signed, err := m.signer.SignTx(tx, m.chainID) + if err != nil { + return common.Hash{}, errors.Errorf("sign transaction: %w", err) + } + if err := m.backend.SendTransaction(ctx, signed); err != nil { + return common.Hash{}, err + } + return signed.Hash(), nil +} + // nextNonce returns the nonce to use, seeding or resyncing from the pending nonce when needed. func (m *Manager) nextNonce(ctx context.Context, resync bool) (uint64, error) { m.mu.Lock() @@ -261,41 +655,92 @@ func (m *Manager) commitNonce(used uint64) { } } -func (m *Manager) waitForReceipt(ctx context.Context, hash common.Hash) (*types.Receipt, error) { +func (m *Manager) waitForConfirmations( + ctx context.Context, + receipt *types.Receipt, + confirmations uint64, +) error { + if receipt == nil || receipt.BlockNumber == nil { + return errors.New("receipt block number is required") + } + if confirmations == 0 { + return nil + } ticker := time.NewTicker(m.cfg.PollInterval) defer ticker.Stop() - var receipt *types.Receipt for { - if receipt == nil { - r, err := m.backend.TransactionReceipt(ctx, hash) - if err == nil { - receipt = r - if receipt.Status == types.ReceiptStatusFailed { - return receipt, errors.Errorf("tx %s reverted on-chain", hash.Hex()) - } - } else if !errors.Is(err, ethereum.NotFound) { - return nil, errors.Errorf("receipt %s: %w", hash.Hex(), err) - } + head, err := m.backend.BlockNumber(ctx) + if err != nil { + return errors.Errorf("block number: %w", err) } - if receipt != nil { - head, err := m.backend.BlockNumber(ctx) - if err != nil { - return nil, errors.Errorf("block number: %w", err) - } - confirmed := receipt.BlockNumber.Uint64() + m.cfg.Confirmations - if head >= confirmed { - return receipt, nil - } + confirmed := receipt.BlockNumber.Uint64() + confirmations + if head >= confirmed { + return nil } select { case <-ctx.Done(): - return nil, ctx.Err() + return ctx.Err() case <-ticker.C: } } } +func cloneRequest(req Request) Request { + req.Data = append([]byte(nil), req.Data...) + if req.Value != nil { + req.Value = new(big.Int).Set(req.Value) + } + if req.MaxFeePerGas != nil { + req.MaxFeePerGas = new(big.Int).Set(req.MaxFeePerGas) + } + if req.Confirmations != nil { + confirmations := *req.Confirmations + req.Confirmations = &confirmations + } + return req +} + +func cloneFeeQuote(fees feeQuote) feeQuote { + return feeQuote{ + baseFee: new(big.Int).Set(fees.baseFee), + tip: new(big.Int).Set(fees.tip), + maxFee: new(big.Int).Set(fees.maxFee), + } +} + +func bumpFee(value *big.Int) *big.Int { + numerator := new(big.Int).Mul(value, big.NewInt(replacementBumpNumerator)) + numerator.Add(numerator, big.NewInt(replacementBumpDenominator-1)) + bumped := numerator.Div(numerator, big.NewInt(replacementBumpDenominator)) + if bumped.Cmp(value) <= 0 { + bumped.Add(value, big.NewInt(1)) + } + return bumped +} + +func reserveCancellationBump(limit *big.Int) *big.Int { + if limit == nil { + return nil + } + reserved := new(big.Int).Mul(limit, big.NewInt(replacementBumpDenominator)) + return reserved.Div(reserved, big.NewInt(replacementBumpNumerator)) +} + +func maxBig(a, b *big.Int) *big.Int { + if a.Cmp(b) >= 0 { + return new(big.Int).Set(a) + } + return new(big.Int).Set(b) +} + +func feeLimitString(limit *big.Int) string { + if limit == nil { + return "unbounded" + } + return limit.String() +} + func gweiToWei(gwei float64) *big.Int { wei, _ := new(big.Float).Mul(big.NewFloat(gwei), big.NewFloat(params.GWei)).Int(nil) return wei diff --git a/internal/txmanager/txmanager_anvil_test.go b/internal/txmanager/txmanager_anvil_test.go new file mode 100644 index 00000000..06466c5b --- /dev/null +++ b/internal/txmanager/txmanager_anvil_test.go @@ -0,0 +1,294 @@ +//go:build integration + +package txmanager + +import ( + "bytes" + "context" + "math/big" + "net" + "os/exec" + "strconv" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/internal/signer" +) + +func TestAnvilTxManagerPendingLifecycle(t *testing.T) { + t.Run("fee bump replacement", testAnvilReplacement) + t.Run("timeout cancellation unblocks later nonce", testAnvilCancellation) +} + +func testAnvilReplacement(t *testing.T) { + rpcClient, ethClient := startAnvilWithoutMining(t) + sgnr := anvilSigner(t) + manager := New( + ethClient, + sgnr, + big.NewInt(31337), + Config{ + MaxFeeGwei: 100, + PollInterval: 20 * time.Millisecond, + ReplacementInterval: 200 * time.Millisecond, + PendingTimeout: 5 * time.Second, + }, + logr.Discard(), + ) + go manager.Start(t.Context()) + + result, accepted := manager.SendAsync(t.Context(), Request{ + To: common.HexToAddress("0x000000000000000000000000000000000000dEaD"), GasLimit: 21_000, Label: "replace", + }) + if !accepted { + t.Fatal("transaction was not accepted") + } + initial := waitForPoolTransaction(t, rpcClient, sgnr.Address(), 0, func(poolTransaction) bool { return true }) + replacement := waitForPoolTransaction(t, rpcClient, sgnr.Address(), 0, func(tx poolTransaction) bool { + return tx.Hash != initial.Hash + }) + if compareHexQuantity(replacement.MaxFeePerGas, initial.MaxFeePerGas) <= 0 || + compareHexQuantity(replacement.MaxPriorityFeePerGas, initial.MaxPriorityFeePerGas) <= 0 { + t.Fatalf( + "replacement fees did not increase: first=%s/%s replacement=%s/%s", + initial.MaxFeePerGas, + initial.MaxPriorityFeePerGas, + replacement.MaxFeePerGas, + replacement.MaxPriorityFeePerGas, + ) + } + + mineAnvilBlock(t, rpcClient) + got := waitForTxResult(t, result) + if got.Err != nil { + t.Fatalf("replacement result: %v", got.Err) + } + if !strings.EqualFold(got.Hash.Hex(), replacement.Hash) { + t.Fatalf("mined hash = %s, want replacement %s", got.Hash.Hex(), replacement.Hash) + } +} + +func testAnvilCancellation(t *testing.T) { + rpcClient, ethClient := startAnvilWithoutMining(t) + sgnr := anvilSigner(t) + manager := New( + ethClient, + sgnr, + big.NewInt(31337), + Config{ + MaxFeeGwei: 100, + PollInterval: 20 * time.Millisecond, + ReplacementInterval: 5 * time.Second, + PendingTimeout: 300 * time.Millisecond, + }, + logr.Discard(), + ) + go manager.Start(t.Context()) + + first, accepted := manager.SendAsync(t.Context(), Request{ + To: common.HexToAddress("0x000000000000000000000000000000000000dEaD"), + GasLimit: 21_000, + MaxFeePerGas: big.NewInt(3_000_000_000), + Label: "blocked", + }) + if !accepted { + t.Fatal("first transaction was not accepted") + } + second, accepted := manager.SendAsync(t.Context(), Request{ + To: common.HexToAddress("0x000000000000000000000000000000000000bEEF"), + GasLimit: 21_000, + Label: "later", + }) + if !accepted { + t.Fatal("second transaction was not accepted") + } + + waitForPoolTransaction(t, rpcClient, sgnr.Address(), 1, func(poolTransaction) bool { return true }) + cancellation := waitForPoolTransaction(t, rpcClient, sgnr.Address(), 0, func(tx poolTransaction) bool { + return strings.EqualFold(tx.To, sgnr.Address().Hex()) && tx.Input == "0x" && tx.Value == "0x0" + }) + if cancellation.Gas != "0x5208" { + t.Fatalf("cancellation gas = %s, want 0x5208", cancellation.Gas) + } + + mineAnvilBlock(t, rpcClient) + firstResult := waitForTxResult(t, first) + if firstResult.Err == nil || !strings.Contains(firstResult.Err.Error(), "cancelled at nonce 0") { + t.Fatalf("first result = %+v, want cancellation", firstResult) + } + if secondResult := waitForTxResult(t, second); secondResult.Err != nil { + t.Fatalf("later transaction remained blocked: %v", secondResult.Err) + } +} + +type poolTransaction struct { + Hash string `json:"hash"` + To string `json:"to"` + Value string `json:"value"` + Input string `json:"input"` + Gas string `json:"gas"` + MaxFeePerGas string `json:"maxFeePerGas"` + MaxPriorityFeePerGas string `json:"maxPriorityFeePerGas"` +} + +type txPoolContent struct { + Pending map[string]map[string]poolTransaction `json:"pending"` + Queued map[string]map[string]poolTransaction `json:"queued"` +} + +func waitForPoolTransaction( + t *testing.T, + client *rpc.Client, + sender common.Address, + nonce uint64, + accept func(poolTransaction) bool, +) poolTransaction { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + tx, ok, err := poolTransactionAt(t.Context(), client, sender, nonce) + if err != nil { + t.Fatalf("txpool_content: %v", err) + } + if ok && accept(tx) { + return tx + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timed out waiting for sender %s nonce %d in txpool", sender.Hex(), nonce) + return poolTransaction{} +} + +func poolTransactionAt( + ctx context.Context, + client *rpc.Client, + sender common.Address, + nonce uint64, +) (poolTransaction, bool, error) { + var content txPoolContent + if err := client.CallContext(ctx, &content, "txpool_content"); err != nil { + return poolTransaction{}, false, err + } + nonceKey := strconv.FormatUint(nonce, 10) + for _, pool := range []map[string]map[string]poolTransaction{content.Pending, content.Queued} { + for address, transactions := range pool { + if !strings.EqualFold(address, sender.Hex()) { + continue + } + tx, ok := transactions[nonceKey] + return tx, ok, nil + } + } + return poolTransaction{}, false, nil +} + +func compareHexQuantity(left, right string) int { + leftValue, leftErr := hexutil.DecodeBig(left) + rightValue, rightErr := hexutil.DecodeBig(right) + if leftErr != nil || rightErr != nil { + return 0 + } + return leftValue.Cmp(rightValue) +} + +func mineAnvilBlock(t *testing.T, client *rpc.Client) { + t.Helper() + if err := client.CallContext(t.Context(), nil, "anvil_mine", 1); err != nil { + t.Fatalf("anvil_mine: %v", err) + } +} + +func waitForTxResult(t *testing.T, result <-chan Result) Result { + t.Helper() + select { + case got := <-result: + return got + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for transaction result") + return Result{} + } +} + +func anvilSigner(t *testing.T) signer.Signer { + t.Helper() + sgnr, err := signer.NewFromHexKey(testKey) + if err != nil { + t.Fatalf("signer: %v", err) + } + return sgnr +} + +func startAnvilWithoutMining(t *testing.T) (*rpc.Client, *ethclient.Client) { + t.Helper() + anvil, err := exec.LookPath("anvil") + if err != nil { + t.Skip("anvil is not installed") + } + listener, err := new(net.ListenConfig).Listen(t.Context(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve anvil port: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + if err := listener.Close(); err != nil { + t.Fatalf("release anvil port: %v", err) + } + + var output bytes.Buffer + + cmd := exec.CommandContext( + t.Context(), + anvil, + "--no-mining", + "--silent", + "--chain-id", + "31337", + "--port", + strconv.Itoa(port), + ) + cmd.Stdout = &output + cmd.Stderr = &output + if err := cmd.Start(); err != nil { + t.Fatalf("start anvil: %v", err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + select { + case <-done: + case <-time.After(time.Second): + } + }) + + url := "http://127.0.0.1:" + strconv.Itoa(port) + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + client, dialErr := rpc.DialContext(t.Context(), url) + if dialErr == nil { + var chainID string + if callErr := client.CallContext(t.Context(), &chainID, "eth_chainId"); callErr == nil { + ethClient := ethclient.NewClient(client) + t.Cleanup(ethClient.Close) + return client, ethClient + } + client.Close() + } + select { + case exitErr := <-done: + t.Fatalf("anvil exited during startup: %v\n%s", exitErr, output.String()) + default: + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("anvil did not become ready:\n%s", output.String()) + return nil, nil +} diff --git a/internal/txmanager/txmanager_test.go b/internal/txmanager/txmanager_test.go index 497b0b9f..5358da1b 100644 --- a/internal/txmanager/txmanager_test.go +++ b/internal/txmanager/txmanager_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "math/big" + "strings" "sync" "testing" "time" @@ -148,6 +149,113 @@ func TestSend_HappyPath(t *testing.T) { } } +func TestMaxFeePerGasMatchesSendFeePolicy(t *testing.T) { + b := newMockBackend() + m, cancel := newTestManager(t, b) + defer cancel() + + fee, err := m.MaxFeePerGas(context.Background()) + if err != nil { + t.Fatalf("MaxFeePerGas: %v", err) + } + if fee.String() != "41000000000" { + t.Fatalf("max fee = %s, want 41000000000", fee) + } +} + +func TestMaxFeeGweiCapsDerivedFeeWithoutConsumingReplacementHeadroom(t *testing.T) { + b := newMockBackend() + m := New( + b, mustSigner(t), big.NewInt(11155111), + Config{MaxFeeGwei: 100, PollInterval: time.Millisecond}, + logr.Discard(), + ) + fee, err := m.MaxFeePerGas(t.Context()) + if err != nil { + t.Fatalf("MaxFeePerGas: %v", err) + } + if fee.String() != "41000000000" { + t.Fatalf("max fee = %s, want derived 41000000000 below the 100 gwei cap", fee) + } +} + +func TestMaxFeeGweiRejectsCurrentBaseFeeAboveCap(t *testing.T) { + b := newMockBackend() + m := New( + b, mustSigner(t), big.NewInt(11155111), + Config{MaxFeeGwei: 10, PollInterval: time.Millisecond}, + logr.Discard(), + ) + if _, err := m.MaxFeePerGas(t.Context()); err == nil { + t.Fatal("expected max fee cap below current base fee to fail") + } +} + +func TestSend_ClampsFeeToRequestCap(t *testing.T) { + b := newMockBackend() + m, cancel := newTestManager(t, b) + defer cancel() + + res := m.Send(context.Background(), Request{ + To: common.HexToAddress("0xabc"), GasLimit: 21_000, Label: "capped", + MaxFeePerGas: big.NewInt(40_000_000_000), + }) + if res.Err != nil { + t.Fatalf("send: %v", res.Err) + } + tx := b.lastSent() + if tx == nil { + t.Fatal("no transaction sent") + } + if tx.GasFeeCap().Cmp(big.NewInt(40_000_000_000)) != 0 { + t.Fatalf("gas fee cap = %s, want 40000000000", tx.GasFeeCap()) + } + if tx.GasTipCap().Cmp(big.NewInt(1_000_000_000)) != 0 { + t.Fatalf("gas tip cap = %s, want 1000000000", tx.GasTipCap()) + } +} + +func TestSend_ClampsTipToFitRequestCap(t *testing.T) { + b := newMockBackend() + m, cancel := newTestManager(t, b) + defer cancel() + + res := m.Send(context.Background(), Request{ + To: common.HexToAddress("0xabc"), GasLimit: 21_000, Label: "capped", + MaxFeePerGas: big.NewInt(20_500_000_000), + }) + if res.Err != nil { + t.Fatalf("send: %v", res.Err) + } + tx := b.lastSent() + if tx == nil { + t.Fatal("no transaction sent") + } + if tx.GasFeeCap().Cmp(big.NewInt(20_500_000_000)) != 0 { + t.Fatalf("gas fee cap = %s, want 20500000000", tx.GasFeeCap()) + } + if tx.GasTipCap().Cmp(big.NewInt(500_000_000)) != 0 { + t.Fatalf("gas tip cap = %s, want 500000000", tx.GasTipCap()) + } +} + +func TestSend_RejectsRequestCapBelowCurrentBaseFee(t *testing.T) { + b := newMockBackend() + m, cancel := newTestManager(t, b) + defer cancel() + + res := m.Send(context.Background(), Request{ + To: common.HexToAddress("0xabc"), GasLimit: 21_000, Label: "capped", + MaxFeePerGas: big.NewInt(19_000_000_000), + }) + if res.Err == nil { + t.Fatal("expected base-fee rejection") + } + if tx := b.lastSent(); tx != nil { + t.Fatalf("underpriced request sent transaction %s", tx.Hash()) + } +} + func TestSend_SequentialNoncesMonotonic(t *testing.T) { b := newMockBackend() m, cancel := newTestManager(t, b) @@ -164,6 +272,415 @@ func TestSend_SequentialNoncesMonotonic(t *testing.T) { } } +func TestSendAsyncBroadcastsSequentialNoncesBeforeConfirmations(t *testing.T) { + b := newMockBackend() + m := New( + b, mustSigner(t), big.NewInt(11155111), + Config{Confirmations: 2, PollInterval: time.Millisecond}, logr.Discard(), + ) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go m.Start(ctx) + + results := make([]<-chan Result, 0, 3) + for range 3 { + result, accepted := m.SendAsync( + context.Background(), Request{To: common.HexToAddress("0xabc"), GasLimit: 21_000, Label: "pipeline"}, + ) + if !accepted { + t.Fatal("SendAsync was not accepted") + } + results = append(results, result) + } + waitForSentTransactions(t, b, 3) + b.mu.Lock() + for i, tx := range b.sent { + if want := uint64(7 + i); tx.Nonce() != want { + b.mu.Unlock() + t.Fatalf("transaction %d nonce = %d, want %d", i, tx.Nonce(), want) + } + } + b.head = 102 + b.mu.Unlock() + for i, result := range results { + select { + case got := <-result: + if got.Err != nil { + t.Fatalf("result %d: %v", i, got.Err) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for result %d", i) + } + } +} + +func TestSendAsyncCanCompleteAtInclusion(t *testing.T) { + b := newMockBackend() + m := New( + b, mustSigner(t), big.NewInt(11155111), + Config{Confirmations: 2, PollInterval: time.Millisecond}, logr.Discard(), + ) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go m.Start(ctx) + confirmations := uint64(0) + result, accepted := m.SendAsync(context.Background(), Request{ + To: common.HexToAddress("0xabc"), GasLimit: 21_000, Confirmations: &confirmations, Label: "inclusion", + }) + if !accepted { + t.Fatal("SendAsync was not accepted") + } + select { + case got := <-result: + if got.Err != nil || got.Receipt == nil || got.Receipt.BlockNumber.Uint64() != 100 { + t.Fatalf("result = %+v", got) + } + case <-time.After(time.Second): + t.Fatal("request did not complete at inclusion") + } +} + +func TestSendAsyncReplacesPendingTransactionWithHigherFees(t *testing.T) { + b := &replacementBackend{ + mockBackend: newMockBackend(), + receiptOnSameNonce: 2, + } + m := New( + b, mustSigner(t), big.NewInt(11155111), + Config{ + PollInterval: time.Millisecond, + ReplacementInterval: 2 * time.Millisecond, + PendingTimeout: time.Second, + }, + logr.Discard(), + ) + go m.Start(t.Context()) + + result, accepted := m.SendAsync(t.Context(), Request{ + To: common.HexToAddress("0xabc"), Data: []byte{0x01}, GasLimit: 21_000, Label: "replace", + }) + if !accepted { + t.Fatal("SendAsync was not accepted") + } + select { + case got := <-result: + if got.Err != nil { + t.Fatalf("replacement result: %v", got.Err) + } + case <-time.After(time.Second): + t.Fatal("replacement did not complete") + } + + b.mu.Lock() + defer b.mu.Unlock() + if len(b.sent) < 2 { + t.Fatalf("sent transactions = %d, want at least 2", len(b.sent)) + } + first, replacement := b.sent[0], b.sent[1] + if replacement.Nonce() != first.Nonce() || string(replacement.Data()) != string(first.Data()) { + t.Fatalf("replacement changed transaction: first=%+v replacement=%+v", first, replacement) + } + if replacement.GasFeeCapCmp(first) <= 0 || replacement.GasTipCapCmp(first) <= 0 { + t.Fatalf( + "replacement fees did not increase: first=%s/%s replacement=%s/%s", + first.GasFeeCap(), first.GasTipCap(), replacement.GasFeeCap(), replacement.GasTipCap(), + ) + } +} + +func TestFailedReplacementDoesNotAdvanceFeeState(t *testing.T) { + b := newMockBackend() + b.sendErrs = []error{errors.New("temporary broadcast failure")} + m := New( + b, mustSigner(t), big.NewInt(11155111), + Config{MaxFeeGwei: 100, PollInterval: time.Millisecond}, + logr.Discard(), + ) + original := feeQuote{ + baseFee: big.NewInt(20_000_000_000), + tip: big.NewInt(1_000_000_000), + maxFee: big.NewInt(41_000_000_000), + } + pending := &pendingTransaction{ + req: Request{To: common.HexToAddress("0xabc"), Label: "replace"}, + nonce: 7, + gas: 21_000, + value: new(big.Int), + fees: cloneFeeQuote(original), + } + + m.tryReplace(t.Context(), pending, false) + if pending.fees.maxFee.Cmp(original.maxFee) != 0 || pending.fees.tip.Cmp(original.tip) != 0 { + t.Fatalf("failed replacement advanced fees to %+v", pending.fees) + } + if len(pending.attempts) != 0 { + t.Fatalf("failed replacement attempts = %+v", pending.attempts) + } + + m.tryReplace(t.Context(), pending, false) + if len(pending.attempts) != 1 { + t.Fatalf("successful retry attempts = %+v", pending.attempts) + } + wantMaxFee := bumpFee(original.maxFee) + if pending.fees.maxFee.Cmp(wantMaxFee) != 0 { + t.Fatalf("successful retry max fee = %s, want first bump %s", pending.fees.maxFee, wantMaxFee) + } +} + +func TestNormalFeeLimitReservesOneCancellationBump(t *testing.T) { + b := newMockBackend() + b.baseFee = big.NewInt(30_000_000_000) + m := New( + b, mustSigner(t), big.NewInt(11155111), + Config{MaxFeeGwei: 50, PollInterval: time.Millisecond}, + logr.Discard(), + ) + + fees, err := m.currentFees(t.Context()) + if err != nil { + t.Fatalf("fees: %v", err) + } + normalLimit := reserveCancellationBump(gweiToWei(50)) + if fees.maxFee.Cmp(normalLimit) != 0 { + t.Fatalf("normal max fee = %s, want reserved limit %s", fees.maxFee, normalLimit) + } + cancellationFees, err := m.nextReplacementFees( + t.Context(), + feeQuote{baseFee: fees.baseFee, tip: fees.tip, maxFee: normalLimit}, + m.globalFeeLimit(), + ) + if err != nil { + t.Fatalf("cancellation fees: %v", err) + } + if cancellationFees.maxFee.Cmp(gweiToWei(50)) != 0 { + t.Fatalf("cancellation max fee = %s, want global cap %s", cancellationFees.maxFee, gweiToWei(50)) + } +} + +func TestPendingTimeoutCancelsBlockedNonceAndUnblocksLaterTransaction(t *testing.T) { + sgnr := mustSigner(t) + b := &replacementBackend{mockBackend: newMockBackend(), cancellationTo: sgnr.Address()} + m := New( + b, sgnr, big.NewInt(11155111), + Config{ + PollInterval: time.Millisecond, + ReplacementInterval: 2 * time.Millisecond, + PendingTimeout: 8 * time.Millisecond, + }, + logr.Discard(), + ) + go m.Start(t.Context()) + + first, accepted := m.SendAsync(t.Context(), Request{ + To: common.HexToAddress("0xabc"), + Data: []byte{0x01}, + GasLimit: 21_000, + MaxFeePerGas: big.NewInt(42_000_000_000), + Label: "blocked", + }) + if !accepted { + t.Fatal("first SendAsync was not accepted") + } + second, accepted := m.SendAsync(t.Context(), Request{ + To: common.HexToAddress("0xdef"), Data: []byte{0x02}, GasLimit: 21_000, Label: "later", + }) + if !accepted { + t.Fatal("second SendAsync was not accepted") + } + + select { + case got := <-first: + if got.Err == nil || !strings.Contains(got.Err.Error(), "cancelled at nonce 7") { + t.Fatalf("first result = %+v", got) + } + case <-time.After(time.Second): + t.Fatal("blocked transaction was not cancelled") + } + select { + case got := <-second: + if got.Err != nil { + t.Fatalf("later transaction result: %v", got.Err) + } + case <-time.After(time.Second): + t.Fatal("later nonce remained wedged") + } + cancellation := b.cancellationTransaction() + if cancellation == nil { + t.Fatal("same-nonce cancellation was not sent") + } + if cancellation.GasFeeCap().Cmp(big.NewInt(42_000_000_000)) <= 0 { + t.Fatalf("cancellation fee %s did not escape the fill profitability cap", cancellation.GasFeeCap()) + } +} + +func TestIncludedNonceDoesNotBlockLaterCancellationWhileConfirming(t *testing.T) { + b := newMockBackend() + m := New( + b, mustSigner(t), big.NewInt(11155111), + Config{ + Confirmations: 2, + MaxFeeGwei: 100, + PollInterval: time.Millisecond, + ReplacementInterval: time.Second, + PendingTimeout: time.Second, + }, + logr.Discard(), + ) + tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: big.NewInt(11155111), Nonce: 7, GasTipCap: big.NewInt(1), GasFeeCap: big.NewInt(2), + Gas: 21_000, To: ptr(common.HexToAddress("0xabc")), + }) + b.receipts[tx.Hash()] = successfulReceipt(tx, b.head) + pending := &pendingTransaction{ + req: Request{Label: "confirming"}, + nonce: 7, + fees: feeQuote{baseFee: big.NewInt(1), tip: big.NewInt(1), maxFee: big.NewInt(2)}, + attempts: []txAttempt{{hash: tx.Hash()}}, + } + m.addUnminedNonce(7) + m.addUnminedNonce(8) + + result := make(chan Result, 1) + go func() { result <- m.waitForPendingTransaction(t.Context(), pending) }() + eventually(t, func() bool { return m.isLowestUnminedNonce(8) }) + select { + case got := <-result: + t.Fatalf("transaction completed before confirmations: %+v", got) + default: + } + + b.mu.Lock() + b.head = 102 + b.mu.Unlock() + if got := <-result; got.Err != nil { + t.Fatalf("confirmed result: %v", got.Err) + } +} + +func TestTransientReceiptErrorKeepsTrackingPendingTransaction(t *testing.T) { + b := &receiptErrorBackend{mockBackend: newMockBackend(), failures: 1} + m := New( + b, mustSigner(t), big.NewInt(11155111), + Config{ + MaxFeeGwei: 100, + PollInterval: time.Millisecond, + ReplacementInterval: time.Second, + PendingTimeout: time.Second, + }, + logr.Discard(), + ) + go m.Start(t.Context()) + + result, accepted := m.SendAsync(t.Context(), Request{ + To: common.HexToAddress("0xabc"), GasLimit: 21_000, Label: "receipt retry", + }) + if !accepted { + t.Fatal("transaction was not accepted") + } + if got := <-result; got.Err != nil { + t.Fatalf("receipt retry result: %v", got.Err) + } +} + +func waitForSentTransactions(t *testing.T, b *mockBackend, count int) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + b.mu.Lock() + sent := len(b.sent) + b.mu.Unlock() + if sent >= count { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("timed out waiting for %d broadcasts", count) +} + +type receiptErrorBackend struct { + *mockBackend + + receiptMu sync.Mutex + failures int +} + +func (b *receiptErrorBackend) TransactionReceipt(ctx context.Context, hash common.Hash) (*types.Receipt, error) { + b.receiptMu.Lock() + if b.failures > 0 { + b.failures-- + b.receiptMu.Unlock() + return nil, errors.New("temporary receipt failure") + } + b.receiptMu.Unlock() + return b.mockBackend.TransactionReceipt(ctx, hash) +} + +type replacementBackend struct { + *mockBackend + + receiptOnSameNonce int + cancellationTo common.Address + sameNonceSends int + cancelled bool +} + +func (b *replacementBackend) SendTransaction(_ context.Context, tx *types.Transaction) error { + b.mu.Lock() + defer b.mu.Unlock() + b.sendCalls++ + b.sent = append(b.sent, tx) + + if b.isCancellation(tx) { + b.cancelled = true + b.receipts[tx.Hash()] = successfulReceipt(tx, b.head) + for _, sent := range b.sent { + if sent.Nonce() > tx.Nonce() { + b.receipts[sent.Hash()] = successfulReceipt(sent, b.head) + } + } + return nil + } + if tx.Nonce() == b.pendingNonce { + b.sameNonceSends++ + if b.receiptOnSameNonce > 0 && b.sameNonceSends >= b.receiptOnSameNonce { + b.receipts[tx.Hash()] = successfulReceipt(tx, b.head) + } + return nil + } + if b.cancelled { + b.receipts[tx.Hash()] = successfulReceipt(tx, b.head) + } + return nil +} + +func (b *replacementBackend) cancellationTransaction() *types.Transaction { + b.mu.Lock() + defer b.mu.Unlock() + for _, tx := range b.sent { + if b.isCancellation(tx) { + return tx + } + } + return nil +} + +func (b *replacementBackend) isCancellation(tx *types.Transaction) bool { + return b.cancellationTo != (common.Address{}) && + tx.To() != nil && + *tx.To() == b.cancellationTo && + len(tx.Data()) == 0 && + tx.Value().Sign() == 0 && + tx.Gas() == cancellationGasLimit +} + +func successfulReceipt(tx *types.Transaction, block uint64) *types.Receipt { + return &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: tx.Hash(), + BlockNumber: new(big.Int).SetUint64(block), + } +} + func TestSend_NonceTooLowResyncsAndRetries(t *testing.T) { b := newMockBackend() b.sendErrs = []error{errors.New("nonce too low")} // first send fails, second succeeds @@ -268,6 +785,36 @@ func TestSend_CallerCancelAfterEnqueueStillReturnsResult(t *testing.T) { } } +func TestTrySendRejectsWhileTransactionIsActive(t *testing.T) { + bb := &blockingBackend{mockBackend: newMockBackend(), entered: make(chan struct{}), release: make(chan struct{})} + m := New(bb, mustSigner(t), big.NewInt(11155111), Config{PollInterval: time.Millisecond}, logr.Discard()) + go m.Start(t.Context()) + + type tryResult struct { + result Result + accepted bool + } + first := make(chan tryResult, 1) + go func() { + result, accepted := m.TrySend( + context.Background(), Request{To: common.HexToAddress("0xabc"), GasLimit: 21_000, Label: "first"}, + ) + first <- tryResult{result: result, accepted: accepted} + }() + + <-bb.entered + if result, accepted := m.TrySend( + context.Background(), Request{To: common.HexToAddress("0xdef"), GasLimit: 21_000, Label: "second"}, + ); accepted || result.Err != nil { + t.Fatalf("busy TrySend = (%+v, %v), want not accepted", result, accepted) + } + close(bb.release) + got := <-first + if !got.accepted || got.result.Err != nil { + t.Fatalf("first TrySend = (%+v, %v)", got.result, got.accepted) + } +} + func mustSigner(t *testing.T) signer.Signer { t.Helper() s, err := signer.NewFromHexKey(testKey) @@ -276,3 +823,19 @@ func mustSigner(t *testing.T) signer.Signer { } return s } + +func ptr[T any](value T) *T { + return &value +} + +func eventually(t *testing.T, condition func() bool) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if condition() { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("condition was not met") +} diff --git a/openapi/lifi-order.openapi.json b/openapi/lifi-order.openapi.json index 63afa11c..2bf7ef53 100644 --- a/openapi/lifi-order.openapi.json +++ b/openapi/lifi-order.openapi.json @@ -1,5 +1,5 @@ { - "openapi": "3.0.0", + "openapi": "3.1.0", "paths": { "/quote/request": { "post": { @@ -7,9 +7,9 @@ "parameters": [ { "name": "X-Integrator-Key", - "in": "header", - "description": "Raw integrator API key (obtained from integrator onboarding). When provided, integrator-specific quotes become eligible in addition to open-market quotes.", "required": false, + "in": "header", + "description": "Raw integrator key. Use a high-entropy random string. When provided, integrator-specific quotes become eligible in addition to open-market quotes.", "schema": { "type": "string" } @@ -101,7 +101,7 @@ "quotes": [ { "order": null, - "validUntil": 1777941876, + "validUntil": 1900000000, "quoteId": "quote_yCyE5aWW4NILo2UdM-8ETpia05TCLv", "preview": { "inputs": [ @@ -153,9 +153,9 @@ }, "summary": "Request quote", "tags": [ - "Quotes", "Bridge API" - ] + ], + "security": [] } }, "/quotes/submit": { @@ -183,7 +183,7 @@ "fromDecimals": 6, "toDecimals": 6, "exclusiveFor": "0x841F63697cFa0e3B54c4D42b3d679F07F7F2485f", - "expiry": 1777941876, + "expiry": 1900000000, "ranges": [ { "minAmount": "300010", @@ -212,7 +212,7 @@ "fromDecimals": 6, "toDecimals": 6, "exclusiveFor": "0x841F63697cFa0e3B54c4D42b3d679F07F7F2485f", - "expiry": 1777941876, + "expiry": 1900000000, "ranges": [ { "minAmount": "300010", @@ -297,7 +297,7 @@ "fromDecimals": 6, "toDecimals": 6, "exclusiveFor": "4Nd1mY9XzN6vfFh1Cm9wHQXgVxqQ8j8s8MoREvkLqJ7G", - "expiry": 1777941876, + "expiry": 1900000000, "ranges": [ { "minAmount": "1000000", @@ -366,7 +366,6 @@ ], "summary": "Submit quotes", "tags": [ - "Quotes", "Solver API" ] } @@ -413,7 +412,8 @@ "summary": "Get supported chains", "tags": [ "Bridge API" - ] + ], + "security": [] } }, "/api/v1/integrator/quote/request": { @@ -422,9 +422,9 @@ "parameters": [ { "name": "X-Integrator-Key", - "in": "header", - "description": "Raw integrator API key (obtained from integrator onboarding). When provided, integrator-specific quotes become eligible in addition to open-market quotes.", "required": false, + "in": "header", + "description": "Raw integrator key. Use a high-entropy random string. When provided, integrator-specific quotes become eligible in addition to open-market quotes.", "schema": { "type": "string" } @@ -558,7 +558,8 @@ "summary": "Request quote", "tags": [ "Bridge API v1" - ] + ], + "security": [] } }, "/orders/submit": { @@ -580,11 +581,11 @@ "inputSettler": "0x000001bf3F3175BD007f3889b50000c7006E72c0", "quoteId": "quote_kQAD6-AIP5AdHKTwPUlz-Ha6VYN31n", "order": { - "expires": 1942819670, + "expires": "1942819670", "user": "0x9773DAcbc46CAFb4e055060565e319922B48607D", "nonce": "1004", "originChainId": "84532", - "fillDeadline": 1942819670, + "fillDeadline": "1942819670", "inputOracle": "0xada1de62bE4F386346453A5b6F005BCdBE4515A1", "inputs": [ [ @@ -612,11 +613,11 @@ "orderType": "CatalystCompactOrder", "inputSettler": "0x000001bf3F3175BD007f3889b50000c7006E72c0", "order": { - "expires": 1942819670, + "expires": "1942819670", "user": "0x9773DAcbc46CAFb4e055060565e319922B48607D", "nonce": "1004", "originChainId": "84532", - "fillDeadline": 1942819670, + "fillDeadline": "1942819670", "inputOracle": "0xada1de62bE4F386346453A5b6F005BCdBE4515A1", "inputs": [ [ @@ -749,7 +750,8 @@ "summary": "Submit order", "tags": [ "Bridge API" - ] + ], + "security": [] } }, "/orders": { @@ -1036,7 +1038,8 @@ "summary": "Get orders", "tags": [ "Bridge API" - ] + ], + "security": [] } }, "/orders/status": { @@ -1048,7 +1051,7 @@ "name": "onChainOrderId", "required": false, "in": "query", - "description": "On chain order id propagated in the logs/events.", + "description": "On chain order id propagated in the logs/events. At least one of `onChainOrderId` or `catalystOrderId` must be provided.", "schema": { "example": "0xc7b44934463285434d1acc3405a6514e6677adfaae180ec042204d3cfc218d81", "type": "string" @@ -1058,7 +1061,7 @@ "name": "catalystOrderId", "required": false, "in": "query", - "description": "Internal order id returned by Lifi Intents API", + "description": "Internal order id returned by Lifi Intents API. At least one of `onChainOrderId` or `catalystOrderId` must be provided.", "schema": { "example": "intent_iU_VePNSu8ED3Y2WxEcQmA0GKxmJsE", "type": "string" @@ -1171,7 +1174,8 @@ "summary": "Get order status", "tags": [ "Bridge API" - ] + ], + "security": [] } }, "/solver-api/solver/identities": { @@ -2691,7 +2695,8 @@ "summary": "Get supported routes", "tags": [ "Bridge API" - ] + ], + "security": [] } }, "/api/v1/integrator/routes": { @@ -2821,7 +2826,8 @@ "summary": "Get supported routes", "tags": [ "Bridge API v1" - ] + ], + "security": [] } } }, @@ -2832,7 +2838,16 @@ "contact": {} }, "tags": [], - "servers": [], + "servers": [ + { + "url": "https://order.li.fi", + "description": "Production" + }, + { + "url": "https://order-dev.li.fi", + "description": "Development" + } + ], "components": { "securitySchemes": { "api-key": { @@ -2856,11 +2871,9 @@ "properties": { "intentType": { "type": "string", + "const": "oif-swap", "description": "Intent type", - "example": "oif-swap", - "enum": [ - "oif-swap" - ] + "example": "oif-swap" }, "inputs": { "type": "array", @@ -2965,7 +2978,7 @@ }, "minValidUntil": { "description": "Minimum validity timestamp in seconds", - "example": 1700000000, + "example": 1785154905, "type": "number" }, "preference": { @@ -3073,7 +3086,7 @@ "supportedTypes" ] }, - "QuotePreviewDto": { + "OifQuotePreviewDto": { "type": "object", "properties": { "inputs": { @@ -3128,10 +3141,12 @@ "type": "object", "properties": { "exclusiveFor": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Solver address with exclusivity on this quote, or null when no solver is exclusive", - "example": "0x1234567890123456789012345678901234567890", - "nullable": true + "example": "0x1234567890123456789012345678901234567890" } }, "required": [ @@ -3142,15 +3157,17 @@ "type": "object", "properties": { "order": { - "type": "object", - "description": "Order details (null for quote requests)", - "nullable": true, + "type": [ + "object", + "null" + ], + "description": "Order details; null for quote requests, provider-specific structure when populated", "example": null }, "validUntil": { "type": "number", "description": "Quote validity timestamp in seconds", - "example": 1700000000 + "example": 1900000000 }, "eta": { "type": "number", @@ -3171,7 +3188,7 @@ "description": "Informational amounts for UX/display", "allOf": [ { - "$ref": "#/components/schemas/QuotePreviewDto" + "$ref": "#/components/schemas/OifQuotePreviewDto" } ] }, @@ -3300,6 +3317,7 @@ "example": 6 }, "ranges": { + "maxItems": 1000, "type": "array", "items": { "type": "object", @@ -3339,14 +3357,14 @@ "quote" ] }, - "description": "Array of quote ranges with different price tiers" + "description": "Array of quote ranges with different price tiers. At most 1000 ranges per quote." }, "expiry": { "type": "integer", "minimum": 1000000000, "maximum": 4102444800, "description": "Expiry timestamp of the quote in seconds", - "example": 1672531200 + "example": 1900000000 }, "exclusiveFor": { "description": "Exclusive solver address allowed to fill this quote. EVM (eip155): 0x-prefixed 40-char hex. Solana: 32–44 char base58. Tron: base58check, T-prefixed, 34 chars.", @@ -3354,7 +3372,7 @@ "type": "string" }, "integratorKeyHash": { - "description": "Integrator key hash identifying the integrator this quote is tagged for. If provided, the quote will only be served to the specific integrator. If omitted, the quote is treated as an open-market quote available to all integrators.", + "description": "Integrator key hash this quote is tagged for. If provided, the quote will only be served to the specific integrator. If omitted, the quote is treated as an open-market quote available to all integrators.", "example": "a1b2c3d4e5f60000000000000000000000000000000000000000000000000000", "type": "string", "pattern": "^[a-f0-9]{64}$" @@ -3463,11 +3481,9 @@ "properties": { "intentType": { "type": "string", + "const": "oif-swap", "description": "Intent type (otherwise return quotes: [])", - "example": "oif-swap", - "enum": [ - "oif-swap" - ] + "example": "oif-swap" }, "inputs": { "type": "array", @@ -3493,13 +3509,20 @@ "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }, "amount": { - "type": "string", "description": "Amount available. For exact-input: exact amount user will provide and is used for quoting. For exact-output: ignored by the quote decoder and not used for quoting", "example": "4000000000", - "nullable": true + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "lock": { - "description": "Optional lock reference for securing the input assets. Shape: { kind: \"the-compact\" | \"rhinestone\", params: object }. Currently ignored by the quote decoder." + "description": "Optional lock reference for securing the input assets. Shape: { kind: \"the-compact\" | \"rhinestone\", params: object }. Currently ignored by the quote decoder.", + "type": "object" } }, "required": [ @@ -3534,10 +3557,16 @@ "example": "0xdAC17F958D2ee523a2206206994597C13D831ec7" }, "amount": { - "type": "string", "description": "For exact-input: ignored by the quote decoder and not used for quoting. For exact-output: exact amount user wants to receive and is used for quoting", "example": "2000000000000000000", - "nullable": true + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "calldata": { "description": "Optional calldata describing how the receiver will consume the output. Enables composability with other protocols", @@ -3565,7 +3594,7 @@ }, "minValidUntil": { "description": "Minimum validity timestamp in unix timestamp (seconds). Only select solver quotes with longer TTL.", - "example": 1700000000, + "example": 1785154905, "type": "number" }, "preference": { @@ -3618,6 +3647,102 @@ } } ] + }, + "oracle": { + "description": "Accepted cross-chain verifier (oracle) contracts, each a { chain, address } object. When provided, only solvers that support one of these oracles can answer, and the returned order is built to settle against an accepted oracle. Omitted or empty means any oracle is acceptable. Ignored for same-chain swaps.", + "example": [ + { + "chain": "eip155:1", + "address": "0x0000003E06000007A224AeE90052fA6bb46d43C9" + } + ], + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "properties": { + "chain": { + "type": "string", + "minLength": 1, + "description": "CAIP-2 chain identifier, e.g. \"eip155:1\"", + "example": "eip155:1" + }, + "address": { + "type": "string", + "minLength": 1, + "description": "Native contract address for the chain", + "example": "0x0000003e06000007a224aee90052fa6bb46d43c9" + } + }, + "required": [ + "chain", + "address" + ] + } + }, + "inputSettler": { + "description": "Accepted input settler contracts, each a { chain, address } object. When provided, a quote is only returned if the order is built with one of these input settlers and the winning solver supports it. Omitted or empty means any input settler is acceptable.", + "example": [ + { + "chain": "eip155:1", + "address": "0x000025c3226C00B2Cdc200005a1600509f4e00C0" + } + ], + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "properties": { + "chain": { + "type": "string", + "minLength": 1, + "description": "CAIP-2 chain identifier, e.g. \"eip155:1\"", + "example": "eip155:1" + }, + "address": { + "type": "string", + "minLength": 1, + "description": "Native contract address for the chain", + "example": "0x0000003e06000007a224aee90052fa6bb46d43c9" + } + }, + "required": [ + "chain", + "address" + ] + } + }, + "outputSettler": { + "description": "Accepted output settler contracts, each a { chain, address } object. When provided, a quote is only returned if the order is built with one of these output settlers and the winning solver supports it. Omitted or empty means any output settler is acceptable.", + "example": [ + { + "chain": "eip155:1", + "address": "0x0000000000eC36B683C2E6AC89e9A75989C22a2e" + } + ], + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "properties": { + "chain": { + "type": "string", + "minLength": 1, + "description": "CAIP-2 chain identifier, e.g. \"eip155:1\"", + "example": "eip155:1" + }, + "address": { + "type": "string", + "minLength": 1, + "description": "Native contract address for the chain", + "example": "0x0000003e06000007a224aee90052fa6bb46d43c9" + } + }, + "required": [ + "chain", + "address" + ] + } } } } @@ -3646,143 +3771,562 @@ "supportedTypes" ] }, - "QuoteMetadataDto": { + "OpenIntentEvmTxDto": { "type": "object", "properties": { - "exclusiveFor": { - "type": "object", - "description": "Exclusive for address (hex32) - solver address that can fill this quote, or null", - "example": "0x1234567890123456789012345678901234567890", - "nullable": true + "chain": { + "type": "string", + "description": "CAIP-2 chain identifier for the destination contract", + "example": "eip155:1" + }, + "to": { + "type": "string", + "description": "Destination contract address (checksummed hex)", + "example": "0x1234567890123456789012345678901234567890" + }, + "data": { + "type": "string", + "description": "Transaction calldata as hex string", + "example": "0x095ea7b3000000000000000000000000..." + }, + "gasRequired": { + "type": "string", + "description": "Gas required for execution as a decimal string", + "example": "120000" } }, "required": [ - "exclusiveFor" + "chain", + "to", + "data", + "gasRequired" ] }, - "QuoteDto": { + "OpenIntentSvmTxDto": { "type": "object", "properties": { - "order": { - "description": "Order details", - "oneOf": [ - { - "$ref": "#/components/schemas/OifUserOpenIntentOrderDto" - }, - { - "$ref": "#/components/schemas/OifEscrowOrderDto" - }, - { - "$ref": "#/components/schemas/Oif3009OrderDto" - } - ] + "chain": { + "type": "string", + "description": "CAIP-2 chain identifier (Solana namespace)", + "example": "solana:1151111081099710" }, - "validUntil": { - "type": "number", - "description": "Quote validity timestamp in unix timestamp (seconds)", - "example": 1700000000 + "to": { + "type": "string", + "description": "Input settler program ID (base58)", + "example": "Amx9xngT2J5156cf1iMdF1BfJFwoDu8Je6Qv2zH14V5c" }, - "eta": { - "type": "number", - "description": "Estimated time of arrival in seconds", - "example": 8 + "data": { + "type": "string", + "description": "Base58-encoded serialized VersionedTransaction. The dummy all-zeros recentBlockhash must be replaced with a fresh blockhash before signing.", + "example": "Base58SerializedVersionedTx..." }, - "quoteId": { + "computeUnitsRequired": { "type": "string", - "description": "Unique quote identifier", - "example": "quote-123-abc" + "description": "Estimated compute units (decimal string)", + "example": "1200000" + } + }, + "required": [ + "chain", + "to", + "data", + "computeUnitsRequired" + ] + }, + "OpenIntentTronTxDto": { + "type": "object", + "properties": { + "chain": { + "type": "string", + "description": "CAIP-2 chain identifier (Tron namespace)", + "example": "tron:728126428" }, - "provider": { + "to": { "type": "string", - "description": "Provider identifier", - "enum": [ - "LI.FI Intent" - ], - "example": "LI.FI Intent" + "description": "Input settler contract address (base58check)", + "example": "TXabfeeRfzpZiK6wABb2KDB3Rbzte87x3o" }, - "preview": { - "description": "Informational amounts for UX/display, must be verified against the order", - "allOf": [ - { - "$ref": "#/components/schemas/QuotePreviewDto" - } - ] + "data": { + "type": "string", + "description": "Full ABI calldata (selector + args) as a 0x-prefixed hex string. Pass it as `data` (without the 0x prefix) to the fullnode HTTP endpoint wallet/triggersmartcontract, or with tronweb 6.x as `triggerSmartContract(to, \"\", { feeLimit, input: data }, [], owner)`.", + "example": "0x7515fd56000000000000000000000000..." }, - "failureHandling": { + "feeLimit": { "type": "string", - "description": "Failure handling policy for execution", - "enum": [ - "refund-automatic" - ], - "example": "refund-automatic" + "description": "Suggested fee_limit in SUN as a decimal string. A cap on energy spend, not an estimate.", + "example": "150000000" + } + }, + "required": [ + "chain", + "to", + "data", + "feeLimit" + ] + }, + "AllowanceCheckDto": { + "type": "object", + "properties": { + "chain": { + "type": "string", + "description": "CAIP-2 chain identifier for this allowance check (e.g., \"eip155:1\")", + "example": "eip155:1" }, - "partialFill": { - "type": "boolean", - "description": "Whether the quote supports partial fills", - "example": false + "token": { + "type": "string", + "description": "Native token address", + "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }, - "metadata": { - "description": "Metadata for the order, potentially contains provider specific data", - "allOf": [ - { - "$ref": "#/components/schemas/QuoteMetadataDto" - } - ] + "user": { + "type": "string", + "description": "Native user address", + "example": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb8" + }, + "spender": { + "type": "string", + "description": "Native spender address - InputSettlerEscrowLIFI", + "example": "0x1234567890123456789012345678901234567890" + }, + "required": { + "type": "string", + "description": "Required allowance amount as string-encoded integer", + "example": "1000000000" } }, "required": [ - "order", - "quoteId", - "provider", - "preview", - "failureHandling", - "partialFill", - "metadata" + "chain", + "token", + "user", + "spender", + "required" ] }, - "QuoteResponseDto": { + "ChecksDto": { "type": "object", "properties": { - "quotes": { - "description": "Array of generated quotes. List of available quotes, may be empty if no quotes are available", + "allowances": { + "description": "Required allowances and balances. Each item asserts that user has at least required balance and allowance for spender on token.", "type": "array", "items": { - "$ref": "#/components/schemas/QuoteDto" + "$ref": "#/components/schemas/AllowanceCheckDto" } } }, "required": [ - "quotes" + "allowances" ] }, - "SubmitOrderDto": { + "OifUserOpenIntentOrderDto": { "type": "object", "properties": { - "orderType": { - "default": "CatalystCompactOrder", - "description": "The type of the order", + "type": { "type": "string", + "description": "Order type identifier for user open intent execution", "enum": [ - "CatalystCompactOrder" - ] + "oif-user-open-v0" + ], + "example": "oif-user-open-v0" }, - "order": { - "type": "object", - "properties": { - "user": { - "description": "User address on source chain (initiator of the intent)", - "example": "0x", - "type": "string" - }, - "nonce": { - "description": "Nonce value of the intent", - "type": "string" + "openIntentTx": { + "description": "Open intent transaction. EVM produces hex calldata; Solana produces a base58 serialized VersionedTransaction whose recentBlockhash must be overwritten before signing; Tron produces hex calldata the client wraps in a TriggerSmartContract envelope.", + "oneOf": [ + { + "$ref": "#/components/schemas/OpenIntentEvmTxDto" }, - "originChainId": { - "description": "Origin chain ID (network id)", - "type": "string" + { + "$ref": "#/components/schemas/OpenIntentSvmTxDto" }, - "fillDeadline": { + { + "$ref": "#/components/schemas/OpenIntentTronTxDto" + } + ] + }, + "checks": { + "description": "Allowance and balance checks that must hold prior to execution. For Solana origins this array is empty; SPL transfers happen inside the open instruction.", + "allOf": [ + { + "$ref": "#/components/schemas/ChecksDto" + } + ] + } + }, + "required": [ + "type", + "openIntentTx", + "checks" + ] + }, + "Eip712PayloadDto": { + "type": "object", + "properties": { + "signatureType": { + "type": "string", + "description": "Signature type indicator", + "enum": [ + "eip712" + ], + "example": "eip712" + }, + "domain": { + "type": "object", + "description": "EIP-712 domain separator", + "example": { + "name": "Permit2", + "version": "1", + "chainId": 1, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + } + }, + "primaryType": { + "type": "string", + "description": "Primary type name", + "example": "PermitBatchWitnessTransferFrom" + }, + "message": { + "type": "object", + "description": "The message object", + "example": {} + }, + "types": { + "type": "object", + "description": "EIP-712 types used to construct the digest", + "example": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ] + } + } + }, + "required": [ + "signatureType", + "domain", + "primaryType", + "message", + "types" + ] + }, + "OifEscrowOrderDto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Order type identifier for escrow-based execution", + "enum": [ + "oif-escrow-v0" + ], + "example": "oif-escrow-v0" + }, + "payload": { + "description": "EIP-712 payload for escrow order", + "allOf": [ + { + "$ref": "#/components/schemas/Eip712PayloadDto" + } + ] + } + }, + "required": [ + "type", + "payload" + ] + }, + "Oif3009OrderDto": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Order type identifier for EIP-3009 transfers", + "enum": [ + "oif-3009-v0" + ], + "example": "oif-3009-v0" + }, + "payload": { + "description": "EIP-3009 Transfer With Authorization typed data", + "allOf": [ + { + "$ref": "#/components/schemas/Eip712PayloadDto" + } + ] + }, + "metadata": { + "type": "object", + "description": "Additional metadata for nonce verification and order tracking", + "example": {} + } + }, + "required": [ + "type", + "payload", + "metadata" + ] + }, + "InputDto": { + "type": "object", + "properties": { + "chain": { + "type": "string", + "minLength": 1, + "description": "CAIP-2 chain identifier for this input (e.g., \"eip155:1\"). Applies to both user and asset.", + "example": "eip155:1" + }, + "user": { + "type": "string", + "minLength": 1, + "description": "Native address of the user providing the input assets", + "example": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb8" + }, + "asset": { + "type": "string", + "minLength": 1, + "description": "Native address of the token/asset being provided as input", + "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + "amount": { + "description": "Amount available. For exact-input: exact amount user will provide and is used for quoting. For exact-output: ignored by the quote decoder and not used for quoting", + "example": "4000000000", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lock": { + "description": "Optional lock reference for securing the input assets. Shape: { kind: \"the-compact\" | \"rhinestone\", params: object }. Currently ignored by the quote decoder.", + "type": "object" + } + }, + "required": [ + "chain", + "user", + "asset" + ] + }, + "OutputDto": { + "type": "object", + "properties": { + "chain": { + "type": "string", + "minLength": 1, + "description": "CAIP-2 chain identifier for this output (e.g., \"eip155:1\"). Applies to both receiver and asset.", + "example": "eip155:1" + }, + "receiver": { + "type": "string", + "minLength": 1, + "description": "Native address that will receive the output assets", + "example": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb8" + }, + "asset": { + "type": "string", + "minLength": 1, + "description": "Native address of the token/asset to be received as output", + "example": "0xdAC17F958D2ee523a2206206994597C13D831ec7" + }, + "amount": { + "description": "For exact-input: ignored by the quote decoder and not used for quoting. For exact-output: exact amount user wants to receive and is used for quoting", + "example": "2000000000000000000", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "calldata": { + "description": "Optional calldata describing how the receiver will consume the output. Enables composability with other protocols", + "example": "0x095ea7b3...", + "type": "string" + } + }, + "required": [ + "chain", + "receiver", + "asset" + ] + }, + "QuotePreviewDto": { + "type": "object", + "properties": { + "inputs": { + "description": "Inputs for the preview", + "type": "array", + "items": { + "$ref": "#/components/schemas/InputDto" + } + }, + "outputs": { + "description": "Outputs for the preview", + "type": "array", + "items": { + "$ref": "#/components/schemas/OutputDto" + } + } + }, + "required": [ + "inputs", + "outputs" + ] + }, + "QuoteMetadataDto": { + "type": "object", + "properties": { + "exclusiveFor": { + "type": [ + "string", + "null" + ], + "description": "Exclusive for address (hex32) - solver address that can fill this quote, or null", + "example": "0x1234567890123456789012345678901234567890" + } + }, + "required": [ + "exclusiveFor" + ] + }, + "QuoteDto": { + "type": "object", + "properties": { + "order": { + "description": "Order details", + "oneOf": [ + { + "$ref": "#/components/schemas/OifUserOpenIntentOrderDto" + }, + { + "$ref": "#/components/schemas/OifEscrowOrderDto" + }, + { + "$ref": "#/components/schemas/Oif3009OrderDto" + } + ] + }, + "validUntil": { + "type": "number", + "description": "Quote validity timestamp in unix timestamp (seconds)", + "example": 1900000000 + }, + "eta": { + "type": "number", + "description": "Estimated time of arrival in seconds", + "example": 8 + }, + "quoteId": { + "type": "string", + "description": "Unique quote identifier", + "example": "quote-123-abc" + }, + "provider": { + "type": "string", + "description": "Provider identifier", + "enum": [ + "LI.FI Intent" + ], + "example": "LI.FI Intent" + }, + "preview": { + "description": "Informational amounts for UX/display, must be verified against the order", + "allOf": [ + { + "$ref": "#/components/schemas/QuotePreviewDto" + } + ] + }, + "failureHandling": { + "type": "string", + "description": "Failure handling policy for execution", + "enum": [ + "refund-automatic" + ], + "example": "refund-automatic" + }, + "partialFill": { + "type": "boolean", + "description": "Whether the quote supports partial fills", + "example": false + }, + "metadata": { + "description": "Metadata for the order, potentially contains provider specific data", + "allOf": [ + { + "$ref": "#/components/schemas/QuoteMetadataDto" + } + ] + } + }, + "required": [ + "order", + "quoteId", + "provider", + "preview", + "failureHandling", + "partialFill", + "metadata" + ] + }, + "QuoteResponseDto": { + "type": "object", + "properties": { + "quotes": { + "description": "Array of generated quotes. List of available quotes, may be empty if no quotes are available", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuoteDto" + } + } + }, + "required": [ + "quotes" + ] + }, + "SubmitOrderDto": { + "type": "object", + "properties": { + "orderType": { + "default": "CatalystCompactOrder", + "description": "The type of the order", + "type": "string", + "enum": [ + "CatalystCompactOrder" + ] + }, + "order": { + "type": "object", + "properties": { + "user": { + "description": "User address on source chain (initiator of the intent)", + "example": "0x742d35cc6634c0532925a3b8d0c0e1c4c5c5c5c5", + "type": "string" + }, + "nonce": { + "description": "Nonce value of the intent", + "type": "string" + }, + "originChainId": { + "description": "Origin chain ID (network id)", + "type": "string" + }, + "fillDeadline": { "description": "Fill deadline of the intent in seconds", "type": "string" }, @@ -3841,21 +4385,35 @@ "type": "string" }, "callbackData": { - "type": "string", "description": "The remote call data", - "nullable": true + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] }, "context": { - "type": "string", "description": "The fulfillment context", - "nullable": true + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ "oracle", "settler", "token", - "recipient" + "amount", + "recipient", + "chainId" ] }, "description": "Array of output objects" @@ -3863,6 +4421,10 @@ }, "required": [ "user", + "nonce", + "originChainId", + "fillDeadline", + "expires", "inputOracle", "inputs", "outputs" @@ -3876,7 +4438,7 @@ }, "inputSettler": { "description": "Input settler address on source chain. Used to determine the type of order [escrow, compact] (mandatory for all orders)", - "example": "0x", + "example": "0x00000000000000447f2a2544c4c1d8c0e2a3b1c9", "type": "string" }, "sponsorSignature": { @@ -4002,6 +4564,127 @@ "outputs" ] }, + "SubmittedOrderQuoteDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Quote ID", + "example": "51" + }, + "createdAt": { + "type": "string", + "description": "Quote creation timestamp", + "example": "2025-10-16T11:51:59.426Z" + }, + "updatedAt": { + "type": "string", + "description": "Quote last update timestamp", + "example": "2025-10-16T11:57:14.716Z" + }, + "quoteId": { + "type": "string", + "description": "Unique quote identifier", + "example": "quote_kQAD6-AIP5AdHKTwPUlz-Ha6VYN31n" + }, + "fromChainNetworkId": { + "type": "string", + "description": "Source chain network ID", + "example": "84532" + }, + "toChainNetworkId": { + "type": "string", + "description": "Destination chain network ID", + "example": "11155111" + }, + "fromAssetAddress": { + "type": "string", + "description": "Source asset address", + "example": "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + }, + "toAssetAddress": { + "type": "string", + "description": "Destination asset address", + "example": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" + }, + "fromAssetDecimals": { + "type": "number", + "description": "Source asset decimals", + "example": 6 + }, + "toAssetDecimals": { + "type": "number", + "description": "Destination asset decimals", + "example": 6 + }, + "quote": { + "type": "string", + "description": "Quote rate", + "example": "0.98548764945417963" + }, + "inputAmount": { + "type": "string", + "description": "Input amount", + "example": "30380900" + }, + "outputAmount": { + "type": "string", + "description": "Output amount", + "example": "29940002" + }, + "expiry": { + "type": "string", + "description": "Quote expiry timestamp", + "example": "2026-05-05T00:44:36.000Z" + }, + "exclusiveFor": { + "type": [ + "string", + "null" + ], + "description": "Exclusive for address", + "example": "0x841F63697cFa0e3B54c4D42b3d679F07F7F2485f" + }, + "user": { + "type": "string", + "description": "Quote owner address", + "example": "0x9773DAcbc46CAFb4e055060565e319922B48607D" + }, + "orderId": { + "type": [ + "number", + "null" + ], + "description": "Associated order ID", + "example": 13 + }, + "solverId": { + "type": "number", + "description": "Solver ID", + "example": 1 + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "quoteId", + "fromChainNetworkId", + "toChainNetworkId", + "fromAssetAddress", + "toAssetAddress", + "fromAssetDecimals", + "toAssetDecimals", + "quote", + "inputAmount", + "outputAmount", + "expiry", + "exclusiveFor", + "user", + "orderId", + "solverId" + ] + }, "OrderMetaDto": { "type": "object", "properties": { @@ -4039,82 +4722,108 @@ "example": "0x742d35Cc6634C0532925a3b8D0C0E1C4C5C5C5C5" }, "orderInitiatedTxHash": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Transaction hash when order was initiated (on-chain order) [eg: Open escrow event]", - "example": "0x1234567890abcdef1234567890abcdef12345678", - "nullable": true + "example": "0x1234567890abcdef1234567890abcdef12345678" }, "orderDeliveredTxHash": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Transaction hash of the OutputFilled event", - "example": "0x1234567890abcdef1234567890abcdef12345678", - "nullable": true + "example": "0x1234567890abcdef1234567890abcdef12345678" }, "orderVerifiedTxHash": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Transaction hash of the OutputProven event", - "example": "0x1234567890abcdef1234567890abcdef12345678", - "nullable": true + "example": "0x1234567890abcdef1234567890abcdef12345678" }, "orderSettledTxHash": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Transaction hash of the Finalised event", - "example": "0x1234567890abcdef1234567890abcdef12345678", - "nullable": true + "example": "0x1234567890abcdef1234567890abcdef12345678" }, "refundTxHash": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Transaction hash of the Refunded event", - "example": "0x1234567890abcdef1234567890abcdef12345678", - "nullable": true + "example": "0x1234567890abcdef1234567890abcdef12345678" }, "signedAt": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Date when the order was signed", - "example": "2024-01-01T00:00:00.000Z", - "nullable": true + "example": "2024-01-01T00:00:00.000Z" }, "expiredAt": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Date when the order expires", - "example": "2024-01-02T00:00:00.000Z", - "nullable": true + "example": "2024-01-02T00:00:00.000Z" }, "deliveredAt": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Date when the order was delivered", - "example": "2024-01-01T12:00:00.000Z", - "nullable": true + "example": "2024-01-01T12:00:00.000Z" }, "settledAt": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Date when the order was settled", - "example": "2024-01-01T18:00:00.000Z", - "nullable": true + "example": "2024-01-01T18:00:00.000Z" }, "refundedAt": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Date when the order was refunded", - "example": "2024-01-01T18:00:00.000Z", - "nullable": true + "example": "2024-01-01T18:00:00.000Z" }, "lastCompactDepositBlockNumber": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Last compact deposit block number", - "example": "12345678", - "nullable": true + "example": "12345678" }, "quoteId": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Quote ID associated with the order", - "example": "quote-123456", - "nullable": true + "example": "quote-123456" }, "solverAddress": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Solver address that filled the order", - "example": "0x742d35Cc6634C0532925a3b8D0C0E1C4C5C5C5C5", - "nullable": true + "example": "0x742d35Cc6634C0532925a3b8D0C0E1C4C5C5C5C5" }, "integratorKeyHash": { "type": "string", @@ -4155,25 +4864,34 @@ }, "quote": { "description": "The quote details", - "nullable": true, - "type": "object", - "allOf": [ + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/SubmittedOrderQuoteDto" + } + ] + }, { - "$ref": "#/components/schemas/QuoteResponseDto" + "type": "null" } ] }, "sponsorSignature": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Sponsor signature", - "example": null, - "nullable": true + "example": null }, "allocatorSignature": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Allocator signature", - "example": null, - "nullable": true + "example": null }, "inputSettler": { "type": "string", @@ -4379,34 +5097,44 @@ "example": "8965673" }, "exclusiveFor": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Exclusive for address", - "example": "0x841F63697cFa0e3B54c4D42b3d679F07F7F2485f", - "nullable": true + "example": "0x841F63697cFa0e3B54c4D42b3d679F07F7F2485f" }, "fromAssetRecordId": { - "type": "object", + "type": [ + "number", + "null" + ], "description": "Source asset record ID", - "example": 7, - "nullable": true + "example": 7 }, "toAssetRecordId": { - "type": "object", + "type": [ + "number", + "null" + ], "description": "Destination asset record ID", - "example": 8, - "nullable": true + "example": 8 }, "fromChainRecordId": { - "type": "object", + "type": [ + "number", + "null" + ], "description": "Source chain record ID", - "example": 11, - "nullable": true + "example": 11 }, "toChainRecordId": { - "type": "object", + "type": [ + "number", + "null" + ], "description": "Destination chain record ID", - "example": 12, - "nullable": true + "example": 12 }, "solverId": { "type": "number", @@ -4414,10 +5142,12 @@ "example": 2 }, "integratorKeyHash": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Integrator key hash this quote is tagged for, or null for open-market quotes", - "example": "a1b2c3d4e5f6...", - "nullable": true + "example": "a1b2c3d4e5f6..." } }, "required": [ @@ -4824,16 +5554,20 @@ "type": "object", "properties": { "symbol": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Token symbol (null if token not registered in system)", - "example": "USDC", - "nullable": true + "example": "USDC" }, "name": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Token name (null if token not registered in system)", - "example": "USDC", - "nullable": true + "example": "USDC" }, "address": { "type": "string", @@ -4892,28 +5626,36 @@ "example": 0 }, "fromChainRecordId": { - "type": "object", + "type": [ + "number", + "null" + ], "description": "Source chain record ID", - "example": 3, - "nullable": true + "example": 3 }, "toChainRecordId": { - "type": "object", + "type": [ + "number", + "null" + ], "description": "Destination chain record ID", - "example": 3, - "nullable": true + "example": 3 }, "fromTokenId": { - "type": "object", + "type": [ + "number", + "null" + ], "description": "Source token record ID", - "example": 5, - "nullable": true + "example": 5 }, "toTokenId": { - "type": "object", + "type": [ + "number", + "null" + ], "description": "Destination token record ID", - "example": 6, - "nullable": true + "example": 6 }, "isActive": { "type": "boolean", @@ -4922,21 +5664,31 @@ }, "fromChain": { "description": "Source chain information", - "nullable": true, - "type": "object", - "allOf": [ + "anyOf": [ { - "$ref": "#/components/schemas/RouteChainInfoDto" + "allOf": [ + { + "$ref": "#/components/schemas/RouteChainInfoDto" + } + ] + }, + { + "type": "null" } ] }, "toChain": { "description": "Destination chain information", - "nullable": true, - "type": "object", - "allOf": [ + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/RouteChainInfoDto" + } + ] + }, { - "$ref": "#/components/schemas/RouteChainInfoDto" + "type": "null" } ] }, @@ -5000,10 +5752,12 @@ "example": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" }, "symbol": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Token symbol (null if token not registered in system)", - "example": "USDC", - "nullable": true + "example": "USDC" }, "decimals": { "type": "number", @@ -5011,10 +5765,12 @@ "example": 6 }, "name": { - "type": "object", + "type": [ + "string", + "null" + ], "description": "Token name (null if token not registered in system)", - "example": "USDC", - "nullable": true + "example": "USDC" } }, "required": [ diff --git a/openapi/uniswapx-service.openapi.json b/openapi/uniswapx-service.openapi.json new file mode 100644 index 00000000..862844e4 --- /dev/null +++ b/openapi/uniswapx-service.openapi.json @@ -0,0 +1,1320 @@ +{ + "openapi": "3.0.0", + "servers": [ + { + "description": "UniswapX APIs", + "url": "https://api.uniswap.org/v2" + } + ], + "info": { + "version": "2.0.0", + "title": "UniswapX", + "description": "REST API for retrieving signed UniswapX orders. Dutch (V1/V2/V3), Priority, Hybrid, and Relay orders are served by /orders; limit orders are served by /limit-orders. Order submission is handled by the Uniswap Trading API and is not part of this specification." + }, + "paths": { + "/orders": { + "get": { + "tags": [ + "Orders" + ], + "summary": "Retrieve UniswapX orders", + "description": "Retrieve orders filtered by query parameter(s). At least one of `orderHash`, `orderHashes`, `chainId`, `orderStatus`, `swapper`, `filler`, or `pair` must be provided. Not supported in combination: `swapper` with `chainId`; `orderHashes` with `sortKey`. `sortKey` is required whenever `sort` or `desc` is provided. The shape of each entry in `orders` depends on the order's type; filter with `orderType` to receive a single shape.", + "parameters": [ + { + "$ref": "#/components/parameters/limitParam" + }, + { + "$ref": "#/components/parameters/orderStatusParam" + }, + { + "$ref": "#/components/parameters/orderHashParam" + }, + { + "$ref": "#/components/parameters/orderHashesParam" + }, + { + "$ref": "#/components/parameters/swapperParam" + }, + { + "$ref": "#/components/parameters/fillerParam" + }, + { + "$ref": "#/components/parameters/executeAddressParam" + }, + { + "$ref": "#/components/parameters/orderTypeParam" + }, + { + "$ref": "#/components/parameters/pairParam" + }, + { + "$ref": "#/components/parameters/sortKeyParam" + }, + { + "$ref": "#/components/parameters/sortParam" + }, + { + "$ref": "#/components/parameters/descParam" + }, + { + "$ref": "#/components/parameters/cursorParam" + }, + { + "$ref": "#/components/parameters/chainIdParam" + } + ], + "responses": { + "200": { + "description": "Request Successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrdersResponse" + } + } + } + }, + "400": { + "description": "Invalid query parameters.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errorCode": "VALIDATION_ERROR", + "detail": "\"value\" must contain at least one of [orderHash, orderHashes, chainId, orderStatus, swapper, filler, pair]" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errorCode": "TOO_MANY_REQUESTS" + } + } + } + }, + "500": { + "description": "Internal error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errorCode": "INTERNAL_ERROR", + "detail": "Unexpected error" + } + } + } + } + } + } + }, + "/limit-orders": { + "get": { + "tags": [ + "Limit Orders" + ], + "summary": "Retrieve UniswapX limit orders", + "description": "Retrieve limit orders filtered by query parameter(s). Query semantics are identical to /orders. Limit orders are returned with the Dutch order shape without decay (input and output startAmount equals endAmount).", + "parameters": [ + { + "$ref": "#/components/parameters/limitParam" + }, + { + "$ref": "#/components/parameters/orderStatusParam" + }, + { + "$ref": "#/components/parameters/orderHashParam" + }, + { + "$ref": "#/components/parameters/orderHashesParam" + }, + { + "$ref": "#/components/parameters/swapperParam" + }, + { + "$ref": "#/components/parameters/fillerParam" + }, + { + "$ref": "#/components/parameters/executeAddressParam" + }, + { + "$ref": "#/components/parameters/orderTypeParam" + }, + { + "$ref": "#/components/parameters/pairParam" + }, + { + "$ref": "#/components/parameters/sortKeyParam" + }, + { + "$ref": "#/components/parameters/sortParam" + }, + { + "$ref": "#/components/parameters/descParam" + }, + { + "$ref": "#/components/parameters/cursorParam" + }, + { + "$ref": "#/components/parameters/chainIdParam" + } + ], + "responses": { + "200": { + "description": "Request Successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrdersResponse" + } + } + } + }, + "400": { + "description": "Invalid query parameters.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errorCode": "VALIDATION_ERROR", + "detail": "\"value\" must contain at least one of [orderHash, orderHashes, chainId, orderStatus, swapper, filler, pair]" + } + } + } + }, + "429": { + "description": "Rate limited.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errorCode": "TOO_MANY_REQUESTS" + } + } + } + }, + "500": { + "description": "Internal error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errorCode": "INTERNAL_ERROR", + "detail": "Unexpected error" + } + } + } + } + } + } + } + }, + "components": { + "parameters": { + "chainIdParam": { + "name": "chainId", + "in": "query", + "description": "Filter by chain id. Cannot be combined with swapper.", + "required": false, + "schema": { + "$ref": "#/components/schemas/ChainId" + } + }, + "limitParam": { + "name": "limit", + "in": "query", + "description": "Maximum number of orders to return.", + "required": false, + "schema": { + "type": "number" + } + }, + "orderStatusParam": { + "name": "orderStatus", + "in": "query", + "description": "Filter by order status. A comma-separated list of statuses is also accepted.", + "required": false, + "schema": { + "$ref": "#/components/schemas/OrderStatus" + } + }, + "orderHashParam": { + "name": "orderHash", + "in": "query", + "description": "Filter by order hash.", + "required": false, + "schema": { + "$ref": "#/components/schemas/OrderHash" + } + }, + "orderHashesParam": { + "name": "orderHashes", + "in": "query", + "description": "Filter by comma-separated order hashes (maximum 50). Cannot be combined with sortKey.", + "required": false, + "schema": { + "type": "string", + "pattern": "^(?=([^,]*,){0,49}[^,]*$)0x[0-9a-zA-Z]{64}(,0x[0-9a-zA-Z]{64})*$", + "example": "orderHash,orderHash,orderHash" + } + }, + "swapperParam": { + "name": "swapper", + "in": "query", + "description": "Filter by swapper address. Cannot be combined with chainId.", + "required": false, + "schema": { + "type": "string", + "pattern": "^(0x)?[0-9a-fA-F]{40}$", + "example": "0x50EC05ADe8280758E2077fcBC08D878D4aef79C3" + } + }, + "fillerParam": { + "name": "filler", + "in": "query", + "description": "Filter by filler address.", + "required": false, + "schema": { + "type": "string", + "pattern": "^(0x)?[0-9a-fA-F]{40}$", + "example": "0x50EC05ADe8280758E2077fcBC08D878D4aef79C3" + } + }, + "executeAddressParam": { + "name": "executeAddress", + "in": "query", + "description": "Filter by execution address.", + "required": false, + "schema": { + "type": "string", + "pattern": "^(0x)?[0-9a-fA-F]{40}$", + "example": "0x50EC05ADe8280758E2077fcBC08D878D4aef79C3" + } + }, + "orderTypeParam": { + "name": "orderType", + "in": "query", + "description": "Filter by order type. Determines the entity shape of the returned orders.", + "required": false, + "schema": { + "$ref": "#/components/schemas/OrderTypeQuery" + } + }, + "pairParam": { + "name": "pair", + "in": "query", + "description": "Filter by token pair, formatted as `--`.", + "required": false, + "schema": { + "type": "string" + } + }, + "sortKeyParam": { + "name": "sortKey", + "in": "query", + "description": "Order the query results by the sort key. Required when sort or desc is provided.", + "required": false, + "schema": { + "$ref": "#/components/schemas/SortKey" + } + }, + "sortParam": { + "name": "sort", + "in": "query", + "description": "Sort query. For example: `sort=gt(UNIX_TIMESTAMP)`, `sort=between(1675872827, 1675872930)`, or `lt(1675872930)`.", + "required": false, + "schema": { + "type": "string" + } + }, + "descParam": { + "name": "desc", + "in": "query", + "description": "Boolean to sort query results by descending sort key.", + "required": false, + "schema": { + "type": "boolean" + } + }, + "cursorParam": { + "name": "cursor", + "in": "query", + "description": "Cursor param to page through results. This will be returned in the previous query if the results have been paginated.", + "required": false, + "schema": { + "type": "string" + } + } + }, + "schemas": { + "ChainId": { + "type": "number", + "description": "Chains supported by UniswapX.", + "enum": [ + 1, + 10, + 56, + 130, + 137, + 143, + 196, + 480, + 1301, + 1868, + 4217, + 4663, + 5042, + 8453, + 42161, + 42220, + 43114, + 81457, + 7777777, + 11155111 + ], + "example": 1 + }, + "OrderStatus": { + "type": "string", + "enum": [ + "open", + "expired", + "error", + "cancelled", + "filled", + "insufficient-funds" + ] + }, + "OrderTypeQuery": { + "type": "string", + "description": "Order type accepted by the orderType query parameter. Dutch_V1_V2 returns both Dutch V1 and Dutch V2 orders.", + "enum": [ + "Dutch", + "Dutch_V2", + "Dutch_V3", + "Limit", + "Relay", + "Dutch_V1_V2", + "Priority", + "Hybrid" + ] + }, + "SortKey": { + "type": "string", + "enum": [ + "createdAt" + ] + }, + "OrderHash": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$" + }, + "TxHash": { + "type": "string", + "description": "Transaction hash of the fill. Defined once the order has been filled.", + "pattern": "^0x[0-9a-fA-F]{64}$" + }, + "Address": { + "type": "string", + "description": "EIP-55 checksummed Ethereum address.", + "example": "0x50EC05ADe8280758E2077fcBC08D878D4aef79C3" + }, + "Amount": { + "type": "string", + "description": "uint256 encoded as a base-10 string.", + "pattern": "^[0-9]{1,78}$" + }, + "CreatedAt": { + "type": "number", + "description": "Unix timestamp (seconds) at which the order was recorded." + }, + "QuoteId": { + "type": "string", + "format": "uuid", + "description": "Defined when the order has a quote associated with it." + }, + "RequestId": { + "type": "string", + "format": "uuid", + "description": "Defined when the order has a quote request associated with it." + }, + "Nonce": { + "type": "string", + "description": "Permit2 nonce, uint256 encoded as a base-10 string.", + "pattern": "^[0-9]{1,78}$" + }, + "EncodedOrder": { + "type": "string", + "description": "ABI-encoded order struct. Decode with the uniswapx-sdk parser for the order's type.", + "pattern": "^0x[0-9a-fA-F]*$" + }, + "Signature": { + "type": "string", + "description": "EIP-712 signature over the order.", + "pattern": "^0x[0-9a-fA-F]{130}$" + }, + "SettledAmount": { + "type": "object", + "description": "Defined when the order has been filled and the fill amounts have been recorded.", + "properties": { + "tokenOut": { + "type": "string", + "pattern": "^(0x)?[0-9a-fA-F]{40}$" + }, + "amountOut": { + "$ref": "#/components/schemas/Amount" + }, + "tokenIn": { + "type": "string", + "pattern": "^(0x)?[0-9a-fA-F]{40}$" + }, + "amountIn": { + "$ref": "#/components/schemas/Amount" + } + } + }, + "Route": { + "type": "object", + "description": "Classic-route quote metadata associated with the order's quote.", + "properties": { + "quote": { + "$ref": "#/components/schemas/Amount" + }, + "quoteGasAdjusted": { + "$ref": "#/components/schemas/Amount" + }, + "gasPriceWei": { + "$ref": "#/components/schemas/Amount" + }, + "gasUseEstimateQuote": { + "$ref": "#/components/schemas/Amount" + }, + "gasUseEstimate": { + "$ref": "#/components/schemas/Amount" + }, + "methodParameters": { + "type": "object", + "properties": { + "calldata": { + "type": "string" + }, + "value": { + "type": "string" + }, + "to": { + "$ref": "#/components/schemas/Address" + } + } + } + } + }, + "NonlinearDutchDecayCurve": { + "type": "object", + "description": "Piecewise decay curve relative to the decay start block.", + "properties": { + "relativeBlocks": { + "type": "array", + "items": { + "type": "number" + } + }, + "relativeAmounts": { + "type": "array", + "items": { + "type": "string", + "description": "int256 encoded as a base-10 string.", + "pattern": "^-?[0-9]{1,78}$" + } + } + } + }, + "OrderInput": { + "type": "object", + "properties": { + "token": { + "$ref": "#/components/schemas/Address" + }, + "startAmount": { + "$ref": "#/components/schemas/Amount" + }, + "endAmount": { + "$ref": "#/components/schemas/Amount" + } + }, + "required": [ + "token" + ] + }, + "OrderOutput": { + "type": "object", + "properties": { + "token": { + "$ref": "#/components/schemas/Address" + }, + "startAmount": { + "$ref": "#/components/schemas/Amount" + }, + "endAmount": { + "$ref": "#/components/schemas/Amount" + }, + "recipient": { + "$ref": "#/components/schemas/Address" + } + }, + "required": [ + "token", + "startAmount", + "endAmount", + "recipient" + ] + }, + "DutchOrderEntity": { + "type": "object", + "description": "Dutch V1 and Limit orders. Legacy DutchLimit entries share this shape.", + "properties": { + "type": { + "type": "string", + "enum": [ + "Dutch", + "DutchLimit", + "Limit" + ] + }, + "encodedOrder": { + "$ref": "#/components/schemas/EncodedOrder" + }, + "signature": { + "$ref": "#/components/schemas/Signature" + }, + "nonce": { + "$ref": "#/components/schemas/Nonce" + }, + "orderHash": { + "$ref": "#/components/schemas/OrderHash" + }, + "orderStatus": { + "$ref": "#/components/schemas/OrderStatus" + }, + "chainId": { + "$ref": "#/components/schemas/ChainId" + }, + "swapper": { + "$ref": "#/components/schemas/Address" + }, + "input": { + "$ref": "#/components/schemas/OrderInput" + }, + "outputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrderOutput" + } + }, + "createdAt": { + "$ref": "#/components/schemas/CreatedAt" + }, + "quoteId": { + "$ref": "#/components/schemas/QuoteId" + }, + "requestId": { + "$ref": "#/components/schemas/RequestId" + }, + "txHash": { + "$ref": "#/components/schemas/TxHash" + }, + "settledAmounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SettledAmount" + } + } + } + }, + "DutchV2OrderEntity": { + "type": "object", + "description": "Dutch V2 orders: cosigned Dutch auctions with time-based decay.", + "properties": { + "type": { + "type": "string", + "enum": [ + "Dutch_V2" + ] + }, + "encodedOrder": { + "$ref": "#/components/schemas/EncodedOrder" + }, + "signature": { + "$ref": "#/components/schemas/Signature" + }, + "nonce": { + "$ref": "#/components/schemas/Nonce" + }, + "orderHash": { + "$ref": "#/components/schemas/OrderHash" + }, + "orderStatus": { + "$ref": "#/components/schemas/OrderStatus" + }, + "chainId": { + "$ref": "#/components/schemas/ChainId" + }, + "swapper": { + "$ref": "#/components/schemas/Address" + }, + "input": { + "type": "object", + "properties": { + "token": { + "$ref": "#/components/schemas/Address" + }, + "startAmount": { + "$ref": "#/components/schemas/Amount" + }, + "endAmount": { + "$ref": "#/components/schemas/Amount" + } + }, + "required": [ + "token", + "startAmount", + "endAmount" + ] + }, + "outputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrderOutput" + } + }, + "cosignerData": { + "type": "object", + "properties": { + "decayStartTime": { + "type": "number" + }, + "decayEndTime": { + "type": "number" + }, + "exclusiveFiller": { + "$ref": "#/components/schemas/Address" + }, + "inputOverride": { + "$ref": "#/components/schemas/Amount" + }, + "outputOverrides": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Amount" + } + } + } + }, + "cosignature": { + "type": "string" + }, + "createdAt": { + "$ref": "#/components/schemas/CreatedAt" + }, + "quoteId": { + "$ref": "#/components/schemas/QuoteId" + }, + "requestId": { + "$ref": "#/components/schemas/RequestId" + }, + "txHash": { + "$ref": "#/components/schemas/TxHash" + }, + "settledAmounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SettledAmount" + } + }, + "route": { + "$ref": "#/components/schemas/Route" + } + }, + "required": [ + "type", + "encodedOrder", + "signature", + "orderHash", + "orderStatus", + "chainId", + "swapper" + ] + }, + "DutchV3OrderEntity": { + "type": "object", + "description": "Dutch V3 orders: cosigned Dutch auctions with block-based nonlinear decay, used on fast chains.", + "properties": { + "type": { + "type": "string", + "enum": [ + "Dutch_V3" + ] + }, + "encodedOrder": { + "$ref": "#/components/schemas/EncodedOrder" + }, + "signature": { + "$ref": "#/components/schemas/Signature" + }, + "nonce": { + "$ref": "#/components/schemas/Nonce" + }, + "orderHash": { + "$ref": "#/components/schemas/OrderHash" + }, + "orderStatus": { + "$ref": "#/components/schemas/OrderStatus" + }, + "chainId": { + "$ref": "#/components/schemas/ChainId" + }, + "swapper": { + "$ref": "#/components/schemas/Address" + }, + "startingBaseFee": { + "$ref": "#/components/schemas/Amount" + }, + "input": { + "type": "object", + "properties": { + "token": { + "$ref": "#/components/schemas/Address" + }, + "startAmount": { + "$ref": "#/components/schemas/Amount" + }, + "curve": { + "$ref": "#/components/schemas/NonlinearDutchDecayCurve" + }, + "maxAmount": { + "$ref": "#/components/schemas/Amount" + }, + "adjustmentPerGweiBaseFee": { + "$ref": "#/components/schemas/Amount" + } + }, + "required": [ + "token", + "startAmount" + ] + }, + "outputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "token": { + "$ref": "#/components/schemas/Address" + }, + "startAmount": { + "$ref": "#/components/schemas/Amount" + }, + "curve": { + "$ref": "#/components/schemas/NonlinearDutchDecayCurve" + }, + "recipient": { + "$ref": "#/components/schemas/Address" + }, + "minAmount": { + "$ref": "#/components/schemas/Amount" + }, + "adjustmentPerGweiBaseFee": { + "$ref": "#/components/schemas/Amount" + } + }, + "required": [ + "token", + "startAmount", + "recipient" + ] + } + }, + "cosignerData": { + "type": "object", + "properties": { + "decayStartBlock": { + "type": "number" + }, + "exclusiveFiller": { + "$ref": "#/components/schemas/Address" + }, + "inputOverride": { + "$ref": "#/components/schemas/Amount" + }, + "outputOverrides": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Amount" + } + } + } + }, + "cosignature": { + "type": "string" + }, + "fillBlock": { + "type": "number", + "description": "Block in which the order was filled. Defined once the fill has been recorded." + }, + "createdAt": { + "$ref": "#/components/schemas/CreatedAt" + }, + "quoteId": { + "$ref": "#/components/schemas/QuoteId" + }, + "requestId": { + "$ref": "#/components/schemas/RequestId" + }, + "txHash": { + "$ref": "#/components/schemas/TxHash" + }, + "settledAmounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SettledAmount" + } + }, + "route": { + "$ref": "#/components/schemas/Route" + } + }, + "required": [ + "type", + "encodedOrder", + "signature", + "orderHash", + "orderStatus", + "chainId", + "swapper" + ] + }, + "PriorityOrderEntity": { + "type": "object", + "description": "Priority orders: amounts scale with the transaction's priority fee.", + "properties": { + "type": { + "type": "string", + "enum": [ + "Priority" + ] + }, + "encodedOrder": { + "$ref": "#/components/schemas/EncodedOrder" + }, + "signature": { + "$ref": "#/components/schemas/Signature" + }, + "nonce": { + "$ref": "#/components/schemas/Nonce" + }, + "orderHash": { + "$ref": "#/components/schemas/OrderHash" + }, + "orderStatus": { + "$ref": "#/components/schemas/OrderStatus" + }, + "chainId": { + "$ref": "#/components/schemas/ChainId" + }, + "swapper": { + "$ref": "#/components/schemas/Address" + }, + "auctionStartBlock": { + "type": "number" + }, + "baselinePriorityFeeWei": { + "$ref": "#/components/schemas/Amount" + }, + "input": { + "type": "object", + "properties": { + "token": { + "$ref": "#/components/schemas/Address" + }, + "amount": { + "$ref": "#/components/schemas/Amount" + }, + "mpsPerPriorityFeeWei": { + "$ref": "#/components/schemas/Amount" + } + }, + "required": [ + "token", + "amount", + "mpsPerPriorityFeeWei" + ] + }, + "outputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "token": { + "$ref": "#/components/schemas/Address" + }, + "amount": { + "$ref": "#/components/schemas/Amount" + }, + "mpsPerPriorityFeeWei": { + "$ref": "#/components/schemas/Amount" + }, + "recipient": { + "$ref": "#/components/schemas/Address" + } + }, + "required": [ + "token", + "amount", + "mpsPerPriorityFeeWei", + "recipient" + ] + } + }, + "cosignerData": { + "type": "object", + "properties": { + "auctionTargetBlock": { + "type": "number" + } + } + }, + "cosignature": { + "type": "string" + }, + "createdAt": { + "$ref": "#/components/schemas/CreatedAt" + }, + "quoteId": { + "$ref": "#/components/schemas/QuoteId" + }, + "requestId": { + "$ref": "#/components/schemas/RequestId" + }, + "txHash": { + "$ref": "#/components/schemas/TxHash" + }, + "settledAmounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SettledAmount" + } + }, + "route": { + "$ref": "#/components/schemas/Route" + } + }, + "required": [ + "type", + "encodedOrder", + "signature", + "orderHash", + "orderStatus", + "chainId", + "swapper" + ] + }, + "HybridOrderEntity": { + "type": "object", + "description": "Hybrid orders: support Dutch auction (price curve) or priority fee scaling mechanics, mutually exclusively.", + "properties": { + "type": { + "type": "string", + "enum": [ + "Hybrid" + ] + }, + "encodedOrder": { + "$ref": "#/components/schemas/EncodedOrder" + }, + "signature": { + "$ref": "#/components/schemas/Signature" + }, + "nonce": { + "$ref": "#/components/schemas/Nonce" + }, + "orderHash": { + "$ref": "#/components/schemas/OrderHash" + }, + "orderStatus": { + "$ref": "#/components/schemas/OrderStatus" + }, + "chainId": { + "$ref": "#/components/schemas/ChainId" + }, + "swapper": { + "$ref": "#/components/schemas/Address" + }, + "auctionStartBlock": { + "type": "number" + }, + "baselinePriorityFee": { + "$ref": "#/components/schemas/Amount" + }, + "scalingFactor": { + "$ref": "#/components/schemas/Amount" + }, + "priceCurve": { + "type": "array", + "description": "1e18-denominated multipliers; all elements are on the same side of 1e18. Empty for priority-style hybrid orders.", + "items": { + "$ref": "#/components/schemas/Amount" + } + }, + "input": { + "type": "object", + "properties": { + "token": { + "$ref": "#/components/schemas/Address" + }, + "maxAmount": { + "$ref": "#/components/schemas/Amount" + } + }, + "required": [ + "token", + "maxAmount" + ] + }, + "outputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "token": { + "$ref": "#/components/schemas/Address" + }, + "minAmount": { + "$ref": "#/components/schemas/Amount" + }, + "recipient": { + "$ref": "#/components/schemas/Address" + } + }, + "required": [ + "token", + "minAmount", + "recipient" + ] + } + }, + "cosigner": { + "$ref": "#/components/schemas/Address" + }, + "cosignerData": { + "type": "object", + "properties": { + "auctionTargetBlock": { + "type": "number" + }, + "supplementalPriceCurve": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Amount" + } + } + } + }, + "cosignature": { + "type": "string" + }, + "createdAt": { + "$ref": "#/components/schemas/CreatedAt" + }, + "quoteId": { + "$ref": "#/components/schemas/QuoteId" + }, + "requestId": { + "$ref": "#/components/schemas/RequestId" + }, + "txHash": { + "$ref": "#/components/schemas/TxHash" + }, + "settledAmounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SettledAmount" + } + }, + "route": { + "$ref": "#/components/schemas/Route" + } + }, + "required": [ + "type", + "encodedOrder", + "signature", + "orderHash", + "orderStatus", + "chainId", + "swapper" + ] + }, + "RelayOrderEntity": { + "type": "object", + "description": "Relay orders: gasless transaction relays paid via a decaying relay fee.", + "properties": { + "type": { + "type": "string", + "enum": [ + "Relay" + ] + }, + "encodedOrder": { + "$ref": "#/components/schemas/EncodedOrder" + }, + "signature": { + "$ref": "#/components/schemas/Signature" + }, + "orderHash": { + "$ref": "#/components/schemas/OrderHash" + }, + "orderStatus": { + "$ref": "#/components/schemas/OrderStatus" + }, + "chainId": { + "$ref": "#/components/schemas/ChainId" + }, + "swapper": { + "$ref": "#/components/schemas/Address" + }, + "input": { + "type": "object", + "properties": { + "token": { + "$ref": "#/components/schemas/Address" + }, + "amount": { + "$ref": "#/components/schemas/Amount" + }, + "recipient": { + "$ref": "#/components/schemas/Address" + } + } + }, + "relayFee": { + "type": "object", + "properties": { + "token": { + "$ref": "#/components/schemas/Address" + }, + "startAmount": { + "$ref": "#/components/schemas/Amount" + }, + "endAmount": { + "$ref": "#/components/schemas/Amount" + }, + "startTime": { + "type": "number" + }, + "endTime": { + "type": "number" + } + } + }, + "createdAt": { + "$ref": "#/components/schemas/CreatedAt" + }, + "txHash": { + "$ref": "#/components/schemas/TxHash" + }, + "settledAmounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SettledAmount" + } + } + }, + "required": [ + "type", + "encodedOrder", + "signature", + "orderHash", + "orderStatus", + "chainId", + "swapper" + ] + }, + "GetOrdersResponse": { + "type": "object", + "properties": { + "orders": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DutchOrderEntity" + }, + { + "$ref": "#/components/schemas/DutchV2OrderEntity" + }, + { + "$ref": "#/components/schemas/DutchV3OrderEntity" + }, + { + "$ref": "#/components/schemas/PriorityOrderEntity" + }, + { + "$ref": "#/components/schemas/HybridOrderEntity" + }, + { + "$ref": "#/components/schemas/RelayOrderEntity" + } + ] + } + }, + "cursor": { + "type": "string", + "description": "Defined when the results are paginated. Pass back via the cursor query parameter to fetch the next page." + } + } + }, + "ErrorCode": { + "type": "string", + "enum": [ + "ORDER_PARSE_FAIL", + "INVALID_ORDER", + "TOO_MANY_OPEN_ORDERS", + "INTERNAL_ERROR", + "VALIDATION_ERROR", + "TOO_MANY_REQUESTS", + "INVALID_TOKEN_IN_ADDRESS" + ] + }, + "ErrorResponse": { + "type": "object", + "properties": { + "errorCode": { + "$ref": "#/components/schemas/ErrorCode" + }, + "detail": { + "type": "string" + }, + "id": { + "type": "string", + "description": "Request id for correlating the error with service logs." + } + } + } + } + } +}