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
- Schema + migration (policies, requests, approvals) + status enums.
- Pure status-machine + validation module (unit-tested: transitions, expiry, concurrency races).
guardOperation interception in every money path; agent-loop skip handling.
- Expiry sweep job + dispatcher events.
- Routes, validators,
docs/openapi.yaml, integration tests (sub-account parent/child approval lifecycle, double-approval race, timeout race).
Acceptance Criteria
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_APPROVALstate, 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 grantVIEW/DEPOSIT/WITHDRAW/MANAGE_STRATEGYpermissions to a child, enforced bysrc/middleware/subAccount.tsand the routes that use it (src/routes/sub-accounts.ts,src/middleware/subAccount.ts).Transactionrows (PENDING/CONFIRMED/FAILED/CANCELLED), the Stellar event listener (src/stellar/events.ts), and the agent-signed write path (src/stellar/contract.ts'sexecuteWriteContractCall).AdminAuditLogshows the precedent for append-only action recording, andsrc/services/webhookDispatcher.tsfans out domain events.Proposed Solution
1. Approval workflow model
Status machine:
PENDING → APPROVED → EXECUTED;PENDING → REJECTED(any hard rejection);PENDING → EXPIRED(timeout);PENDING → CANCELLED(by requester or an admin). OnceEXECUTEDit is terminal (on-chain finality — never un-execute, matching the referral-payout philosophy).2. Enforcement in the money path
guardOperation({ userId, actingAsUserId, permission, amount, assetSymbol, payload })that either returns the operation, or returns aPENDING_APPROVALtoken with the request id. Every caller must handle both returns; the agent loop must skip rather than silently execute.WITHDRAWon 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.src/index.tslike the otherschedule*jobs) transitionsEXPIREDrequests and emits a webhook event so stakeholders know the operation lapsed — the requester's intent must never silently vanish.executedTxIdlinks the approval to the resultingTransaction; the transaction'sTransactionType/memo must identify it as approval-gated for audit and the tax report (src/tax/service.ts).(requestId, approverUserId)+ a conditional UPDATE toAPPROVED/EXECUTED), never a check-then-act.3. Notifications & API surface
approval.requested,approval.approved,approval.rejected,approval.executed,approval.expired,approval.cancelled(add toWEBHOOK_EVENTS).requireAuth, respectingenforceUserAccessand 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'sMANAGE_STRATEGY-class authority.Edge Cases & Failure Modes
minApprovers > 1, and a test must assert it).payload(e.g. rate change), execute at the recorded payload and surface the drift — never execute at an amount the approvers didn't sign.SKIPPEDAgentLogwith the approval id, never execute.Security & Privacy Considerations
@@unique), append-only (never editable/voidable except by the documented admin cancel).AdminAuditLogwith the reason.guardOperation(mirroring the import-graph tests intests/integration/agent/strategy-follow.integration.test.ts), so a future money path can't silently bypass approvals.Out of Scope
Suggested Implementation Plan
guardOperationinterception in every money path; agent-loop skip handling.docs/openapi.yaml, integration tests (sub-account parent/child approval lifecycle, double-approval race, timeout race).Acceptance Criteria
ApprovalPolicy/ApprovalRequest/Approvalmodels with the documented state machine;EXECUTEDis terminalguardOperationcentralizes interception; a structural test fails if a money path bypasses itminApproversdistinct decisions; self-approval never counts toward a multi-approver thresholddocs/openapi.yamlupdated