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

209 lines
7.4 KiB
Plaintext

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
// provider = "sqlite"
url = env("POSTGRES_URL") // uses connection pooling
directUrl = env("POSTGRES_URL") // uses a direct connection
}
// pnpx prisma generate
// npx prisma db push
// npx prisma studio
// prisma migrate dev
model Network {
id Int @id @default(autoincrement())
display_name String?
internal_name String? @unique
native_currency String?
is_testnet Boolean?
is_featured Boolean?
logo String?
chain_id String?
type String?
average_completion_time String?
transaction_explorer_template String?
account_explorer_template String?
listing_date DateTime?
source_network Swap[] @relation("sourceNetwork")
destination_network Swap[] @relation("destinationNetwork")
currencies Currency[]
deposit_actions DepositAction[]
transaction Transaction[]
nodes RpcNode[]
}
model Currency {
id Int @id @default(autoincrement())
name String
asset String
logo String?
contract_address String?
decimals Int?
price_in_usd Float?
precision Int?
is_native Boolean?
listing_date DateTime @default(now())
deposit_action_currency DepositAction[] @relation("DepositActionCurrency")
transaction Transaction[]
network_id Int
network Network @relation(fields: [network_id], references: [id])
source_currency Swap[] @relation("sourceAsset")
destination_currency Swap[] @relation("destinationAsset")
@@unique([network_id, asset]) // Composite unique constraint
@@index([network_id])
@@index([asset])
}
model DepositAction {
id Int @id @default(autoincrement())
type String? @default("transfer")
status String
from String
to String
amount Float
transaction_hash String
max_confirmations Int
confirmations Int
created_date DateTime @default(now())
timestamp DateTime @default(now())
network_id Int
network Network @relation(fields: [network_id], references: [id])
currency_id Int
currency Currency @relation("DepositActionCurrency", fields: [currency_id], references: [id])
swap_id String
swap Swap @relation(fields: [swap_id], references: [id])
@@index([swap_id])
@@index([transaction_hash])
@@index([currency_id])
@@index([network_id])
}
model Swap {
id String @id @default(uuid())
created_date DateTime @default(now())
source_network_id Int
source_network Network @relation("sourceNetwork", fields: [source_network_id], references: [id])
source_exchange String?
source_asset_id Int
source_asset Currency @relation("sourceAsset", fields: [source_asset_id], references: [id])
source_address String
destination_network_id Int
destination_network Network @relation("destinationNetwork", fields: [destination_network_id], references: [id])
destination_exchange String?
destination_asset_id Int
destination_asset Currency @relation("destinationAsset", fields: [destination_asset_id], references: [id])
destination_address String
refuel Boolean
use_deposit_address Boolean
use_teleporter Boolean? @default(false)
requested_amount Float
status String
fail_reason String?
metadata_sequence_number Int?
deposit_address String?
quotes Quote?
deposit_actions DepositAction[]
transactions Transaction[]
cosigner_steps CosignerStep[]
@@index([id])
}
/// Per-swap cosigner record — layered Utila / Fireblocks cosign on top
/// of the native MPC threshold network (issue #386). One row per
/// intent listed in the original POST /api/swaps cosigners[] array.
/// Lifecycle:
/// pending — created at swap-create time alongside the swap row
/// approved — external custodian signed; `signature` populated
/// rejected — external denial; `reason` carries subStatus / detail
/// failed — transport / config error or terminal Fireblocks FAILED;
/// also surfaced as fail_reason on the parent Swap
///
/// Public IDs only (`public_id` = org_id for utila, api_key for
/// fireblocks). NEVER stores secret material — secrets stay in KMS,
/// keyed off `public_id` at dispatch time.
model CosignerStep {
id Int @id @default(autoincrement())
created_date DateTime @default(now())
updated_date DateTime @updatedAt
swap_id String
swap Swap @relation(fields: [swap_id], references: [id])
// Discriminator + public identifiers — match the wire shape exactly.
kind String // "utila" | "fireblocks"
public_id String // org_id (utila) or api_key (fireblocks)
api_host String?
vault_id String? // utila vault_id OR fireblocks vault_account_id
// Step state.
status String @default("pending") // "pending" | "approved" | "rejected" | "failed"
signature String?
reason String?
external_id String? // utila tx name OR fireblocks tx id
@@index([swap_id])
@@index([status])
}
model Transaction {
id Int @id @default(autoincrement())
type String? @default("transfer")
status String
from String
to String
amount Float
transaction_hash String
max_confirmations Int
confirmations Int
created_date DateTime @default(now())
timestamp DateTime @default(now())
network_id Int
network Network @relation(fields: [network_id], references: [id])
currency_id Int
currency Currency @relation(fields: [currency_id], references: [id])
swap_id String
swap Swap @relation(fields: [swap_id], references: [id])
@@index([swap_id])
@@index([transaction_hash])
@@index([currency_id])
@@index([network_id])
}
model Quote {
id Int @id @default(autoincrement())
swap_id String @unique @map("swap_id")
receive_amount Float
min_receive_amount Float
blockchain_fee Float
service_fee Float
avg_completion_time String
slippage Float
total_fee Float
total_fee_in_usd Float
swap Swap @relation(fields: [swap_id], references: [id])
}
model RpcNode {
id Int @id @default(autoincrement())
url String
network_id Int
network Network @relation(fields: [network_id], references: [id])
}