Files
zeekay dd406080d9 feat(app/server): CosignerStep Prisma model + state-machine gate (issue #386)
Closes the persistence + state-machine gating gap from v1.1.12. After
this commit, layered cosigners are 2-of-2 enforced end-to-end on the
backend.

Prisma:
  + model CosignerStep — one row per intent. Public IDs only:
      kind         'utila' | 'fireblocks'
      public_id    org_id (utila) or api_key (fireblocks)
      api_host?, vault_id?
      status       'pending' (default) | 'approved' | 'rejected' | 'failed'
      signature?, reason?, external_id?
  + Swap.cosigner_steps relation (1:N)
  Indexed on swap_id + status for the dispatch query.

Persistence path:
  handleSwapCreation(...)
    └─ persistCosignerIntents(swapId, cosigners) — Prisma ,
        one create() per intent, all status='pending'. No-ops when no
        intents listed (backwards compat — zero cost for native-MPC-only
        tenants).

Dispatch path (issue #386 trust-boundary enforcement):
  handlerUpdateMpcSignAction(swapId, txHash, ...)
    └─ dispatchCosignersForSwap(swapId, signature, txHash)
        ├─ findMany({ swap_id, status: 'pending' })
        ├─ rowToIntent(...) — invert persistence
        ├─ dispatchCosigners(...) — runs Fireblocks (real) / Utila (stub)
        ├─ update each step with terminal state
        └─ aggregate: 'all_approved' | 'any_rejected' | 'any_failed' | 'no_steps'

If aggregate is rejected/failed:
  - Swap.status → SwapStatus.Failed
  - Swap.fail_reason → 'Layered cosigner rejection: <kind>: <reason>'
  - Never advances to UserPayoutPending; user payout step is unreachable
    without 2-of-2 (native + every listed cosigner).
  - cosigner_steps included in the response payload for traceability.

If aggregate is no_steps (native-only swap):
  Existing code path runs unchanged — proves out the additive, opt-in
  design from the SDK side.

Tests: 7 new persistence + dispatch tests (vi.hoisted Prisma mock).
  - SDK:    58/58 unchanged
  - Server: 26 → 33 (16 validator + 10 Fireblocks + 7 persistence)

What's still STUB after this commit:
  - runUtila — waiting on the first Utila tenant to model the
    InitiateTransaction subtype against their TAP policy
  - fetchCosignerSecret KMS round-trip — env-var fallback works for
    local dev; production needs the KMS path read

The Prisma schema change requires `prisma migrate dev` (in dev) +
`prisma migrate deploy` (in CI) to apply the CosignerStep table to
existing databases. The migration file itself is left for the new
dev to generate against the target Postgres instance — autogenerated
migrations need a connected DB and shouldn't be committed blind.
2026-05-22 17:24:24 -07:00
..