Skip to content

Approval Workflows & Multi-Signature Governance for High-Value Transactions #314

Description

@robertocarlous

Problem Statement

Today, a user (or a parent via sub-accounts) can move money with zero friction — and zero oversight. A compromised session, a fat-fingered amount, or a "family" setup where any member can withdraw the whole vault is a single call away. There is no intermediate state between "requested" and "executed" for high-value or sensitive operations. This issue introduces an approval-workflow layer: high-value transactions enter a PENDING_APPROVAL state, require a configurable number of independent approvers (co-signers), expire if not approved in time, and leave a complete audit trail — while preserving the existing one-action path for normal operations.

Current State

  • SubAccount (prisma/schema.prisma) already models delegation: a parent can grant VIEW/DEPOSIT/WITHDRAW/MANAGE_STRATEGY permissions to a child, enforced by src/middleware/subAccount.ts and the routes that use it (src/routes/sub-accounts.ts, src/middleware/subAccount.ts).
  • Withdrawals/deposits flow through typed Transaction rows (PENDING/CONFIRMED/FAILED/CANCELLED), the Stellar event listener (src/stellar/events.ts), and the agent-signed write path (src/stellar/contract.ts's executeWriteContractCall). AdminAuditLog shows the precedent for append-only action recording, and src/services/webhookDispatcher.ts fans out domain events.
  • There is no approval concept anywhere: every permitted actor executes immediately.

Proposed Solution

1. Approval workflow model

model ApprovalPolicy {
  id            String   @id @default(uuid())
  // Owner of the policy: a user, or a sub-account relationship (parent/child)
  principalUserId String
  scopedToChildUserId String?   // null = the user's own account; set = the child under the parent
  permission   SubAccountPermission  // which action class the policy governs (WITHDRAW, DEPOSIT, MANAGE_STRATEGY)
  minApprovers Int      // number of distinct approving users required
  highValueThreshold Decimal? @db.Decimal(36, 18) // per-operation value above which the policy applies
  // null threshold = policy applies to every operation of `permission`
  approvalTimeoutMs Int    // time before the request auto-expires
  isActive     Boolean  @default(true)
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt
}

model ApprovalRequest {
  id              String   @id @default(uuid())
  policyId        String
  userId          String    // principal whose funds are affected
  actingAsUserId  String    // who requested the operation
  permission      SubAccountPermission
  amount          Decimal   @db.Decimal(36, 18)
  assetSymbol     String
  payload         Json      // the exact operation (e.g. { type: "withdraw", to: ..., amount: ... })
  status          ApprovalStatus @default(PENDING)
  requestedAt     DateTime @default(now())
  expiresAt       DateTime
  executedAt      DateTime?
  executedTxId    String?   // Transaction created on execution
  cancelledById   String?   // who cancelled, for audit
  reason          String?   // approver-provided note
  approvals       Approval[]
}

model Approval {
  id               String   @id @default(uuid())
  requestId        String
  approverUserId   String
  approved         Boolean  // true=approve, false=reject
  note             String?
  createdAt        DateTime @default(now())
  @@unique([requestId, approverUserId]) // one decision per approver per request
}

Status machine: PENDING → APPROVED → EXECUTED; PENDING → REJECTED (any hard rejection); PENDING → EXPIRED (timeout); PENDING → CANCELLED (by requester or an admin). Once EXECUTED it is terminal (on-chain finality — never un-execute, matching the referral-payout philosophy).

2. Enforcement in the money path

  • Decision point: interception happens at the service layer (not the route), so every entry point is covered — REST routes, the agent loop, recurring deposits, referral payouts. Define a single guardOperation({ userId, actingAsUserId, permission, amount, assetSymbol, payload }) that either returns the operation, or returns a PENDING_APPROVAL token with the request id. Every caller must handle both returns; the agent loop must skip rather than silently execute.
  • Sub-account integration: a parent's WITHDRAW on a child hits the child-scoped policy. Approval counts toward the threshold regardless of who is parent/child — an operation on a shared vault can require the parent and a child, or two children, per the policy.
  • Expiry sweep: a scheduled job (registered in src/index.ts like the other schedule* jobs) transitions EXPIRED requests and emits a webhook event so stakeholders know the operation lapsed — the requester's intent must never silently vanish.
  • Execution on approval: once threshold met, execution re-runs the payload through the exact same deposit/withdraw path that a non-approved call would take — one implementation, two gates. executedTxId links the approval to the resulting Transaction; the transaction's TransactionType/memo must identify it as approval-gated for audit and the tax report (src/tax/service.ts).
  • Concurrency: two approvals landing simultaneously must not double-execute — enforce with a state-transition that is atomic (unique constraint on (requestId, approverUserId) + a conditional UPDATE to APPROVED/EXECUTED), never a check-then-act.

3. Notifications & API surface

  • Events on the existing dispatcher: approval.requested, approval.approved, approval.rejected, approval.executed, approval.expired, approval.cancelled (add to WEBHOOK_EVENTS).
  • Routes (owner-scoped, behind requireAuth, respecting enforceUserAccess and sub-account scoping):
    • GET /api/v1/approvals — pending requests affecting the caller (as principal or required approver), paginated.
    • GET /api/v1/approvals/:id — full request + decision list.
    • POST /api/v1/approvals/:id/approve / /reject — caller must be a validated approver (policy-defined), one decision per approver.
    • POST /api/v1/approvals/:id/cancel — requester or admin.
    • POST /api/v1/approval-policies / PUT /api/v1/approval-policies/:id — manage policies; sub-account policy changes require the parent's MANAGE_STRATEGY-class authority.
  • Validation: a rejection reason should be required on reject, optional on approve; all via the zod-validator style and named 400s.

Edge Cases & Failure Modes

  • Approver and requester are the same person: policy must specify whether self-approval counts (default: no for minApprovers > 1, and a test must assert it).
  • Approver loses permission mid-request: decisions are validated against the policy at decision time, so a revoked approver's earlier approval must be recomputed — define whether it counts retroactively (recommended: count it, the policy snapshot is the request's, but document).
  • Policy edited/deactivated while requests are open: pending requests follow their snapshot of the policy at request time; only new requests use the new policy. Explicit and tested.
  • Amount drift: if the operation's amount at execution time differs materially from payload (e.g. rate change), execute at the recorded payload and surface the drift — never execute at an amount the approvers didn't sign.
  • Timeout vs. execution race: expiry sweep and final approval racing — the atomic transition must make "expired" beat "approved" (whichever commits first, the loser is a no-op).
  • Agent loop encountering an approval-gated operation: must log a SKIPPED AgentLog with the approval id, never execute.

Security & Privacy Considerations

  • Approval payloads may contain amounts and addresses — owner-scoped reads only; an approver sees only what's needed to make a decision.
  • Decisions are one-per-approver (@@unique), append-only (never editable/voidable except by the documented admin cancel).
  • Admin cancellation must be audited via AdminAuditLog with the reason.
  • The guard must be centralized — a test should assert that the withdraw/recurring/referral paths all pass through guardOperation (mirroring the import-graph tests in tests/integration/agent/strategy-follow.integration.test.ts), so a future money path can't silently bypass approvals.

Out of Scope

  • Multi-party signatures on-chain (the approval happens off-chain; the smart contract's own auth is unchanged).
  • Hierarchical chains of approval (A must approve before B is asked).
  • Approver weighting beyond "distinct users" (no 2-of-3-weighted schemes in v1).

Suggested Implementation Plan

  1. Schema + migration (policies, requests, approvals) + status enums.
  2. Pure status-machine + validation module (unit-tested: transitions, expiry, concurrency races).
  3. guardOperation interception in every money path; agent-loop skip handling.
  4. Expiry sweep job + dispatcher events.
  5. Routes, validators, docs/openapi.yaml, integration tests (sub-account parent/child approval lifecycle, double-approval race, timeout race).

Acceptance Criteria

  • ApprovalPolicy/ApprovalRequest/Approval models with the documented state machine; EXECUTED is terminal
  • guardOperation centralizes interception; a structural test fails if a money path bypasses it
  • Approval-gated withdrawals execute at the recorded payload after minApprovers distinct decisions; self-approval never counts toward a multi-approver threshold
  • Expiry sweep + atomic transition means expired always beats approved on a race (tested)
  • Parent/child sub-account operations respect child-scoped policies; decisions validated at decision time
  • Full lifecycle events on the dispatcher; docs/openapi.yaml updated
  • Admin audit log entries for admin cancellations
  • Unit + integration tests green

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignenhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions