Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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-<number>` tickets through Linear (for example, with the Linear `get_issue` tool); they are not GitHub issue numbers for this repository.
39 changes: 27 additions & 12 deletions design-document.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 --> [*]
Expand All @@ -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 |
Expand All @@ -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.

Expand All @@ -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 |

Expand All @@ -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 |

Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
yvesfracari marked this conversation as resolved.
Outdated

- **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).

Expand Down
5 changes: 3 additions & 2 deletions guides/sub-solver-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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

Expand All @@ -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. |
Expand Down
Loading
Loading