Skip to content

Epic — Swap v1: CoW Protocol market swaps (trust path, GUI, agent tools) #23

Description

@hellno

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

  1. Swap only — Send is a separate spec.
  2. 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.
  3. ERC-20↔ERC-20 only. No native ETH, no wrap helper, no EthFlow.
  4. Market (sell) orders only.
  5. Every swap raises a human approval card (orders are unconditionally NeedsApproval). No token-value caps in v1.
  6. 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).
  7. Daemon signs only; key-less side submits to the orderbook. signerd gains zero network dependencies (enforced, see AC below).
  8. 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).
  9. 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).
  10. 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.
  11. 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)

  1. All three children closed; DoD green on each (cargo fmt --all --check, just check both feature configs, cargo test --workspace).
  2. just swap-e2e on live Sepolia: order accepted + open on the orderbook (hard gate); fill evidence attached if observed.
  3. Fork demo completes the flow with simulated fill + visible "simulated" banner.
  4. THREAT-MODEL.md documents quote privacy (wallet address to api.cow.fi) and the signed-order residual windows; README tool count updated to 9.
  5. Dependency policy: only the already-in-lock reqwest gains a direct deckard-core line (pre-approved); any other new crate needs explicit approval.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions