Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
150 changes: 150 additions & 0 deletions docs/APPROVALS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Approval Workflows & Multi-Signature Governance

Closes the gap between "requested" and "executed" for high-value or sensitive
operations (#314). Today a user — or a parent acting through a sub-account —
can move funds with zero friction and zero oversight. An `ApprovalPolicy` lets
a principal require `minApprovers` independent co-signers before an operation
above `highValueThreshold` (or every operation, if the threshold is null) is
allowed to submit on-chain.

## Data model

| Model | Meaning |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ApprovalPolicy` | Governs one `SubAccountPermission` (`WITHDRAW`/`DEPOSIT`/`MANAGE_STRATEGY`) for a principal's own account (`scopedToChildUserId = null`) or a specific sub-account relationship (`scopedToChildUserId` = the child). |
| `ApprovalRequest` | One held operation. Snapshots `minApprovers` from the policy at request time and carries the exact operation to (re-)run as `payload`. Status machine below. |
| `Approval` | One approver's decision. `@@unique([requestId, approverUserId])` is the concurrency primitive — see below. |

Status machine: `PENDING → APPROVED → EXECUTED` (terminal — on-chain finality,
never un-executed), or `PENDING → REJECTED / EXPIRED / CANCELLED`.

## Policy resolution

| Caller | Resolves |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Self (`actingAsUserId === userId`) | `principalUserId = userId`, `scopedToChildUserId = null` |
| Parent acting on a child (delegated via `requireSubAccountPermission`) | `principalUserId = actingAsUserId` (the parent), `scopedToChildUserId = userId` (the child) |

Pending requests follow the **policy snapshot at request time**
(`minApprovers` is copied onto the row). Editing or deactivating a policy
never changes an already-open request — only new requests see the change.

## Eligible approvers

The issue's schema has no separate "approver list" field. This
implementation reuses the existing `SubAccount` graph as the approver pool:
**the policy owner (`principalUserId`) plus every ACTIVE child under that
same parent** — so "the parent and a child, or two children" can co-sign an
operation on a shared vault, per the issue's example, with no schema change.

**Self-approval**: the requester (`ApprovalRequest.actingAsUserId`) never
counts toward the threshold when `minApprovers > 1` — hard rule, no
per-policy opt-in in v1 (issue's documented default).

**Approver eligibility is checked live**, at decision time, against the
current `SubAccount` state — not the policy snapshot. If a family member's
access was revoked between the request being opened and them approving it,
they can no longer approve. An approval already recorded before revocation
still counts toward the threshold (the issue's recommended "count it, the
policy snapshot is the request's" option) — decisions are never retroactively
invalidated once persisted.

## Enforcement in the money path

`guardOperation` is called from **inside** `executeDeposit` and
`executeWithdraw` (`src/controllers/transaction-controller.ts`), not from
routes — so every caller that reuses those functions is covered by
construction:

- `POST /api/v1/withdraw`, `POST /api/v1/deposit` (HTTP)
- `src/jobs/recurringDeposits.ts` (calls `executeDeposit` directly)

When gated, no `Transaction` row is created yet — the `ApprovalRequest.payload`
holds the exact operation, re-run through the same `executeDeposit`/
`executeWithdraw` path on approval (`skipApprovalGuard: true`, set only by
`src/approvals/executors.ts`, so the approved re-run can't re-gate itself).
The resulting `Transaction`'s memo is tagged `(approval:<requestId>)` and
`ApprovalRequest.executedTxId` links back to it, for audit and the tax report.

**Recurring deposits** treat `PENDING_APPROVAL` as _skip, not fail_:
`lastRunStatus` is set to `pending_approval`, `nextRunAt` is left untouched,
and `guardOperation`'s dedupe check (an existing PENDING request for the same
policy/user/amount) means the next sweep lands on the same open request
instead of creating a new one every tick.

### Scoped out of v1 (documented, not silently dropped)

- **The agent rebalance loop** (`src/agent/loop.ts` →
`executeRebalanceIfNeeded`) is not gated. Rebalances move funds
protocol-to-protocol _inside_ the vault (same asset, same user, no
egress) — the same category the tax report already excludes from
disposals — not a withdraw/deposit call. `ApprovalPolicy.permission` still
accepts `MANAGE_STRATEGY` so a policy row is valid data for a future
rebalance-gating change; wiring it into the hourly autonomous loop is a
materially different design problem (a diff-of-allocations, not a single
amount + payload).
- **Referral payouts** (`src/jobs/referralPayout.ts`) are not gated — they
are platform-funded credits _to_ the user, not the user moving funds out,
so they don't fit the delegated-authority threat model this issue is
scoped to (a compromised session, or an over-permissioned family member,
draining a vault).

## Concurrency

Every status transition after `PENDING` is a **conditional `updateMany`**
(`where: { id, status: 'PENDING' }`), never read-then-write:

- Two approvals landing simultaneously: `Approval` creation is guarded by the
`@@unique([requestId, approverUserId])` constraint (a second decision from
the same approver gets `409`, not silently ignored or double-counted).
- Threshold crossed by two concurrent `decide()` calls: both recompute the
approval count and both attempt `updateMany(PENDING → APPROVED)`; only one
`count === 1` — that call proceeds to execute, the other sees `count === 0`
and returns without executing. **Never double-executes.**
- The expiry sweep batches a single `updateMany` per tick since it has no
other actor contending for the same PENDING → EXPIRED transition (unlike
`decide`, which races a concurrent human decision).

If execution throws or the on-chain call fails after the request reaches
`APPROVED`, the request is deliberately **left `APPROVED`**, not silently
reset to `PENDING` or lost — an ops-visible stuck state for retry/
investigation, matching the tax module's "never invert the dependency"
philosophy for money-adjacent bookkeeping.

## Endpoints

```
GET /api/v1/approvals — requests affecting the caller (paginated)
GET /api/v1/approvals/:id — full request + decisions
POST /api/v1/approvals/:id/approve — { note? }
POST /api/v1/approvals/:id/reject — { reason } (required)
POST /api/v1/approvals/:id/cancel — requester only
POST /api/v1/admin/approvals/:id/cancel — admin (scope: approvals:write)

GET /api/v1/approval-policies — policies the caller owns
GET /api/v1/approval-policies/:id
POST /api/v1/approval-policies — { scopedToChildUserId?, permission, minApprovers, highValueThreshold?, approvalTimeoutMs }
PUT /api/v1/approval-policies/:id — { minApprovers?, highValueThreshold?, approvalTimeoutMs?, isActive? }
```

Creating/editing a policy with `scopedToChildUserId` set requires the caller
to currently hold `MANAGE_STRATEGY` on that child (re-checked on every write,
not just at creation).

## Webhooks

`approval.requested`, `approval.approved`, `approval.rejected`,
`approval.executed`, `approval.expired`, `approval.cancelled` — see
`src/validators/webhook-validators.ts`.

## Known limitations (v1)

1. Agent-loop rebalances and referral payouts are not gated (see above).
2. No per-policy self-approval opt-in — always disallowed when
`minApprovers > 1`.
3. Approver pool is derived entirely from the `SubAccount` graph; there is no
standalone "add an approver who isn't a sub-account party" concept.
4. `guardOperation`'s dedupe window is keyed on (policy, user, actingAsUser,
permission, asset, amount) — two _different_ legitimate operations that
happen to share every one of those fields within the same open window
will be treated as the same request.
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
-- CreateEnum
CREATE TYPE "ApprovalStatus" AS ENUM ('PENDING', 'APPROVED', 'EXECUTED', 'REJECTED', 'EXPIRED', 'CANCELLED');

-- CreateTable
CREATE TABLE "approval_policies" (
"id" TEXT NOT NULL,
"principalUserId" TEXT NOT NULL,
"scopedToChildUserId" TEXT,
"permission" "SubAccountPermission" NOT NULL,
"minApprovers" INTEGER NOT NULL,
"highValueThreshold" DECIMAL(36,18),
"approvalTimeoutMs" INTEGER NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "approval_policies_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "approval_requests" (
"id" TEXT NOT NULL,
"policyId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"actingAsUserId" TEXT NOT NULL,
"permission" "SubAccountPermission" NOT NULL,
"amount" DECIMAL(36,18) NOT NULL,
"assetSymbol" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"status" "ApprovalStatus" NOT NULL DEFAULT 'PENDING',
"minApprovers" INTEGER NOT NULL,
"requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"executedAt" TIMESTAMP(3),
"executedTxId" TEXT,
"cancelledById" TEXT,
"reason" TEXT,

CONSTRAINT "approval_requests_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "approvals" (
"id" TEXT NOT NULL,
"requestId" TEXT NOT NULL,
"approverUserId" TEXT NOT NULL,
"approved" BOOLEAN NOT NULL,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "approvals_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "approval_policies_principalUserId_permission_isActive_idx" ON "approval_policies"("principalUserId", "permission", "isActive");

-- CreateIndex
CREATE INDEX "approval_policies_scopedToChildUserId_permission_isActive_idx" ON "approval_policies"("scopedToChildUserId", "permission", "isActive");

-- CreateIndex
CREATE INDEX "approval_requests_userId_status_idx" ON "approval_requests"("userId", "status");

-- CreateIndex
CREATE INDEX "approval_requests_status_expiresAt_idx" ON "approval_requests"("status", "expiresAt");

-- CreateIndex
CREATE INDEX "approvals_requestId_idx" ON "approvals"("requestId");

-- CreateIndex
CREATE UNIQUE INDEX "approvals_requestId_approverUserId_key" ON "approvals"("requestId", "approverUserId");

-- AddForeignKey
ALTER TABLE "approval_policies" ADD CONSTRAINT "approval_policies_principalUserId_fkey" FOREIGN KEY ("principalUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "approval_requests" ADD CONSTRAINT "approval_requests_policyId_fkey" FOREIGN KEY ("policyId") REFERENCES "approval_policies"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "approval_requests" ADD CONSTRAINT "approval_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "approvals" ADD CONSTRAINT "approvals_requestId_fkey" FOREIGN KEY ("requestId") REFERENCES "approval_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- Rollback for 20260824153458_add_approval_workflows
-- Drops the approval-workflow tables (#314).
-- WARNING: Destroys every ApprovalPolicy/ApprovalRequest/Approval row —
-- any currently-open PENDING_APPROVAL operation's intent is lost, not just
-- deferred. Deploy the reverted application code BEFORE running this: the
-- live code calls guardOperation on every deposit/withdraw, so dropping
-- these tables underneath a running server breaks that gate.

ALTER TABLE "approvals" DROP CONSTRAINT IF EXISTS "approvals_requestId_fkey";
ALTER TABLE "approval_requests" DROP CONSTRAINT IF EXISTS "approval_requests_userId_fkey";
ALTER TABLE "approval_requests" DROP CONSTRAINT IF EXISTS "approval_requests_policyId_fkey";
ALTER TABLE "approval_policies" DROP CONSTRAINT IF EXISTS "approval_policies_principalUserId_fkey";

DROP TABLE IF EXISTS "approvals";
DROP TABLE IF EXISTS "approval_requests";
DROP TABLE IF EXISTS "approval_policies";

DROP TYPE IF EXISTS "ApprovalStatus";
92 changes: 92 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ model User {
allocationSuggestions AllocationSuggestion[]
portfolioAttributions PortfolioAttribution[]
portfolioRiskAggregates PortfolioRiskAggregate[]
approvalPolicies ApprovalPolicy[] @relation("ApprovalPolicyPrincipal")
approvalRequests ApprovalRequest[] @relation("ApprovalRequestPrincipal")
userEvents UserEvent[]
userEventSequence UserEventSequence?
userApiKeys UserApiKey[]
Expand Down Expand Up @@ -823,6 +825,96 @@ model SubAccount {
@@map("sub_accounts")
}

enum ApprovalStatus {
PENDING
APPROVED
EXECUTED
REJECTED
EXPIRED
CANCELLED
}

/// Multi-signature approval policy (#314). Governs one permission class
/// (WITHDRAW/DEPOSIT/MANAGE_STRATEGY) for a principal's own account
/// (scopedToChildUserId = null) or for a specific sub-account relationship
/// (scopedToChildUserId = the child). A null highValueThreshold means the
/// policy applies to every operation of that permission, not just large ones.
/// Pending requests snapshot the policy that created them (see
/// ApprovalRequest.minApprovers) — editing or deactivating a policy never
/// changes an already-open request, only new ones.
model ApprovalPolicy {
id String @id @default(uuid())
principalUserId String
scopedToChildUserId String?
permission SubAccountPermission
minApprovers Int
highValueThreshold Decimal? @db.Decimal(36, 18)
approvalTimeoutMs Int
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

principal User @relation("ApprovalPolicyPrincipal", fields: [principalUserId], references: [id], onDelete: Cascade)
requests ApprovalRequest[]

@@index([principalUserId, permission, isActive])
@@index([scopedToChildUserId, permission, isActive])
@@map("approval_policies")
}

/// One high-value/sensitive operation held at PENDING_APPROVAL. Status
/// machine: PENDING -> APPROVED -> EXECUTED (terminal, on-chain finality,
/// never un-executed); PENDING -> REJECTED / EXPIRED / CANCELLED. `payload`
/// is the exact operation to re-run on execution (see
/// src/approvals/service.ts's guardOperation/decide). `minApprovers` is
/// snapshotted from the policy at request time so a later policy edit never
/// changes an open request's threshold.
model ApprovalRequest {
id String @id @default(uuid())
policyId String
userId String
actingAsUserId String
permission SubAccountPermission
amount Decimal @db.Decimal(36, 18)
assetSymbol String
payload Json
status ApprovalStatus @default(PENDING)
minApprovers Int
requestedAt DateTime @default(now())
expiresAt DateTime
executedAt DateTime?
executedTxId String?
cancelledById String?
reason String?

policy ApprovalPolicy @relation(fields: [policyId], references: [id])
user User @relation("ApprovalRequestPrincipal", fields: [userId], references: [id], onDelete: Cascade)
approvals Approval[]

@@index([userId, status])
@@index([status, expiresAt])
@@map("approval_requests")
}

/// One approver's decision on an ApprovalRequest. The unique constraint is
/// the concurrency primitive: two approvals landing simultaneously cannot
/// both be recorded for the same (request, approver) pair, and threshold-met
/// detection is a conditional UPDATE on the request, never check-then-act.
model Approval {
id String @id @default(uuid())
requestId String
approverUserId String
approved Boolean
note String?
createdAt DateTime @default(now())

request ApprovalRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)

@@unique([requestId, approverUserId])
@@index([requestId])
@@map("approvals")
}

/// Fiat on-ramp / off-ramp order (#290).
/// Tracks the off-chain payment leg of a fiat<->crypto conversion handled by a
/// third-party provider (e.g. MoonPay). The on-chain settlement is reconciled
Expand Down
Loading
Loading