From 648cc2fb31437659e3bb2acee40cfb08a5faec26 Mon Sep 17 00:00:00 2001 From: Pedro Yves Fracari Date: Wed, 26 Aug 2026 18:29:14 -0300 Subject: [PATCH 1/2] docs: specify proposal supersession lifecycle (COW-1274) --- AGENTS.md | 5 ++++ design-document.md | 39 ++++++++++++++++++++++---------- glossary.md | 2 +- guides/sub-solver-integration.md | 5 ++-- service.md | 28 ++++++++++++++++------- 5 files changed, 56 insertions(+), 23 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f04fef4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# BYOS documentation + +## Issue tracker + +BYOS work is tracked in the **Bleu Builders Linear workspace**, project [BYOS](https://linear.app/bleu-builders/project/byos-28bb488dcf7c/overview). Resolve `COW-` tickets through Linear (for example, with the Linear `get_issue` tool); they are not GitHub issue numbers for this repository. diff --git a/design-document.md b/design-document.md index 2fc40f6..5548979 100644 --- a/design-document.md +++ b/design-document.md @@ -431,9 +431,11 @@ struct CancelProposal { } ``` -Proposals are immutable, so there is no update operation and no `PUT`. Replacement is a new `POST`, optionally preceded by a `DELETE`. +Proposals are immutable, so there is no update operation and no `PUT`. Replacement is always a new `POST`; it is not preceded by a `DELETE`. Once the newer proposal validates, it supersedes the caller's older `Submitted` and `Active` proposals for the same order. -**Ingestion is asynchronous.** The request path does three things inline: parse, `ecrecover`, and check the expiry window. On success the proposal is stored as `Submitted` and answered `202` — meaning "accepted for validation", not "accepted". Signature and expiry-window failures reject synchronously with a typed 4xx, since there is no point storing and auditing a proposal that is dead on arrival. All on-chain work, the escrow balance check and simulation, runs in a background validator loop ([`#proposal-lifecycle`](#proposal-lifecycle)). Sub-solvers poll for the verdict, and a rejection carries a machine-readable typed reason. +**Nonce uniqueness is global per sub-solver.** The recovered signer and nonce form the unique key: `(subSolver, nonce)`. A byte-for-byte equivalent signed proposal is idempotent: BYOS returns its original proposal id with `202 Accepted` and creates no row, audit event, or replacement. Reusing that nonce with any different signed content returns `409 NonceAlreadyUsed`. The signature already identifies the sub-solver, so there is no unsigned replacement-intent field. + +**Ingestion is asynchronous.** The request path does three things inline: parse, `ecrecover`, and check the expiry window. On first use of a nonce, the proposal is stored as `Submitted` and answered `202` — meaning "accepted for validation", not "accepted". Signature and expiry-window failures reject synchronously with a typed 4xx, since there is no point storing and auditing a proposal that is dead on arrival. All on-chain work, the escrow balance check and simulation, runs in a background validator loop ([`#proposal-lifecycle`](#proposal-lifecycle)). Sub-solvers poll for the verdict, and a rejection carries a machine-readable typed reason. That means a `2xx` from `POST` is not acceptance. Integration code that treats it as acceptance is wrong. Verdict latency is bounded by the validator tick interval, not by the request round-trip. @@ -473,13 +475,20 @@ stateDiagram-v2 Active --> SimFailed: re-simulation reverts Active --> Rejected: escrow re-check fails Active --> Executing: driver SettlementStarted + Submitted --> Superseded: newer proposal activates + Active --> Superseded: newer proposal activates + Superseded --> Executing: delayed driver SettlementStarted + Superseded --> Settled: delayed driver Success + Superseded --> SettleFailed: delayed driver Revert Submitted --> Expired: validUntil passed Active --> Expired: validUntil passed + Superseded --> Expired: validUntil passed Submitted --> Cancelled: DELETE /proposals/{id} Active --> Cancelled: DELETE /proposals/{id} Executing --> Settled: driver Success Executing --> SettleFailed: driver Revert - Executing --> Active: driver Cancelled/Expired/Fail,\nor executing timeout + Executing --> Active: driver Cancelled/Expired/Fail,\nor executing timeout (was Active) + Executing --> Superseded: driver Cancelled/Expired/Fail,\nor executing timeout (was Superseded) SettleFailed --> Penalized: Track A escrow debit lands Settled --> [*] Penalized --> [*] @@ -491,6 +500,7 @@ A state answers exactly one question: what does the service do with this proposa |---|---|---|---|---|---| | `Submitted` | first pass | no | yes | yes | live | | `Active` | every tick | yes | yes | yes | live | +| `Superseded` | no | no | yes | no | live | | `Executing` | no | no | no | no | live | | `Rejected` | no | no | — | no | 1 hour | | `SimFailed` | no | no | — | no | 1 hour | @@ -511,19 +521,22 @@ Every transition: | `Active` | `Active` | Re-validation tick refreshes the gas estimate. No status change, no audit event. | | `Active` | `Rejected` | Escrow re-check fails; the balance dropped below the threshold. | | `Active` | `SimFailed` | Re-simulation reverts: the order filled or expired on-chain, the route broke, balances moved. | -| `Submitted`, `Active` | `Expired` | `validUntil` is behind the clock. | +| `Submitted`, `Active` | `Superseded` | A newer proposal for the same `(subSolver, orderUid)` validates and activates. Records `supersededByProposalId`. | +| `Submitted`, `Active`, `Superseded` | `Expired` | `validUntil` is behind the clock. | | `Submitted`, `Active` | `Cancelled` | Signed `DELETE` by the owner. `DELETE` against any other state is a 409. | -| `Active` | `Executing` | Driver `SettlementStarted`: our solution won and the transaction is being submitted. | +| `Active`, `Superseded` | `Executing` | Driver `SettlementStarted`: our solution won and the transaction is being submitted. This accepts delayed notifications for a proposal returned before it was superseded. | | `Executing` | `Settled` | Driver `Success`. Transaction hash recorded. | | `Executing` | `SettleFailed` | Driver `Revert`. Transaction hash recorded; the Track A debit follows. | -| `Executing` | `Active` | Driver `Cancelled`, `Expired`, or `Fail` (submission abandoned, no transaction landed), or the executing timeout elapsed. Queues the non-settlement debit. | +| `Executing` | `Active`, `Superseded` | Driver `Cancelled`, `Expired`, or `Fail` (submission abandoned, no transaction landed), or the executing timeout elapsed. Returns to its pre-execution live state, recorded so a superseded proposal remains superseded; queues the non-settlement debit. | | `SettleFailed` | `Penalized` | The Track A escrow debit lands on-chain. Penalty transaction hash recorded. | **Losing an auction is not a state.** A proposal outscored internally, or whose solution lost the external competition, is still valid and keeps competing. Participation is recorded as data, so "which auctions did this compete in and lose" is a query, not a status. Winning *is* a state change, because it changes what the service does: it must stop offering the proposal and stop re-simulating it. -`Executing` is entered on `SettlementStarted`, not at `/solve` time, because at `/solve` time we do not yet know we won. It is exempt from the expiry sweep on purpose — the chain enforces the order's real deadline. Two safety properties make the state recoverable: `Executing` to `Active` is always safe, because if the order was actually consumed the next re-simulation reverts and the proposal dies; and an executing timeout returns a stuck proposal to `Active`, covering lost notifications and restarts mid-settlement. Re-simulation is the truth-teller. +**Supersession is an activation-time, atomic group transition.** A successfully validated proposal becomes `Active` only if it is still the newest eligible submission for its `(subSolver, orderUid)` group. In that same serialized transaction, it changes every older `Submitted` or `Active` proposal in the group to `Superseded` and records its id as `supersededByProposalId`; `Executing` proposals are unchanged. This ordering prevents an older validator job from replacing or reactivating a newer proposal. Superseded proposals are neither simulated nor returned by `/solve`, but still expire normally. + +`Executing` is entered on `SettlementStarted`, not at `/solve` time, because at `/solve` time we do not yet know we won. A notification can arrive after its proposal was superseded, so `Superseded` may also enter `Executing`. It is exempt from the expiry sweep on purpose — the chain enforces the order's real deadline. An abandoned execution returns to the proposal's prior live state: `Active` or `Superseded`. Success and revert remain terminal. Re-simulation is the truth-teller for proposals that return to `Active`. -Transitions are compare-and-swap. Zero rows affected means the caller's verdict was stale, because a cancellation or a notification won the race. +Transitions are compare-and-swap. Zero rows affected means the caller's verdict was stale, because a cancellation, replacement, or notification won the race. **Terminal retention has one knob.** Rejected, sim-failed, expired, and cancelled rows are deleted an hour after reaching the state; consumers are polling loops that observe a terminal state within one interval, and after that the proposal is a 404. The money states — settled, settle-failed, penalized — are kept indefinitely, with no sweep code at all. `audit_events` has no deletion path. @@ -538,6 +551,7 @@ Every reason a proposal stops competing, grouped by when it happens: | Invalid signature | Malformed signature hex or recovery failure | | Proposal expired | `validUntil` is already in the past | | Lifetime exceeded | `validUntil` is more than 5 minutes in the future (configurable) | +| Nonce already used | This sub-solver used the nonce for different signed content; an exact signed replay is idempotent and returns its original id with `202`. | | Rate limited | IP or signer rate limit exceeded | | Insufficient escrow (floor gate) | The signer's cached balance is known to be below the minimum collateral. Applies to proposal submission only, so a sub-solver whose withdrawal is pending can still read and cancel what it has live. Read from cache, never RPC; an address BYOS has not seen before is admitted at the lowest rate tier instead | @@ -558,6 +572,7 @@ Every reason a proposal stops competing, grouped by when it happens: |---|---| | Expired | `validUntil` passed | | Cancelled | Sub-solver sent a signed `DELETE` | +| Superseded | A newer proposal for the same sub-solver and order activated | | Settled | The proposal won an auction and settled on-chain | | Settle failed | Settlement reverted on-chain — triggers a Track A penalty | @@ -569,10 +584,10 @@ Outcomes come from the stock CoW driver's `/notify` protocol. There is no chain | Notification | Effect | |---|---| -| `SettlementStarted` | `Active` to `Executing` | -| `Success { transaction }` | `Executing` to `Settled` | -| `Revert { transaction }` | `Executing` to `SettleFailed`; Track A trigger | -| `Cancelled`, `Expired`, `Fail` | `Executing` to `Active`; queues the non-settlement debit | +| `SettlementStarted` | `Active` or `Superseded` to `Executing` | +| `Success { transaction }` | `Executing` or `Superseded` to `Settled` | +| `Revert { transaction }` | `Executing` or `Superseded` to `SettleFailed`; Track A trigger | +| `Cancelled`, `Expired`, `Fail` | `Executing` to its pre-execution state (`Active` or `Superseded`); queues the non-settlement debit | | Pre-submission kinds | no transition; recorded as audit events | Notifications carry auction and solution ids, not proposals, so `/solve` records the `(auction_id, solution_id, proposal_id)` mapping synchronously before returning a solution — if it cannot be recorded, it is not bid. `/notify` joins through that mapping, which doubles as the per-auction participation record. The ids are optional on the wire, so the handler must tolerate notifications it cannot join, but an *outcome* notification that cannot be attributed is an alert-worthy bug. diff --git a/glossary.md b/glossary.md index 083f873..2b52665 100644 --- a/glossary.md +++ b/glossary.md @@ -28,7 +28,7 @@ The core economic framing. Everything about escrow, penalties, and gatekeeping f - **Sub-solver** — an external, permissionless party that computes a route for a specific order and submits a signed proposal to BYOS. Never holds submission keys; never calls `settle`. Identified by its address, recovered from its EIP-712 signature; that same address is its escrow key and its Trampoline CREATE2 salt. It is `sub_solver`, never plain `solver` — in CoW's vocabulary `solver` means BYOS itself. -- **Proposal** — an EIP-712-signed message authorizing BYOS to attempt a settlement of a specific route, and consenting to the associated escrow risk. Immutable: amounts (`sellAmount`, `minBuyAmount`, `quoteBuyAmount`), interactions, expiry, nonce, and signature form one signed unit, so there is no update operation. One proposal commits to exactly one order. Field-level definition is in [the design document](design-document#proposal-schema); the wire shape is in `byos-service`'s [`crates/byos/openapi.yml`](https://github.com/bleu/byos-service/blob/main/crates/byos/openapi.yml). +- **Proposal** — an EIP-712-signed message authorizing BYOS to attempt a settlement of a specific route, and consenting to the associated escrow risk. Immutable: amounts (`sellAmount`, `minBuyAmount`, `quoteBuyAmount`), interactions, expiry, nonce, and signature form one signed unit, so there is no update operation. One proposal commits to exactly one order. A nonce is unique per sub-solver: replaying identical signed content returns the original proposal, while different content with the same nonce is rejected. When a newer proposal activates for the same order, older submitted or active proposals become **Superseded** and stop competing. Field-level definition is in [the design document](design-document#proposal-schema); the wire shape is in `byos-service`'s [`crates/byos/openapi.yml`](https://github.com/bleu/byos-service/blob/main/crates/byos/openapi.yml). - **Trampoline** — the contract that receives a route's `sellAmount`, executes the sub-solver's interactions as itself, sweeps both trade tokens back to `GPv2Settlement`, and enforces `minBuyAmount` as a floor. Confines sub-solver code to a fund-less context so it cannot reach settlement buffers or plant an exploitable approval. One immutable instance per sub-solver, at a deterministic CREATE2 address, deployed at escrow-deposit time. See [the design document](design-document#trampoline). diff --git a/guides/sub-solver-integration.md b/guides/sub-solver-integration.md index e7a3360..5343570 100644 --- a/guides/sub-solver-integration.md +++ b/guides/sub-solver-integration.md @@ -125,7 +125,7 @@ All endpoints are on the public listener (default port 9585): | Method | Path | Auth | Purpose | |---|---|---|---| -| `POST` | `/proposals` | Proposal signature (in body) | Submit a signed proposal. Returns `202` with an id. This is **not** acceptance. | +| `POST` | `/proposals` | Proposal signature (in body) | Submit a signed proposal. Returns `202` with an id. Replaying identical signed content returns the original id; reusing its nonce with different signed content returns `409 NonceAlreadyUsed`. This is **not** acceptance. | | `GET` | `/proposal/{id}` | `X-Signature` (EIP-712 `ReadAuth`) | Get your proposal status, rejection reason, and settlement/penalty tx hashes. | | `GET` | `/proposals/{order_uid}` | `X-Signature` | List your proposals on one order. | | `GET` | `/proposals/by-sub-solver` | `X-Signature` | List all your proposals. | @@ -152,7 +152,7 @@ Run a loop: quote, sign, submit, poll, resubmit. This loop is the intended opera ### Cancel a proposal -To cancel a proposal before it settles, send a signed `DELETE` request. Proposals are immutable. There is no update operation. To replace a proposal, cancel it and submit a new one. +To cancel a proposal before it settles, send a signed `DELETE` request. Proposals are immutable and there is no update operation. To replace one, submit a new proposal with a new nonce: when it validates, BYOS supersedes your older submitted or active proposals for that order. Do not cancel first; the old proposal remains usable until the replacement becomes active. ## 7. Error handling @@ -162,6 +162,7 @@ To cancel a proposal before it settles, send a signed `DELETE` request. Proposal | Simulation reverted | None (one rate-limit slot used) | The proposal is dropped on the first revert. There are no retries. Resubmit if the route is still valid. | | `429` rate limited | None | You are over your budget. Wait out `Retry-After`. Deposit more escrow for a larger budget. | | `503` service unavailable | None | A BYOS dependency is down. Wait out `Retry-After` and retry; nothing was lost. | +| `409 NonceAlreadyUsed` | None | The nonce was already used for different signed content. Generate a new nonce; retrying the exact same signed proposal is safe and returns its original id. | | `403` insufficient escrow | None | Your cached balance is below the minimum collateral. Deposit more. Submission only — reads and cancellations keep working. | | Expired | None | Your `validUntil` passed. Use a shorter interval. | | Lost the auction | None | Your proposal stays live and competes in the next auction. | diff --git a/service.md b/service.md index 62cabf3..56727b8 100644 --- a/service.md +++ b/service.md @@ -51,10 +51,10 @@ The CoW driver notifies BYOS of outcomes via `POST /notify`. There is no chain w | Notification | Proposal transition | |---|---| -| `SettlementStarted` | `Active` → `Executing` | -| `Success { transaction }` | `Active` or `Executing` → `Settled` | -| `Revert { transaction }` | `Active` or `Executing` → `SettleFailed` (triggers Track A debit) | -| `Cancelled` / `Expired` / `Fail` | `Executing` → `Active` (queues non-settlement debit) | +| `SettlementStarted` | `Active` or `Superseded` → `Executing` | +| `Success { transaction }` | `Active`, `Executing`, or `Superseded` → `Settled` | +| `Revert { transaction }` | `Active`, `Executing`, or `Superseded` → `SettleFailed` (triggers Track A debit) | +| `Cancelled` / `Expired` / `Fail` | `Executing` → its recorded prior state, `Active` or `Superseded` (queues non-settlement debit) | `/notify` joins to proposals through the `(auction_id, solution_id, proposal_id)` mapping that `/solve` records synchronously before returning solutions. @@ -66,8 +66,8 @@ Three background loops run alongside the HTTP listeners: Runs every ~12 seconds (one block): -1. **Release stale executing proposals** — proposals stuck in `Executing` for more than 5 minutes (lost notification or restart) fall back to `Active`. -2. **Expire proposals** — any `Submitted` or `Active` proposal with `validUntil < now` transitions to `Expired`. +1. **Release stale executing proposals** — proposals stuck in `Executing` for more than 5 minutes (lost notification or restart) return to their prior live state: `Active` or `Superseded`. +2. **Expire proposals** — any `Submitted`, `Active`, or `Superseded` proposal with `validUntil < now` transitions to `Expired`. 3. **Validate remaining proposals** — for each `Submitted` or `Active` proposal: - **Escrow check** (cheap): `effectiveBalance(subSolver) >= ESCROW_GAS_ESTIMATION × gas_price + min_collateral`, where `ESCROW_GAS_ESTIMATION` is a fixed 200k gas floor. Reject if insufficient. - **Order envelope check** (no RPC): ERC20 balances only, no bridging orders, amounts consistent with order kind. For sell orders: `order.buyAmount <= minBuyAmount <= quoteBuyAmount`, `quoteBuyAmount` beats the limit price. For buy orders: `minBuyAmount == quoteBuyAmount == order.buyAmount` (hard-reject otherwise). Partially fillable orders use the same constraints against scaled amounts. @@ -102,15 +102,22 @@ stateDiagram-v2 Active --> SimFailed: re-simulation reverts Active --> Rejected: escrow re-check fails Active --> Executing: driver SettlementStarted + Submitted --> Superseded: newer proposal activates + Active --> Superseded: newer proposal activates + Superseded --> Executing: delayed SettlementStarted + Superseded --> Settled: delayed Success + Superseded --> SettleFailed: delayed Revert Submitted --> Expired: validUntil passed Active --> Expired: validUntil passed + Superseded --> Expired: validUntil passed Submitted --> Cancelled: DELETE Active --> Cancelled: DELETE Active --> Settled: driver Success\n(missed Started) Active --> SettleFailed: driver Revert\n(missed Started) Executing --> Settled: driver Success Executing --> SettleFailed: driver Revert - Executing --> Active: driver Cancelled/Fail,\nor timeout + Executing --> Active: driver Cancelled/Fail,\nor timeout (was Active) + Executing --> Superseded: driver Cancelled/Fail,\nor timeout (was Superseded) SettleFailed --> Penalized: Track A debit lands ``` @@ -120,11 +127,16 @@ A state answers one question: **what does the service do with this proposal righ |---|---|---|---| | `Submitted` | First pass pending | No | Yes | | `Active` | Every tick | Yes | Yes | +| `Superseded` | No | No | No | | `Executing` | No | No | No | | `Rejected` / `SimFailed` / `Expired` / `Cancelled` | No | No | No | | `Settled` / `SettleFailed` / `Penalized` | No | No | No | -Transitions are **compare-and-swap** — zero rows affected means the caller's verdict was stale (a cancellation or notification won the race). +`POST /proposals` uses `(subSolver, nonce)` as its idempotency key. An identical signed replay returns the original id with `202` and has no side effects; different signed content for the same key returns `409 NonceAlreadyUsed`. + +When a proposal is still the newest eligible submission and validates, one serialized group transaction activates it and supersedes older `Submitted` or `Active` proposals for the same `(subSolver, orderUid)`. It records `supersededByProposalId`, never supersedes `Executing`, and prevents a late older validator from reactivating stale work. Superseded proposals are excluded from validation and `/solve`, but continue to expire. A delayed notification for a previously offered superseded proposal is accepted: abandonment restores `Superseded`; success and revert stay terminal. + +Transitions are **compare-and-swap** — zero rows affected means the caller's verdict was stale (a cancellation, replacement, or notification won the race). ## Persistence From 4bb4d8a844e08c0d8dd26d1c630666fa2c4c9bb5 Mon Sep 17 00:00:00 2001 From: Pedro Yves Fracari Date: Thu, 27 Aug 2026 14:16:39 -0300 Subject: [PATCH 2/2] docs: simplify proposal replacement lifecycle --- design-document.md | 36 +++++++++++++------------------- glossary.md | 2 +- guides/sub-solver-integration.md | 2 +- service.md | 26 +++++++++-------------- 4 files changed, 26 insertions(+), 40 deletions(-) diff --git a/design-document.md b/design-document.md index 5548979..9f8b5e5 100644 --- a/design-document.md +++ b/design-document.md @@ -431,7 +431,7 @@ struct CancelProposal { } ``` -Proposals are immutable, so there is no update operation and no `PUT`. Replacement is always a new `POST`; it is not preceded by a `DELETE`. Once the newer proposal validates, it supersedes the caller's older `Submitted` and `Active` proposals for the same order. +Proposals are immutable, so there is no update operation and no `PUT`. Replacement is always a new `POST`; it is not preceded by a `DELETE`. Once the newer proposal validates, it cancels the caller's older `Submitted` and `Active` proposals for the same order. **Nonce uniqueness is global per sub-solver.** The recovered signer and nonce form the unique key: `(subSolver, nonce)`. A byte-for-byte equivalent signed proposal is idempotent: BYOS returns its original proposal id with `202 Accepted` and creates no row, audit event, or replacement. Reusing that nonce with any different signed content returns `409 NonceAlreadyUsed`. The signature already identifies the sub-solver, so there is no unsigned replacement-intent field. @@ -475,20 +475,15 @@ stateDiagram-v2 Active --> SimFailed: re-simulation reverts Active --> Rejected: escrow re-check fails Active --> Executing: driver SettlementStarted - Submitted --> Superseded: newer proposal activates - Active --> Superseded: newer proposal activates - Superseded --> Executing: delayed driver SettlementStarted - Superseded --> Settled: delayed driver Success - Superseded --> SettleFailed: delayed driver Revert + Submitted --> Cancelled: newer proposal activates + Active --> Cancelled: newer proposal activates Submitted --> Expired: validUntil passed Active --> Expired: validUntil passed - Superseded --> Expired: validUntil passed Submitted --> Cancelled: DELETE /proposals/{id} Active --> Cancelled: DELETE /proposals/{id} Executing --> Settled: driver Success Executing --> SettleFailed: driver Revert - Executing --> Active: driver Cancelled/Expired/Fail,\nor executing timeout (was Active) - Executing --> Superseded: driver Cancelled/Expired/Fail,\nor executing timeout (was Superseded) + Executing --> Active: driver Cancelled/Expired/Fail,\nor executing timeout SettleFailed --> Penalized: Track A escrow debit lands Settled --> [*] Penalized --> [*] @@ -500,7 +495,6 @@ A state answers exactly one question: what does the service do with this proposa |---|---|---|---|---|---| | `Submitted` | first pass | no | yes | yes | live | | `Active` | every tick | yes | yes | yes | live | -| `Superseded` | no | no | yes | no | live | | `Executing` | no | no | no | no | live | | `Rejected` | no | no | — | no | 1 hour | | `SimFailed` | no | no | — | no | 1 hour | @@ -521,20 +515,19 @@ Every transition: | `Active` | `Active` | Re-validation tick refreshes the gas estimate. No status change, no audit event. | | `Active` | `Rejected` | Escrow re-check fails; the balance dropped below the threshold. | | `Active` | `SimFailed` | Re-simulation reverts: the order filled or expired on-chain, the route broke, balances moved. | -| `Submitted`, `Active` | `Superseded` | A newer proposal for the same `(subSolver, orderUid)` validates and activates. Records `supersededByProposalId`. | -| `Submitted`, `Active`, `Superseded` | `Expired` | `validUntil` is behind the clock. | -| `Submitted`, `Active` | `Cancelled` | Signed `DELETE` by the owner. `DELETE` against any other state is a 409. | -| `Active`, `Superseded` | `Executing` | Driver `SettlementStarted`: our solution won and the transaction is being submitted. This accepts delayed notifications for a proposal returned before it was superseded. | +| `Submitted`, `Active` | `Expired` | `validUntil` is behind the clock. | +| `Submitted`, `Active` | `Cancelled` | Signed `DELETE` by the owner, or a newer proposal for the same `(subSolver, orderUid)` validates and activates. `DELETE` against any other state is a 409. | +| `Active` | `Executing` | Driver `SettlementStarted`: our solution won and the transaction is being submitted. | | `Executing` | `Settled` | Driver `Success`. Transaction hash recorded. | | `Executing` | `SettleFailed` | Driver `Revert`. Transaction hash recorded; the Track A debit follows. | -| `Executing` | `Active`, `Superseded` | Driver `Cancelled`, `Expired`, or `Fail` (submission abandoned, no transaction landed), or the executing timeout elapsed. Returns to its pre-execution live state, recorded so a superseded proposal remains superseded; queues the non-settlement debit. | +| `Executing` | `Active` | Driver `Cancelled`, `Expired`, or `Fail` (submission abandoned, no transaction landed), or the executing timeout elapsed. Queues the non-settlement debit. | | `SettleFailed` | `Penalized` | The Track A escrow debit lands on-chain. Penalty transaction hash recorded. | **Losing an auction is not a state.** A proposal outscored internally, or whose solution lost the external competition, is still valid and keeps competing. Participation is recorded as data, so "which auctions did this compete in and lose" is a query, not a status. Winning *is* a state change, because it changes what the service does: it must stop offering the proposal and stop re-simulating it. -**Supersession is an activation-time, atomic group transition.** A successfully validated proposal becomes `Active` only if it is still the newest eligible submission for its `(subSolver, orderUid)` group. In that same serialized transaction, it changes every older `Submitted` or `Active` proposal in the group to `Superseded` and records its id as `supersededByProposalId`; `Executing` proposals are unchanged. This ordering prevents an older validator job from replacing or reactivating a newer proposal. Superseded proposals are neither simulated nor returned by `/solve`, but still expire normally. +**Replacement is an activation-time, atomic group transition.** A successfully validated proposal becomes `Active` only if it is still the newest eligible submission for its `(subSolver, orderUid)` group. In that same serialized transaction, it changes every older `Submitted` or `Active` proposal in the group to `Cancelled`; `Executing` proposals are unchanged. This ordering prevents an older validator job from replacing or reactivating a newer proposal. -`Executing` is entered on `SettlementStarted`, not at `/solve` time, because at `/solve` time we do not yet know we won. A notification can arrive after its proposal was superseded, so `Superseded` may also enter `Executing`. It is exempt from the expiry sweep on purpose — the chain enforces the order's real deadline. An abandoned execution returns to the proposal's prior live state: `Active` or `Superseded`. Success and revert remain terminal. Re-simulation is the truth-teller for proposals that return to `Active`. +`Executing` is entered on `SettlementStarted`, not at `/solve` time, because at `/solve` time we do not yet know we won. It is exempt from the expiry sweep on purpose — the chain enforces the order's real deadline. An abandoned execution returns to `Active`, covering lost notifications and restarts mid-settlement. Success and revert remain terminal. Re-simulation is the truth-teller. Transitions are compare-and-swap. Zero rows affected means the caller's verdict was stale, because a cancellation, replacement, or notification won the race. @@ -572,7 +565,6 @@ Every reason a proposal stops competing, grouped by when it happens: |---|---| | Expired | `validUntil` passed | | Cancelled | Sub-solver sent a signed `DELETE` | -| Superseded | A newer proposal for the same sub-solver and order activated | | Settled | The proposal won an auction and settled on-chain | | Settle failed | Settlement reverted on-chain — triggers a Track A penalty | @@ -584,10 +576,10 @@ Outcomes come from the stock CoW driver's `/notify` protocol. There is no chain | Notification | Effect | |---|---| -| `SettlementStarted` | `Active` or `Superseded` to `Executing` | -| `Success { transaction }` | `Executing` or `Superseded` to `Settled` | -| `Revert { transaction }` | `Executing` or `Superseded` to `SettleFailed`; Track A trigger | -| `Cancelled`, `Expired`, `Fail` | `Executing` to its pre-execution state (`Active` or `Superseded`); queues the non-settlement debit | +| `SettlementStarted` | `Active` to `Executing` | +| `Success { transaction }` | `Executing` to `Settled` | +| `Revert { transaction }` | `Executing` to `SettleFailed`; Track A trigger | +| `Cancelled`, `Expired`, `Fail` | `Executing` to `Active`; queues the non-settlement debit | | Pre-submission kinds | no transition; recorded as audit events | Notifications carry auction and solution ids, not proposals, so `/solve` records the `(auction_id, solution_id, proposal_id)` mapping synchronously before returning a solution — if it cannot be recorded, it is not bid. `/notify` joins through that mapping, which doubles as the per-auction participation record. The ids are optional on the wire, so the handler must tolerate notifications it cannot join, but an *outcome* notification that cannot be attributed is an alert-worthy bug. diff --git a/glossary.md b/glossary.md index 2b52665..083f873 100644 --- a/glossary.md +++ b/glossary.md @@ -28,7 +28,7 @@ The core economic framing. Everything about escrow, penalties, and gatekeeping f - **Sub-solver** — an external, permissionless party that computes a route for a specific order and submits a signed proposal to BYOS. Never holds submission keys; never calls `settle`. Identified by its address, recovered from its EIP-712 signature; that same address is its escrow key and its Trampoline CREATE2 salt. It is `sub_solver`, never plain `solver` — in CoW's vocabulary `solver` means BYOS itself. -- **Proposal** — an EIP-712-signed message authorizing BYOS to attempt a settlement of a specific route, and consenting to the associated escrow risk. Immutable: amounts (`sellAmount`, `minBuyAmount`, `quoteBuyAmount`), interactions, expiry, nonce, and signature form one signed unit, so there is no update operation. One proposal commits to exactly one order. A nonce is unique per sub-solver: replaying identical signed content returns the original proposal, while different content with the same nonce is rejected. When a newer proposal activates for the same order, older submitted or active proposals become **Superseded** and stop competing. Field-level definition is in [the design document](design-document#proposal-schema); the wire shape is in `byos-service`'s [`crates/byos/openapi.yml`](https://github.com/bleu/byos-service/blob/main/crates/byos/openapi.yml). +- **Proposal** — an EIP-712-signed message authorizing BYOS to attempt a settlement of a specific route, and consenting to the associated escrow risk. Immutable: amounts (`sellAmount`, `minBuyAmount`, `quoteBuyAmount`), interactions, expiry, nonce, and signature form one signed unit, so there is no update operation. One proposal commits to exactly one order. Field-level definition is in [the design document](design-document#proposal-schema); the wire shape is in `byos-service`'s [`crates/byos/openapi.yml`](https://github.com/bleu/byos-service/blob/main/crates/byos/openapi.yml). - **Trampoline** — the contract that receives a route's `sellAmount`, executes the sub-solver's interactions as itself, sweeps both trade tokens back to `GPv2Settlement`, and enforces `minBuyAmount` as a floor. Confines sub-solver code to a fund-less context so it cannot reach settlement buffers or plant an exploitable approval. One immutable instance per sub-solver, at a deterministic CREATE2 address, deployed at escrow-deposit time. See [the design document](design-document#trampoline). diff --git a/guides/sub-solver-integration.md b/guides/sub-solver-integration.md index 5343570..f9e996a 100644 --- a/guides/sub-solver-integration.md +++ b/guides/sub-solver-integration.md @@ -152,7 +152,7 @@ Run a loop: quote, sign, submit, poll, resubmit. This loop is the intended opera ### Cancel a proposal -To cancel a proposal before it settles, send a signed `DELETE` request. Proposals are immutable and there is no update operation. To replace one, submit a new proposal with a new nonce: when it validates, BYOS supersedes your older submitted or active proposals for that order. Do not cancel first; the old proposal remains usable until the replacement becomes active. +To cancel a proposal before it settles, send a signed `DELETE` request. Proposals are immutable and there is no update operation. To replace one, submit a new proposal with a new nonce: when it validates, BYOS cancels your older submitted or active proposals for that order. Do not cancel first; the old proposal remains usable until the replacement becomes active. ## 7. Error handling diff --git a/service.md b/service.md index 56727b8..f19a18c 100644 --- a/service.md +++ b/service.md @@ -51,10 +51,10 @@ The CoW driver notifies BYOS of outcomes via `POST /notify`. There is no chain w | Notification | Proposal transition | |---|---| -| `SettlementStarted` | `Active` or `Superseded` → `Executing` | -| `Success { transaction }` | `Active`, `Executing`, or `Superseded` → `Settled` | -| `Revert { transaction }` | `Active`, `Executing`, or `Superseded` → `SettleFailed` (triggers Track A debit) | -| `Cancelled` / `Expired` / `Fail` | `Executing` → its recorded prior state, `Active` or `Superseded` (queues non-settlement debit) | +| `SettlementStarted` | `Active` → `Executing` | +| `Success { transaction }` | `Active` or `Executing` → `Settled` | +| `Revert { transaction }` | `Active` or `Executing` → `SettleFailed` (triggers Track A debit) | +| `Cancelled` / `Expired` / `Fail` | `Executing` → `Active` (queues non-settlement debit) | `/notify` joins to proposals through the `(auction_id, solution_id, proposal_id)` mapping that `/solve` records synchronously before returning solutions. @@ -66,8 +66,8 @@ Three background loops run alongside the HTTP listeners: Runs every ~12 seconds (one block): -1. **Release stale executing proposals** — proposals stuck in `Executing` for more than 5 minutes (lost notification or restart) return to their prior live state: `Active` or `Superseded`. -2. **Expire proposals** — any `Submitted`, `Active`, or `Superseded` proposal with `validUntil < now` transitions to `Expired`. +1. **Release stale executing proposals** — proposals stuck in `Executing` for more than 5 minutes (lost notification or restart) fall back to `Active`. +2. **Expire proposals** — any `Submitted` or `Active` proposal with `validUntil < now` transitions to `Expired`. 3. **Validate remaining proposals** — for each `Submitted` or `Active` proposal: - **Escrow check** (cheap): `effectiveBalance(subSolver) >= ESCROW_GAS_ESTIMATION × gas_price + min_collateral`, where `ESCROW_GAS_ESTIMATION` is a fixed 200k gas floor. Reject if insufficient. - **Order envelope check** (no RPC): ERC20 balances only, no bridging orders, amounts consistent with order kind. For sell orders: `order.buyAmount <= minBuyAmount <= quoteBuyAmount`, `quoteBuyAmount` beats the limit price. For buy orders: `minBuyAmount == quoteBuyAmount == order.buyAmount` (hard-reject otherwise). Partially fillable orders use the same constraints against scaled amounts. @@ -102,22 +102,17 @@ stateDiagram-v2 Active --> SimFailed: re-simulation reverts Active --> Rejected: escrow re-check fails Active --> Executing: driver SettlementStarted - Submitted --> Superseded: newer proposal activates - Active --> Superseded: newer proposal activates - Superseded --> Executing: delayed SettlementStarted - Superseded --> Settled: delayed Success - Superseded --> SettleFailed: delayed Revert + Submitted --> Cancelled: newer proposal activates + Active --> Cancelled: newer proposal activates Submitted --> Expired: validUntil passed Active --> Expired: validUntil passed - Superseded --> Expired: validUntil passed Submitted --> Cancelled: DELETE Active --> Cancelled: DELETE Active --> Settled: driver Success\n(missed Started) Active --> SettleFailed: driver Revert\n(missed Started) Executing --> Settled: driver Success Executing --> SettleFailed: driver Revert - Executing --> Active: driver Cancelled/Fail,\nor timeout (was Active) - Executing --> Superseded: driver Cancelled/Fail,\nor timeout (was Superseded) + Executing --> Active: driver Cancelled/Fail,\nor timeout SettleFailed --> Penalized: Track A debit lands ``` @@ -127,14 +122,13 @@ A state answers one question: **what does the service do with this proposal righ |---|---|---|---| | `Submitted` | First pass pending | No | Yes | | `Active` | Every tick | Yes | Yes | -| `Superseded` | No | No | No | | `Executing` | No | No | No | | `Rejected` / `SimFailed` / `Expired` / `Cancelled` | No | No | No | | `Settled` / `SettleFailed` / `Penalized` | No | No | No | `POST /proposals` uses `(subSolver, nonce)` as its idempotency key. An identical signed replay returns the original id with `202` and has no side effects; different signed content for the same key returns `409 NonceAlreadyUsed`. -When a proposal is still the newest eligible submission and validates, one serialized group transaction activates it and supersedes older `Submitted` or `Active` proposals for the same `(subSolver, orderUid)`. It records `supersededByProposalId`, never supersedes `Executing`, and prevents a late older validator from reactivating stale work. Superseded proposals are excluded from validation and `/solve`, but continue to expire. A delayed notification for a previously offered superseded proposal is accepted: abandonment restores `Superseded`; success and revert stay terminal. +When a proposal is still the newest eligible submission and validates, one serialized group transaction activates it and cancels older `Submitted` or `Active` proposals for the same `(subSolver, orderUid)`. It never cancels `Executing` proposals and prevents a late older validator from reactivating stale work. Transitions are **compare-and-swap** — zero rows affected means the caller's verdict was stale (a cancellation, replacement, or notification won the race).