Context
Deckard has no swap. The wallet home shows a permanently disabled Swap button (crates/deckard-app/src/welcome.rs:207). Swap is the first post-launch milestone. Protocol decision (2026-06-11): CoW Protocol direct, not the Spandex meta-aggregator. Rationale: a CoW order is a signed EIP-712 struct with explicit bounds, so the signer enforces policy on the signed artifact itself instead of trusting opaque router calldata; one quote endpoint instead of eight (privacy); MEV protection inherent; plain REST + alloy in Rust.
This epic was spec'd via /spec (codex quality gate 7/10) and reviewed via /plan-eng-review with a codex outside-voice pass. All decisions below are final; do not relitigate them in children.
Locked decisions
- Swap only — Send is a separate spec.
- Networks: Sepolia (dev target) + mainnet, selected by the existing chain-id/RPC resolution in
crates/deckard-core/src/env.rs + config.rs; unsupported chain ⇒ Swap disabled with reason tooltip. Fork demo gets a mocked fill.
- ERC-20↔ERC-20 only. No native ETH, no wrap helper, no EthFlow.
- Market (sell) orders only.
- Every swap raises a human approval card (orders are unconditionally
NeedsApproval). No token-value caps in v1.
- Vault-relayer ERC-20 approval: exact-amount only. Unlimited allowance is cut from v1 (standing authority contradicts the manifesto; a disclosure label is not a security boundary).
- Daemon signs only; key-less side submits to the orderbook. signerd gains zero network dependencies (enforced, see AC below).
- MCP gets three new tools:
deckard_swap_quote, deckard_swap, deckard_submit_order (explicitly NOT overloading deckard_execute — orders have different result shape and post-conditions).
- Token allowlist is Policy data: new
allow_swap_tokens: Vec<Address> field (daemon config populates it from the bundled chain list; contract crate keeps zero chain knowledge per its charter, policy.rs:117).
- Order cancellation (
invalidateOrder) ships in v1: daemon capability + STOP wiring in child 1, Cancel UI in child 2 — closes the "STOP cannot revoke a submitted order" gap.
- Acceptance gate for e2e is order accepted + status open on the live Sepolia orderbook (server-side validates signature/balance/allowance). An actual fill is best-effort evidence, not a merge gate (solver liquidity for faucet tokens is not Deckard's correctness).
Architecture
app / mcp (key-less) signerd (trust core, no HTTP)
──────────────────── ─────────────────────────────
quote: POST api.cow.fi /quote
build SwapOrder ──────────────────► propose_order → evaluate_order(order, policy, wallet, now)
→ NeedsApproval (always)
[allowance short?] exact approve ─► shaped-approve precheck → card → sign+broadcast tx
human approves order card (full payload rendered)
sign request ─────────────────────► sign stored order (EIP-712), re-check revoked (TOCTOU)
◄──────────── signature (sign+submit happens immediately within approval TTL)
POST /orders (uid) ─► poll GET /orders/{uid} → open → filled / expired / cancelled
STOP / Cancel ────────────────────► invalidateOrder shaped tx (child 1 capability)
Key invariants (enforced by tests in the children):
- One pending store:
PendingReq payload becomes enum { Tx(Intent), Order(SwapOrder) } in the existing requests: HashMap<RequestId, PendingReq> (daemon.rs:164); request_id_for extends deterministically to orders so re-propose dedups. lock/revoke_all/resolve/status sweeps work for orders with zero new sweep code.
- Daemon admits a shaped approve: the v1 guard (
daemon.rs:398-409) currently denies ContractCall and token: Some. Do NOT open generic ContractCall. Admit an approve only when: to == sell_token of a pending order, selector is approve(address,uint256), spender is exactly the chain's CoW vault relayer, amount == pending order's sell_amount.
- Approval inbox: the wire protocol gains a pending-request listing/payload fetch so the GUI can render MCP-originated cards (today
Status returns only ApprovalStatus, no payload). Card payload carries full order fields.
- Quote fidelity: use the orderbook quote response fields verbatim (
sellAmountBeforeFee semantics, quoteId carried into order submission, signingScheme: eip712); the only computed field is buy_amount_min = slippage bps off quoted buyAmount (rounding direction pinned by test). A re-quote after the card is shown creates a NEW request and a NEW card — never silently update an approved card's numbers.
- Owner binding:
SwapOrder carries owner; the daemon binds it to the unlocked wallet address (never inferred from the signature). receiver == 0x0 is rejected (CoW treats zero receiver specially; we don't rely on protocol semantics).
- HTTP isolation: split
cow_types (order struct, EIP-712 digest, app_data hash — no HTTP) from cow_client (reqwest); signerd depends only on cow_types via feature gating. AC: cargo tree -p deckard-signerd -e normal | grep -c reqwest is 0.
- Signed-but-unsubmitted window: sign and submit happen in one flow; on submit failure the app persists the signed payload (config dir) for retry and shows the live-signature warning.
invalidateOrder is the kill switch.
Child Issues
| # |
Title |
Effort (CC) |
Depends on |
| #24 |
Trust path: cow module, contract order types, signerd typed-data signing + shaped approve + cancellation |
~4-5d |
— |
| #25 |
GUI: Swap surface, allowance + order cards, open-order lifecycle, chain-keyed holdings |
~3-4d |
#24 |
| #26 |
MCP swap tools + fork-demo stub + swap-e2e harness |
~2-3d |
#24 |
#24 (trust path) ─┬─► #25 (GUI)
└─► #26 (MCP + demo)
Sequencing rationale: #25 and #26 both consume #24's engine/contract/daemon surface and can run in parallel worktrees after it lands. #24 lands alone so the trust-core diff gets isolated review.
Out of Scope (epic-wide)
Send; limit orders; shielded swaps (CoW receiver is the future hook); native ETH / EthFlow / wrap helper; unlimited allowances; price-oracle token-value caps (deliberately skipped — revisit post-audit if agent swap autonomy is wanted); partial fills; Spandex-style multi-aggregator benchmarks.
Definition of Done (epic)
- All three children closed; DoD green on each (
cargo fmt --all --check, just check both feature configs, cargo test --workspace).
just swap-e2e on live Sepolia: order accepted + open on the orderbook (hard gate); fill evidence attached if observed.
- Fork demo completes the flow with simulated fill + visible "simulated" banner.
- THREAT-MODEL.md documents quote privacy (wallet address to api.cow.fi) and the signed-order residual windows; README tool count updated to 9.
- Dependency policy: only the already-in-lock
reqwest gains a direct deckard-core line (pre-approved); any other new crate needs explicit approval.
Context
Deckard has no swap. The wallet home shows a permanently disabled Swap button (
crates/deckard-app/src/welcome.rs:207). Swap is the first post-launch milestone. Protocol decision (2026-06-11): CoW Protocol direct, not the Spandex meta-aggregator. Rationale: a CoW order is a signed EIP-712 struct with explicit bounds, so the signer enforces policy on the signed artifact itself instead of trusting opaque router calldata; one quote endpoint instead of eight (privacy); MEV protection inherent; plain REST + alloy in Rust.This epic was spec'd via /spec (codex quality gate 7/10) and reviewed via /plan-eng-review with a codex outside-voice pass. All decisions below are final; do not relitigate them in children.
Locked decisions
crates/deckard-core/src/env.rs+config.rs; unsupported chain ⇒ Swap disabled with reason tooltip. Fork demo gets a mocked fill.NeedsApproval). No token-value caps in v1.deckard_swap_quote,deckard_swap,deckard_submit_order(explicitly NOT overloadingdeckard_execute— orders have different result shape and post-conditions).allow_swap_tokens: Vec<Address>field (daemon config populates it from the bundled chain list; contract crate keeps zero chain knowledge per its charter,policy.rs:117).invalidateOrder) ships in v1: daemon capability + STOP wiring in child 1, Cancel UI in child 2 — closes the "STOP cannot revoke a submitted order" gap.Architecture
Key invariants (enforced by tests in the children):
PendingReqpayload becomesenum { Tx(Intent), Order(SwapOrder) }in the existingrequests: HashMap<RequestId, PendingReq>(daemon.rs:164);request_id_forextends deterministically to orders so re-propose dedups.lock/revoke_all/resolve/statussweeps work for orders with zero new sweep code.daemon.rs:398-409) currently deniesContractCallandtoken: Some. Do NOT open generic ContractCall. Admit an approve only when:to == sell_tokenof a pending order, selector isapprove(address,uint256), spender is exactly the chain's CoW vault relayer, amount == pending order'ssell_amount.Statusreturns onlyApprovalStatus, no payload). Card payload carries full order fields.sellAmountBeforeFeesemantics,quoteIdcarried into order submission,signingScheme: eip712); the only computed field isbuy_amount_min= slippage bps off quoted buyAmount (rounding direction pinned by test). A re-quote after the card is shown creates a NEW request and a NEW card — never silently update an approved card's numbers.SwapOrdercarries owner; the daemon binds it to the unlocked wallet address (never inferred from the signature).receiver == 0x0is rejected (CoW treats zero receiver specially; we don't rely on protocol semantics).cow_types(order struct, EIP-712 digest, app_data hash — no HTTP) fromcow_client(reqwest); signerd depends only oncow_typesvia feature gating. AC:cargo tree -p deckard-signerd -e normal | grep -c reqwestis 0.invalidateOrderis the kill switch.Child Issues
Sequencing rationale: #25 and #26 both consume #24's engine/contract/daemon surface and can run in parallel worktrees after it lands. #24 lands alone so the trust-core diff gets isolated review.
Out of Scope (epic-wide)
Send; limit orders; shielded swaps (CoW
receiveris the future hook); native ETH / EthFlow / wrap helper; unlimited allowances; price-oracle token-value caps (deliberately skipped — revisit post-audit if agent swap autonomy is wanted); partial fills; Spandex-style multi-aggregator benchmarks.Definition of Done (epic)
cargo fmt --all --check,just checkboth feature configs,cargo test --workspace).just swap-e2eon live Sepolia: order accepted + open on the orderbook (hard gate); fill evidence attached if observed.reqwestgains a directdeckard-coreline (pre-approved); any other new crate needs explicit approval.