diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 17069924..cc2eed19 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -111,6 +111,10 @@ jobs: - name: Install dependencies run: npm ci + - name: Run npm audit on production dependencies (HIGH and CRITICAL only) + # Development tooling has an independent, non-blocking full-tree report + # in dependency-audit.yml; production dependencies block this workflow. + run: npm audit --omit=dev --audit-level=high - name: Run blocking production dependency audit run: npm run audit:production continue-on-error: false diff --git a/docs/ACCOUNT_RECOVERY.md b/docs/ACCOUNT_RECOVERY.md new file mode 100644 index 00000000..e802a4cc --- /dev/null +++ b/docs/ACCOUNT_RECOVERY.md @@ -0,0 +1,72 @@ +# Guardian-based account access recovery (#535) + +This flow recovers the account's **sign-in key**, not custodial wallet key +material. It is deliberately fail-closed: there is no operator or platform +administrator override. + +## Setup + +An owner nominates between two and five other active platform users. Each +nominee must have a verified email address and must explicitly accept while +signed in before they count toward the policy. The owner chooses a quorum from +two to five; recovery stays unavailable until at least that many eligible +guardians have accepted. The recovery delay is fixed at 72 hours and cannot be +shortened or disabled. + +Version 1 supports platform-user guardians only. External email/phone contacts +are not accepted as guardians because the platform has no independent external +guardian identity and approval credential flow. Users should nominate reliable +people who can sign in and respond; if the accepted quorum is unreachable, +recovery cannot complete and the platform cannot bypass it. + +## Recovery lifecycle + +1. A claimant obtains a nonce for a new Stellar public signing key through + `/auth/challenge`, signs it, and submits the registered, verified account + email, key, and signature. The public endpoint returns the same response + for eligible and ineligible requests to avoid account enumeration. +2. The registered email receives an immediate alert and one-time cancellation + token. The token remains valid only while the request is pending, so the owner + can cancel at any time before completion. Every accepted guardian receives an email and must separately sign + in and approve. Initiation is also emitted to the account's durable/live + security event stream. +3. Reaching the configured quorum starts a mandatory 72-hour cooling-off + period. No recovery action is effective before it ends. +4. Until completion, the owner can cancel immediately with any active session + or with the one-time token sent to the registered email. Guardian consensus + is not required to cancel. +5. At expiry, the new public key replaces only the account's sign-in key (the + asset wallet address is unchanged), all existing sessions and refresh + credentials are revoked, API keys are revoked, and TOTP/passkey credentials + are reset. The claimant signs in using the normal wallet-signature flow and + re-enrolls any second factors. + +Nomination, acceptance, rejection, revocation, initiation, each approval, +cancellation, and completion are recorded in `AdminAuditLog`. Notifications +are queued through the durable outbound email queue; initiation also emits a +real-time account event. + +## Threat model and residual risk + +The quorum prevents a single guardian or the platform from unilaterally +recovering an account. The delay plus a registered-email cancellation path +provides the original owner time to notice and stop a takeover. Approval is an +individual action; no bulk or automatic approval exists. A request also fails +closed if it lacks a policy, accepted quorum, a verified registered email, or +an available new signing key. + +Guardian collusion or successful social engineering of enough guardians +remains a material risk. Multi-channel alerts and the fixed delay reduce, but +cannot eliminate, that risk. Users should select independent, reachable +guardians and keep their registered email secure. If the registered email is +compromised along with a sufficient guardian quorum, the owner may lose the +ability to cancel in time. + +Recovery is for a primary account only. It does not recover a custodial +wallet's private key and does not implement sub-account recovery. + +The migration rollback intentionally retains `users.authPublicKey`: copying a +recovered sign-in key back into `walletAddress` would change the user's asset +destination. Because completed credential recoveries cannot be reversed +safely, deployments must not roll back to an application version that only +authenticates against `walletAddress`. diff --git a/docs/API_KEYS.md b/docs/API_KEYS.md index 6f6d90c5..b73b0fd5 100644 --- a/docs/API_KEYS.md +++ b/docs/API_KEYS.md @@ -55,6 +55,7 @@ implies every admin scope, and the legacy `agent` implies `agent:read` and | `fiat:write` | money | enforced | — | Create fiat on/off-ramp orders. | — | `POST /api/v1/fiat/orders` | | `loans:write` | money | enforced | — | Originate and repay collateral loans. | Origination and repayment still pass the compliance freeze and sub-account permission checks. | `POST /api/v1/loans`, `POST /api/v1/loans/:loanId/repay` | | `recurring_deposits:write` | money | enforced | — | Manage recurring deposit plans. | — | `DELETE /api/v1/deposit/recurring/:id`, `PATCH /api/v1/deposit/recurring/:id`, `POST /api/v1/deposit/recurring` | +| `transfer:write` | money | enforced | — | Transfer assets to another platform user. | Transfers still pass compliance, frozen-account, balance, and sub-account permission checks. | `POST /api/v1/transfer` | | `vault:write` | money | enforced | — | Build vault transactions. | — | `POST /api/v1/vault/build-transaction` | | `withdraw:write` | money | enforced | — | Create withdrawals. | Requires allowWithdrawals on the key AND the platform kill-switch USER_API_KEY_WITHDRAWALS_ENABLED to be on. | `DELETE /api/v1/recurring-withdrawals/:id`, `PATCH /api/v1/recurring-withdrawals/:id`, `POST /api/v1/recurring-withdrawals`, `POST /api/v1/withdraw` | diff --git a/docs/INTERNAL_TRANSFERS.md b/docs/INTERNAL_TRANSFERS.md new file mode 100644 index 00000000..b1401859 --- /dev/null +++ b/docs/INTERNAL_TRANSFERS.md @@ -0,0 +1,39 @@ +# Platform-Internal Transfers (#546) + +`POST /api/v1/transfer` sends one asset between two users on the same network. +The recipient is resolved from their eight-character public referral code; raw +user IDs and wallet addresses are not accepted. Users can retrieve their code +from the existing referral-code endpoint. + +## Settlement and limits + +Settlement atomically reassigns the sender's existing active, unlocked Position +balance to the recipient under the same protocol. It creates paired +`INTERNAL_TRANSFER` transaction history and one durable transfer record, but +does not submit a Stellar transaction. This is a transfer of platform-managed +position ownership, not an on-chain withdrawal and not a separate cash balance. +The v1 path rejects collateral-locked positions, frozen accounts, cross-network +transfers, self-transfers, and transfers while a withdrawal is pending. An +active savings goal is also enforced unless the sender explicitly acknowledges +the impact. + +Transfers are immediate and final when the database transaction commits. V1 +does not provide a recall or reversal window. The API requires an +`Idempotency-Key`, and recipient-handle resolution is protected by the +authenticated sensitive-operation rate limit. A parent's transfer into a +linked sub-account requires the existing `DEPOSIT` permission. + +The compliance scorer evaluates each transfer, including velocity, +structuring, account age, amount anomaly, and a new-destination signal for a +first-time recipient. Scores at or above the existing withdrawal hold threshold +of 70 reject the transfer. Both transfer legs and audit entries are committed +with the position changes. Transaction responses expose the other party's +public handle only, not their internal user ID or wallet address. + +## Tax treatment + +An internal transfer is an ownership change, not a disposal. No tax disposal +is recorded; open cost-basis lots are split and carried to the recipient with +their original acquisition date and price. Amounts not represented by an open +lot (such as yield or legacy balance) are transferred without a new lot. See +[TAX_REPORT.md](./TAX_REPORT.md) for details. diff --git a/docs/TAX_REPORT.md b/docs/TAX_REPORT.md index 0d63a176..e5582d87 100644 --- a/docs/TAX_REPORT.md +++ b/docs/TAX_REPORT.md @@ -26,6 +26,23 @@ methods" below). The lot/disposal schema itself is unchanged from #284 — the method only decides _which_ open lots a withdrawal consumes, never the shape of what gets written. +## Platform-internal transfers (#546) + +An `INTERNAL_TRANSFER` is an ownership change between two platform users, not +a sale or withdrawal. It creates no `LotDisposal` and does not realize a gain +or loss. In the same database transaction as the paired transfer records, open +sender cost-basis lots are reduced and corresponding recipient lots are created +with the original acquisition price, price source, and acquisition date. Thus +later withdrawals by either user continue to use the original basis and +accounting-method rules. The source lot's consumed amount is not treated as a +taxable disposal. + +Transfer amounts without a corresponding open cost-basis lot (for example, +legacy balances or amounts attributable to yield) are credited without a new +lot and remain unpriced for lot reporting. This transfer path changes ledger +ownership only: it does not liquidate or move the underlying protocol assets +on-chain. Consult a tax professional for jurisdiction-specific treatment. + ## Accounting methods (#317) `src/tax/methods/` is a small interface (`CostBasisMethod.consumeLots`) with diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 8e3e8d46..da5d4ddd 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -50,6 +50,10 @@ tags: description: User-scoped outbound webhook endpoints (#368) - name: Net Worth description: Owner-private net worth and read-only external Stellar wallet links (#540) + - name: Recovery + description: Guardian-authorized account access recovery with a fixed 72-hour cooling-off period (#535) + - name: P2P Transfers + description: Idempotent platform-internal transfers by public referral handle (#546) # ─── Reusable components ────────────────────────────────────────────────────── components: @@ -1493,6 +1497,270 @@ components: # ─── Paths ──────────────────────────────────────────────────────────────────── paths: + /recovery/guardians: + get: + tags: [Recovery] + operationId: listRecoveryGuardians + summary: List nominated guardians, incoming nominations, and the recovery quorum + security: + - bearerAuth: [] + responses: + '200': + description: Guardian and policy state + '401': + description: Active session required + post: + tags: [Recovery] + operationId: nominateRecoveryGuardian + summary: Nominate another verified platform user as a guardian + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [guardianUserId] + properties: + guardianUserId: + type: string + format: uuid + requiredApprovals: + type: integer + minimum: 2 + maximum: 5 + responses: + '201': + description: Nomination created; guardian acceptance is still required + '400': + description: Invalid quorum or guardian eligibility + '401': + description: Active session required + /recovery/guardians/{id}/accept: + post: + tags: [Recovery] + operationId: acceptRecoveryGuardianNomination + summary: Explicitly accept a guardian nomination + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: Nomination accepted + '401': + description: Active session required + '404': + description: Pending nomination not found + /recovery/guardians/{id}/decline: + post: + tags: [Recovery] + operationId: declineRecoveryGuardianNomination + summary: Decline a guardian nomination + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '204': + description: Nomination declined + '401': + description: Active session required + /recovery/guardians/revoke: + post: + tags: [Recovery] + operationId: revokeRecoveryGuardian + summary: Revoke a nominated guardian + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [guardianId] + properties: + guardianId: { type: string, format: uuid } + responses: + '204': + description: Guardian revoked + '401': + description: Active session required + /recovery/requests: + get: + tags: [Recovery] + operationId: listRecoveryRequests + summary: List the caller's pending request and requests awaiting their guardian approval + security: + - bearerAuth: [] + responses: + '200': + description: Owner-scoped and guardian-scoped recovery requests + '401': + description: Active session required + /recovery/initiate: + post: + tags: [Recovery] + operationId: initiateAccountRecovery + summary: Request account access recovery using the registered email and a signed new Stellar key + description: Obtain a nonce from POST /auth/challenge for newAuthPublicKey, sign it, then submit the signature here. Returns the same generic response for eligible and ineligible account/email combinations. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, newAuthPublicKey, signature] + properties: + email: { type: string, format: email } + newAuthPublicKey: + { + type: string, + description: New Stellar public key used for future sign-in; does not alter the user's asset wallet address, + } + signature: + { + type: string, + description: Signature of the unexpired challenge nonce for newAuthPublicKey, + } + responses: + '202': + description: Generic response; eligible users and guardians are notified + '429': + description: Rate limit exceeded + /recovery/{id}/approve: + post: + tags: [Recovery] + operationId: approveAccountRecovery + summary: Record one guardian's independent approval + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '201': + description: Approval recorded; reaching quorum starts the fixed 72-hour delay + '403': + description: Caller is not an accepted guardian + '404': + description: Pending recovery not found + /recovery/{id}/reject: + post: + tags: [Recovery] + operationId: rejectAccountRecovery + summary: Reject a pending request, preventing completion + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: Request rejected + '403': + description: Caller is not an accepted guardian + '404': + description: Pending recovery not found + /recovery/{id}/cancel: + post: + tags: [Recovery] + operationId: cancelAccountRecovery + summary: Cancel a pending recovery immediately using any active owner session + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: Recovery cancelled without guardian approval + '401': + description: Active owner session required + /recovery/cancel: + post: + tags: [Recovery] + operationId: cancelAccountRecoveryByEmail + summary: Cancel a pending recovery using the one-time token sent to the registered email + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [requestId, token] + properties: + requestId: { type: string, format: uuid } + token: { type: string, minLength: 32 } + responses: + '200': + description: Recovery cancelled + '404': + description: Invalid or completed recovery request + /transfer: + post: + tags: [P2P Transfers] + operationId: createInternalTransfer + summary: Transfer an asset to another platform user by public handle + description: | + Reassigns ownership of existing, unlocked positions in one atomic database + transaction. No Stellar transaction is submitted. The recipient handle is + the user's eight-character public referral code; internal user IDs are not + accepted. The Idempotency-Key header is required. + security: + - bearerAuth: [] + parameters: + - name: Idempotency-Key + in: header + required: true + schema: { type: string, minLength: 1, maxLength: 255 } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recipientHandle, amount, assetSymbol] + properties: + recipientHandle: + type: string + pattern: '^[A-HJ-NP-Z2-9]{8}$' + description: Eight-character public referral code; case-insensitive. + amount: { type: number, minimum: 0, exclusiveMinimum: true } + assetSymbol: { type: string, minLength: 1, maxLength: 32 } + note: { type: string, maxLength: 280 } + acknowledgeGoalImpact: + type: boolean + description: Acknowledge that this transfer may put the balance below an active savings goal. + responses: + '201': + description: Transfer settled internally; no on-chain transaction was submitted + '400': + description: Invalid request or self-transfer + '404': + description: Recipient handle not found + '409': + description: Insufficient available balance, network mismatch, goal guardrail, or compliance hold + '423': + description: Sender or recipient account is frozen + '429': + description: Rate limit exceeded /sub-accounts/bulk: post: tags: @@ -2270,12 +2538,18 @@ paths: application/json: schema: type: object - required: [userId, destinationAddress, assetSymbol, cadence, confirmed] + required: + [userId, destinationAddress, assetSymbol, cadence, confirmed] properties: userId: { type: string, format: uuid } destinationAddress: { type: string } assetSymbol: { type: string } - amountMode: { type: string, enum: [FIXED, YIELD_ONLY, PERCENT_OF_BALANCE], default: FIXED } + amountMode: + { + type: string, + enum: [FIXED, YIELD_ONLY, PERCENT_OF_BALANCE], + default: FIXED, + } amount: { type: number, minimum: 0, exclusiveMinimum: true } percentage: { type: number, minimum: 0.01, maximum: 100 } minAmount: { type: number, minimum: 0, exclusiveMinimum: true } @@ -2287,7 +2561,12 @@ paths: '401': { description: Active owner session required } /recurring-withdrawals/{id}: parameters: - - { name: id, in: path, required: true, schema: { type: string, format: uuid } } + - { + name: id, + in: path, + required: true, + schema: { type: string, format: uuid }, + } get: tags: [Transactions] operationId: getRecurringWithdrawal @@ -2310,7 +2589,11 @@ paths: type: object properties: destinationAddress: { type: string } - amountMode: { type: string, enum: [FIXED, YIELD_ONLY, PERCENT_OF_BALANCE] } + amountMode: + { + type: string, + enum: [FIXED, YIELD_ONLY, PERCENT_OF_BALANCE], + } amount: { type: number, minimum: 0, exclusiveMinimum: true } percentage: { type: number, minimum: 0.01, maximum: 100 } cadence: { type: string, enum: [WEEKLY, BIWEEKLY, MONTHLY] } @@ -2332,7 +2615,12 @@ paths: '404': { description: Plan not found } /recurring-withdrawals/{id}/preview: parameters: - - { name: id, in: path, required: true, schema: { type: string, format: uuid } } + - { + name: id, + in: path, + required: true, + schema: { type: string, format: uuid }, + } post: tags: [Transactions] operationId: previewRecurringWithdrawal diff --git a/package-lock.json b/package-lock.json index f1eecb3a..cca72e03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1103,10 +1103,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", - "license": "Apache-2.0", + "version": "1.14.6", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.6.tgz", + "integrity": "sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g==", "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -5776,10 +5775,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "license": "MIT", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dependencies": { "balanced-match": "^4.0.2" }, @@ -7641,13 +7639,12 @@ "license": "MIT" }, "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "dev": true, - "license": "MIT", "dependencies": { - "minimist": "^1.2.5", + "minimist": "^1.2.8", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" @@ -7940,10 +7937,9 @@ } }, "node_modules/ip-address": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", - "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", - "license": "MIT", + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "engines": { "node": ">= 12" } @@ -9992,16 +9988,19 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { @@ -10577,10 +10576,9 @@ } }, "node_modules/smol-toml": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", - "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", - "license": "BSD-3-Clause", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", + "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", "engines": { "node": ">= 18" }, diff --git a/package.json b/package.json index 1dfeb3c9..6ea51be7 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ }, "overrides": { "axios": "^1.18.0", - "brace-expansion": "5.0.9", + "brace-expansion": "5.0.12", "ip-address": "^10.5.0", "eslint": { "js-yaml": "^4.3.1" diff --git a/prisma/migrations/20260828160000_smart_dca_engine/rollback.sql b/prisma/migrations/20260828160000_smart_dca_engine/rollback.sql new file mode 100644 index 00000000..30ac763f --- /dev/null +++ b/prisma/migrations/20260828160000_smart_dca_engine/rollback.sql @@ -0,0 +1,21 @@ +-- rollback.sql — reverse of 20260828160000_smart_dca_engine/migration.sql +-- Remove Smart DCA run history and plan fields. +-- WARNING: DATA LOSS — recurring deposit run history and Smart DCA settings are +-- permanently removed. Restore from a snapshot if this data is needed. +-- Safe to run multiple times. + +DROP TABLE IF EXISTS "recurring_deposit_runs"; + +ALTER TABLE "recurring_deposit_plans" + DROP COLUMN IF EXISTS "policy", + DROP COLUMN IF EXISTS "catchUpMode", + DROP COLUMN IF EXISTS "pauseOnDrawdownPct", + DROP COLUMN IF EXISTS "doubleOnDrawdown", + DROP COLUMN IF EXISTS "accumulatedRuns", + DROP COLUMN IF EXISTS "consecutiveFailures", + DROP COLUMN IF EXISTS "autoPauseReason", + DROP COLUMN IF EXISTS "allocationMap"; + +DROP TYPE IF EXISTS "RecurringDepositRunStatus"; +DROP TYPE IF EXISTS "CatchUpMode"; +DROP TYPE IF EXISTS "ContributionPolicy"; diff --git a/prisma/migrations/20261003_001_add_webhook_query_indexes/migration.sql b/prisma/migrations/20261003_001_add_webhook_query_indexes/migration.sql index a607f906..605f1507 100644 --- a/prisma/migrations/20261003_001_add_webhook_query_indexes/migration.sql +++ b/prisma/migrations/20261003_001_add_webhook_query_indexes/migration.sql @@ -1,8 +1,17 @@ -- This migration creates indexes to support user delivery history, -- subscription health counts, and bounded dead-letter replay queries. -- CONCURRENTLY removed for CI/test environments to allow transaction-wrapped execution. -CREATE INDEX IF NOT EXISTS "user_webhook_deliveries_endpointId_createdAt_idx" -ON "user_webhook_deliveries"("endpointId", "createdAt"); +-- user_webhook_deliveries is created by the later-sorting +-- 20261003_add_missing_models migration. Existing databases may already have +-- the table, so create the index here only when it is available; a follow-up +-- migration creates it after the table on fresh databases. +DO $$ +BEGIN + IF to_regclass('"user_webhook_deliveries"') IS NOT NULL THEN + CREATE INDEX IF NOT EXISTS "user_webhook_deliveries_endpointId_createdAt_idx" + ON "user_webhook_deliveries"("endpointId", "createdAt"); + END IF; +END $$; CREATE INDEX IF NOT EXISTS "webhook_deliveries_subscriptionId_status_createdAt_idx" ON "webhook_deliveries"("subscriptionId", "status", "createdAt"); diff --git a/prisma/migrations/20261003_001_add_webhook_query_indexes/rollback.sql b/prisma/migrations/20261003_001_add_webhook_query_indexes/rollback.sql index 6da3aa2e..834c1607 100644 --- a/prisma/migrations/20261003_001_add_webhook_query_indexes/rollback.sql +++ b/prisma/migrations/20261003_001_add_webhook_query_indexes/rollback.sql @@ -1,5 +1,5 @@ -- rollback.sql — reverse of 20260928180000_add_webhook_query_indexes/migration.sql -- Remove webhook indexes added for hot history, health, and replay queries. -DROP INDEX CONCURRENTLY IF EXISTS "user_webhook_deliveries_endpointId_createdAt_idx"; -DROP INDEX CONCURRENTLY IF EXISTS "webhook_deliveries_subscriptionId_status_createdAt_idx"; -DROP INDEX CONCURRENTLY IF EXISTS "webhook_dead_letters_subscriptionId_status_firstFailedAt_idx"; \ No newline at end of file +DROP INDEX IF EXISTS "user_webhook_deliveries_endpointId_createdAt_idx"; +DROP INDEX IF EXISTS "webhook_deliveries_subscriptionId_status_createdAt_idx"; +DROP INDEX IF EXISTS "webhook_dead_letters_subscriptionId_status_firstFailedAt_idx"; \ No newline at end of file diff --git a/prisma/migrations/20261003_add_missing_models/migration.sql b/prisma/migrations/20261003_add_missing_models/migration.sql index d6c402e6..57bdac40 100644 --- a/prisma/migrations/20261003_add_missing_models/migration.sql +++ b/prisma/migrations/20261003_add_missing_models/migration.sql @@ -3,7 +3,12 @@ -- ── Enums ──────────────────────────────────────────────────────────────────── -CREATE TYPE "TreasuryTier" AS ENUM ('HOT', 'WARM', 'COLD'); +DO $$ +BEGIN + IF to_regtype('"TreasuryTier"') IS NULL THEN + CREATE TYPE "TreasuryTier" AS ENUM ('HOT', 'WARM', 'COLD'); + END IF; +END $$; CREATE TYPE "CaseStatus" AS ENUM ( 'OPEN', 'TRIAGE', 'INVESTIGATING', 'ESCALATED', 'PENDING_SAR', 'SAR_FILED', 'CLEARED', 'CLOSED_NO_ACTION' @@ -18,7 +23,7 @@ ALTER TABLE "referral_conversions" -- ── AgentCircuitBreaker ─────────────────────────────────────────────────────── -CREATE TABLE "agent_circuit_breakers" ( +CREATE TABLE IF NOT EXISTS "agent_circuit_breakers" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "workflowKind" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'CLOSED', @@ -33,13 +38,9 @@ CREATE TABLE "agent_circuit_breakers" ( "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "agent_circuit_breakers_pkey" PRIMARY KEY ("id") ); -CREATE UNIQUE INDEX "agent_circuit_breakers_workflowKind_key" ON "agent_circuit_breakers"("workflowKind"); -CREATE INDEX "agent_circuit_breakers_status_idx" ON "agent_circuit_breakers"("status"); -CREATE INDEX "agent_circuit_breakers_workflowKind_idx" ON "agent_circuit_breakers"("workflowKind"); - -- ── ComplianceCase ──────────────────────────────────────────────────────────── -CREATE TABLE "compliance_cases" ( +CREATE TABLE IF NOT EXISTS "compliance_cases" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "userId" TEXT NOT NULL, "status" "CaseStatus" NOT NULL DEFAULT 'OPEN', @@ -52,13 +53,13 @@ CREATE TABLE "compliance_cases" ( CONSTRAINT "compliance_cases_pkey" PRIMARY KEY ("id"), CONSTRAINT "compliance_cases_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ); -CREATE INDEX "compliance_cases_userId_idx" ON "compliance_cases"("userId"); -CREATE INDEX "compliance_cases_status_idx" ON "compliance_cases"("status"); -CREATE INDEX "compliance_cases_createdAt_idx" ON "compliance_cases"("createdAt"); +CREATE INDEX IF NOT EXISTS "compliance_cases_userId_idx" ON "compliance_cases"("userId"); +CREATE INDEX IF NOT EXISTS "compliance_cases_status_idx" ON "compliance_cases"("status"); +CREATE INDEX IF NOT EXISTS "compliance_cases_createdAt_idx" ON "compliance_cases"("createdAt"); -- ── CaseEvent ──────────────────────────────────────────────────────────────── -CREATE TABLE "case_events" ( +CREATE TABLE IF NOT EXISTS "case_events" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "caseId" TEXT NOT NULL, "type" TEXT NOT NULL DEFAULT 'EVIDENCE', @@ -68,12 +69,12 @@ CREATE TABLE "case_events" ( CONSTRAINT "case_events_pkey" PRIMARY KEY ("id"), CONSTRAINT "case_events_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "compliance_cases"("id") ON DELETE CASCADE ); -CREATE INDEX "case_events_caseId_idx" ON "case_events"("caseId"); -CREATE INDEX "case_events_createdAt_idx" ON "case_events"("createdAt"); +CREATE INDEX IF NOT EXISTS "case_events_caseId_idx" ON "case_events"("caseId"); +CREATE INDEX IF NOT EXISTS "case_events_createdAt_idx" ON "case_events"("createdAt"); -- ── TravelRuleRecord ────────────────────────────────────────────────────────── -CREATE TABLE "travel_rule_records" ( +CREATE TABLE IF NOT EXISTS "travel_rule_records" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "transactionId" TEXT NOT NULL, "direction" TEXT NOT NULL, @@ -87,13 +88,13 @@ CREATE TABLE "travel_rule_records" ( "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "travel_rule_records_pkey" PRIMARY KEY ("id") ); -CREATE INDEX "travel_rule_records_transactionId_idx" ON "travel_rule_records"("transactionId"); -CREATE INDEX "travel_rule_records_status_idx" ON "travel_rule_records"("status"); -CREATE INDEX "travel_rule_records_createdAt_idx" ON "travel_rule_records"("createdAt"); +CREATE INDEX IF NOT EXISTS "travel_rule_records_transactionId_idx" ON "travel_rule_records"("transactionId"); +CREATE INDEX IF NOT EXISTS "travel_rule_records_status_idx" ON "travel_rule_records"("status"); +CREATE INDEX IF NOT EXISTS "travel_rule_records_createdAt_idx" ON "travel_rule_records"("createdAt"); -- ── EmailIdentity ───────────────────────────────────────────────────────────── -CREATE TABLE "email_identities" ( +CREATE TABLE IF NOT EXISTS "email_identities" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "userId" TEXT NOT NULL, "email" TEXT NOT NULL, @@ -107,12 +108,12 @@ CREATE TABLE "email_identities" ( CONSTRAINT "email_identities_userId_key" UNIQUE ("userId"), CONSTRAINT "email_identities_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ); -CREATE INDEX "email_identities_email_idx" ON "email_identities"("email"); -CREATE INDEX "email_identities_token_idx" ON "email_identities"("token"); +CREATE INDEX IF NOT EXISTS "email_identities_email_idx" ON "email_identities"("email"); +CREATE INDEX IF NOT EXISTS "email_identities_token_idx" ON "email_identities"("token"); -- ── AlertAck ───────────────────────────────────────────────────────────────── -CREATE TABLE "alert_acks" ( +CREATE TABLE IF NOT EXISTS "alert_acks" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "ruleId" TEXT NOT NULL, "userId" TEXT NOT NULL, @@ -122,13 +123,13 @@ CREATE TABLE "alert_acks" ( "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "alert_acks_pkey" PRIMARY KEY ("id") ); -CREATE INDEX "alert_acks_ruleId_idx" ON "alert_acks"("ruleId"); -CREATE INDEX "alert_acks_userId_idx" ON "alert_acks"("userId"); -CREATE INDEX "alert_acks_createdAt_idx" ON "alert_acks"("createdAt"); +CREATE INDEX IF NOT EXISTS "alert_acks_ruleId_idx" ON "alert_acks"("ruleId"); +CREATE INDEX IF NOT EXISTS "alert_acks_userId_idx" ON "alert_acks"("userId"); +CREATE INDEX IF NOT EXISTS "alert_acks_createdAt_idx" ON "alert_acks"("createdAt"); -- ── AlertFire ──────────────────────────────────────────────────────────────── -CREATE TABLE "alert_fires" ( +CREATE TABLE IF NOT EXISTS "alert_fires" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "ruleId" TEXT NOT NULL, "firedAt" TIMESTAMP(3) NOT NULL, @@ -137,12 +138,12 @@ CREATE TABLE "alert_fires" ( CONSTRAINT "alert_fires_pkey" PRIMARY KEY ("id"), CONSTRAINT "alert_fires_ackId_fkey" FOREIGN KEY ("ackId") REFERENCES "alert_acks"("id") ON DELETE SET NULL ); -CREATE INDEX "alert_fires_ruleId_idx" ON "alert_fires"("ruleId"); -CREATE INDEX "alert_fires_firedAt_idx" ON "alert_fires"("firedAt"); +CREATE INDEX IF NOT EXISTS "alert_fires_ruleId_idx" ON "alert_fires"("ruleId"); +CREATE INDEX IF NOT EXISTS "alert_fires_firedAt_idx" ON "alert_fires"("firedAt"); -- ── UserWebhookEndpoint ─────────────────────────────────────────────────────── -CREATE TABLE "user_webhook_endpoints" ( +CREATE TABLE IF NOT EXISTS "user_webhook_endpoints" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "userId" TEXT NOT NULL, "url" TEXT NOT NULL, @@ -154,12 +155,12 @@ CREATE TABLE "user_webhook_endpoints" ( CONSTRAINT "user_webhook_endpoints_pkey" PRIMARY KEY ("id"), CONSTRAINT "user_webhook_endpoints_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ); -CREATE INDEX "user_webhook_endpoints_userId_idx" ON "user_webhook_endpoints"("userId"); -CREATE INDEX "user_webhook_endpoints_isActive_idx" ON "user_webhook_endpoints"("isActive"); +CREATE INDEX IF NOT EXISTS "user_webhook_endpoints_userId_idx" ON "user_webhook_endpoints"("userId"); +CREATE INDEX IF NOT EXISTS "user_webhook_endpoints_isActive_idx" ON "user_webhook_endpoints"("isActive"); -- ── UserWebhookDelivery ─────────────────────────────────────────────────────── -CREATE TABLE "user_webhook_deliveries" ( +CREATE TABLE IF NOT EXISTS "user_webhook_deliveries" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "endpointId" TEXT NOT NULL, "eventId" TEXT NOT NULL, @@ -171,14 +172,14 @@ CREATE TABLE "user_webhook_deliveries" ( CONSTRAINT "user_webhook_deliveries_pkey" PRIMARY KEY ("id"), CONSTRAINT "user_webhook_deliveries_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "user_webhook_endpoints"("id") ON DELETE CASCADE ); -CREATE INDEX "user_webhook_deliveries_endpointId_idx" ON "user_webhook_deliveries"("endpointId"); -CREATE INDEX "user_webhook_deliveries_eventId_idx" ON "user_webhook_deliveries"("eventId"); -CREATE INDEX "user_webhook_deliveries_status_idx" ON "user_webhook_deliveries"("status"); -CREATE INDEX "user_webhook_deliveries_createdAt_idx" ON "user_webhook_deliveries"("createdAt"); +CREATE INDEX IF NOT EXISTS "user_webhook_deliveries_endpointId_idx" ON "user_webhook_deliveries"("endpointId"); +CREATE INDEX IF NOT EXISTS "user_webhook_deliveries_eventId_idx" ON "user_webhook_deliveries"("eventId"); +CREATE INDEX IF NOT EXISTS "user_webhook_deliveries_status_idx" ON "user_webhook_deliveries"("status"); +CREATE INDEX IF NOT EXISTS "user_webhook_deliveries_createdAt_idx" ON "user_webhook_deliveries"("createdAt"); -- ── UserEvent ───────────────────────────────────────────────────────────────── -CREATE TABLE "user_events" ( +CREATE TABLE IF NOT EXISTS "user_events" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "userId" TEXT NOT NULL, "seq" BIGINT NOT NULL, @@ -188,13 +189,13 @@ CREATE TABLE "user_events" ( "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "user_events_pkey" PRIMARY KEY ("id") ); -CREATE INDEX "user_events_userId_seq_idx" ON "user_events"("userId", "seq"); -CREATE INDEX "user_events_userId_topic_idx" ON "user_events"("userId", "topic"); -CREATE INDEX "user_events_createdAt_idx" ON "user_events"("createdAt"); +CREATE INDEX IF NOT EXISTS "user_events_userId_seq_idx" ON "user_events"("userId", "seq"); +CREATE INDEX IF NOT EXISTS "user_events_userId_topic_idx" ON "user_events"("userId", "topic"); +CREATE INDEX IF NOT EXISTS "user_events_createdAt_idx" ON "user_events"("createdAt"); -- ── UserEventSequence ───────────────────────────────────────────────────────── -CREATE TABLE "user_event_sequences" ( +CREATE TABLE IF NOT EXISTS "user_event_sequences" ( "userId" TEXT NOT NULL, "lastSeq" BIGINT NOT NULL DEFAULT 0, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -203,7 +204,7 @@ CREATE TABLE "user_event_sequences" ( -- ── TreasurySweepPolicy ─────────────────────────────────────────────────────── -CREATE TABLE "treasury_sweep_policies" ( +CREATE TABLE IF NOT EXISTS "treasury_sweep_policies" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "fromTier" "TreasuryTier" NOT NULL, "toTier" "TreasuryTier" NOT NULL, @@ -217,12 +218,12 @@ CREATE TABLE "treasury_sweep_policies" ( "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "treasury_sweep_policies_pkey" PRIMARY KEY ("id") ); -CREATE INDEX "treasury_sweep_policies_fromTier_toTier_idx" ON "treasury_sweep_policies"("fromTier", "toTier"); -CREATE INDEX "treasury_sweep_policies_isActive_idx" ON "treasury_sweep_policies"("isActive"); +CREATE INDEX IF NOT EXISTS "treasury_sweep_policies_fromTier_toTier_idx" ON "treasury_sweep_policies"("fromTier", "toTier"); +CREATE INDEX IF NOT EXISTS "treasury_sweep_policies_isActive_idx" ON "treasury_sweep_policies"("isActive"); -- ── TreasuryAccount ─────────────────────────────────────────────────────────── -CREATE TABLE "treasury_accounts" ( +CREATE TABLE IF NOT EXISTS "treasury_accounts" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "publicKey" TEXT NOT NULL, "tier" "TreasuryTier" NOT NULL, @@ -232,12 +233,12 @@ CREATE TABLE "treasury_accounts" ( "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "treasury_accounts_pkey" PRIMARY KEY ("id") ); -CREATE INDEX "treasury_accounts_tier_idx" ON "treasury_accounts"("tier"); -CREATE INDEX "treasury_accounts_isActive_idx" ON "treasury_accounts"("isActive"); +CREATE INDEX IF NOT EXISTS "treasury_accounts_tier_idx" ON "treasury_accounts"("tier"); +CREATE INDEX IF NOT EXISTS "treasury_accounts_isActive_idx" ON "treasury_accounts"("isActive"); -- ── TreasurySweep ───────────────────────────────────────────────────────────── -CREATE TABLE "treasury_sweeps" ( +CREATE TABLE IF NOT EXISTS "treasury_sweeps" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "fromTier" "TreasuryTier" NOT NULL, "toTier" "TreasuryTier" NOT NULL, @@ -252,13 +253,13 @@ CREATE TABLE "treasury_sweeps" ( CONSTRAINT "treasury_sweeps_pkey" PRIMARY KEY ("id"), CONSTRAINT "treasury_sweeps_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "treasury_accounts"("id") ON DELETE SET NULL ); -CREATE INDEX "treasury_sweeps_status_idx" ON "treasury_sweeps"("status"); -CREATE INDEX "treasury_sweeps_fromTier_toTier_idx" ON "treasury_sweeps"("fromTier", "toTier"); -CREATE INDEX "treasury_sweeps_createdAt_idx" ON "treasury_sweeps"("createdAt"); +CREATE INDEX IF NOT EXISTS "treasury_sweeps_status_idx" ON "treasury_sweeps"("status"); +CREATE INDEX IF NOT EXISTS "treasury_sweeps_fromTier_toTier_idx" ON "treasury_sweeps"("fromTier", "toTier"); +CREATE INDEX IF NOT EXISTS "treasury_sweeps_createdAt_idx" ON "treasury_sweeps"("createdAt"); -- ── MultisigEnvelope ────────────────────────────────────────────────────────── -CREATE TABLE "multisig_envelopes" ( +CREATE TABLE IF NOT EXISTS "multisig_envelopes" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "sweepId" TEXT NOT NULL, "publicKey" TEXT NOT NULL, @@ -271,13 +272,13 @@ CREATE TABLE "multisig_envelopes" ( CONSTRAINT "multisig_envelopes_pkey" PRIMARY KEY ("id"), CONSTRAINT "multisig_envelopes_sweepId_fkey" FOREIGN KEY ("sweepId") REFERENCES "treasury_sweeps"("id") ON DELETE CASCADE ); -CREATE INDEX "multisig_envelopes_sweepId_idx" ON "multisig_envelopes"("sweepId"); -CREATE INDEX "multisig_envelopes_status_idx" ON "multisig_envelopes"("status"); -CREATE INDEX "multisig_envelopes_expiresAt_idx" ON "multisig_envelopes"("expiresAt"); +CREATE INDEX IF NOT EXISTS "multisig_envelopes_sweepId_idx" ON "multisig_envelopes"("sweepId"); +CREATE INDEX IF NOT EXISTS "multisig_envelopes_status_idx" ON "multisig_envelopes"("status"); +CREATE INDEX IF NOT EXISTS "multisig_envelopes_expiresAt_idx" ON "multisig_envelopes"("expiresAt"); -- ── SignerRotation ──────────────────────────────────────────────────────────── -CREATE TABLE "signer_rotations" ( +CREATE TABLE IF NOT EXISTS "signer_rotations" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "treasuryAccountId" TEXT NOT NULL, "oldSignerKey" TEXT NOT NULL, @@ -291,13 +292,13 @@ CREATE TABLE "signer_rotations" ( CONSTRAINT "signer_rotations_pkey" PRIMARY KEY ("id"), CONSTRAINT "signer_rotations_treasuryAccountId_fkey" FOREIGN KEY ("treasuryAccountId") REFERENCES "treasury_accounts"("id") ON DELETE CASCADE ); -CREATE INDEX "signer_rotations_treasuryAccountId_idx" ON "signer_rotations"("treasuryAccountId"); -CREATE INDEX "signer_rotations_status_idx" ON "signer_rotations"("status"); -CREATE INDEX "signer_rotations_createdAt_idx" ON "signer_rotations"("createdAt"); +CREATE INDEX IF NOT EXISTS "signer_rotations_treasuryAccountId_idx" ON "signer_rotations"("treasuryAccountId"); +CREATE INDEX IF NOT EXISTS "signer_rotations_status_idx" ON "signer_rotations"("status"); +CREATE INDEX IF NOT EXISTS "signer_rotations_createdAt_idx" ON "signer_rotations"("createdAt"); -- ── NotificationPreference ──────────────────────────────────────────────────── -CREATE TABLE "notification_preferences" ( +CREATE TABLE IF NOT EXISTS "notification_preferences" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "userId" TEXT NOT NULL, "channel" TEXT NOT NULL, @@ -312,13 +313,13 @@ CREATE TABLE "notification_preferences" ( CONSTRAINT "notification_preferences_userId_channel_category_key" UNIQUE ("userId", "channel", "category"), CONSTRAINT "notification_preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ); -CREATE INDEX "notification_preferences_userId_idx" ON "notification_preferences"("userId"); -CREATE INDEX "notification_preferences_channel_idx" ON "notification_preferences"("channel"); -CREATE INDEX "notification_preferences_category_idx" ON "notification_preferences"("category"); +CREATE INDEX IF NOT EXISTS "notification_preferences_userId_idx" ON "notification_preferences"("userId"); +CREATE INDEX IF NOT EXISTS "notification_preferences_channel_idx" ON "notification_preferences"("channel"); +CREATE INDEX IF NOT EXISTS "notification_preferences_category_idx" ON "notification_preferences"("category"); -- ── NotificationPreferenceAuditLog ─────────────────────────────────────────── -CREATE TABLE "notification_preference_audit_logs" ( +CREATE TABLE IF NOT EXISTS "notification_preference_audit_logs" ( "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, "userId" TEXT NOT NULL, "action" TEXT NOT NULL, @@ -331,5 +332,5 @@ CREATE TABLE "notification_preference_audit_logs" ( "metadata" JSONB, CONSTRAINT "notification_preference_audit_logs_pkey" PRIMARY KEY ("id") ); -CREATE INDEX "notification_preference_audit_logs_userId_idx" ON "notification_preference_audit_logs"("userId"); -CREATE INDEX "notification_preference_audit_logs_changedAt_idx" ON "notification_preference_audit_logs"("changedAt"); +CREATE INDEX IF NOT EXISTS "notification_preference_audit_logs_userId_idx" ON "notification_preference_audit_logs"("userId"); +CREATE INDEX IF NOT EXISTS "notification_preference_audit_logs_changedAt_idx" ON "notification_preference_audit_logs"("changedAt"); diff --git a/prisma/migrations/20261003_add_missing_models/rollback.sql b/prisma/migrations/20261003_add_missing_models/rollback.sql index 261665b9..3331c791 100644 --- a/prisma/migrations/20261003_add_missing_models/rollback.sql +++ b/prisma/migrations/20261003_add_missing_models/rollback.sql @@ -4,15 +4,18 @@ -- ── Drop new tables (in reverse dependency order) ───────────────────────────── +DROP INDEX IF EXISTS "signer_rotations_createdAt_idx"; +DROP INDEX IF EXISTS "signer_rotations_status_idx"; +DROP INDEX IF EXISTS "signer_rotations_treasuryAccountId_idx"; +DROP INDEX IF EXISTS "multisig_envelopes_sweepId_idx"; +DROP INDEX IF EXISTS "treasury_sweeps_createdAt_idx"; +DROP INDEX IF EXISTS "treasury_sweeps_fromTier_toTier_idx"; +DROP INDEX IF EXISTS "treasury_sweep_policies_isActive_idx"; +DROP INDEX IF EXISTS "treasury_sweep_policies_fromTier_toTier_idx"; +DROP INDEX IF EXISTS "user_events_userId_topic_idx"; + DROP TABLE IF EXISTS "notification_preference_audit_logs"; DROP TABLE IF EXISTS "notification_preferences"; -DROP TABLE IF EXISTS "signer_rotations"; -DROP TABLE IF EXISTS "multisig_envelopes"; -DROP TABLE IF EXISTS "treasury_sweeps"; -DROP TABLE IF EXISTS "treasury_accounts"; -DROP TABLE IF EXISTS "treasury_sweep_policies"; -DROP TABLE IF EXISTS "user_event_sequences"; -DROP TABLE IF EXISTS "user_events"; DROP TABLE IF EXISTS "user_webhook_deliveries"; DROP TABLE IF EXISTS "user_webhook_endpoints"; DROP TABLE IF EXISTS "alert_fires"; @@ -21,7 +24,6 @@ DROP TABLE IF EXISTS "email_identities"; DROP TABLE IF EXISTS "travel_rule_records"; DROP TABLE IF EXISTS "case_events"; DROP TABLE IF EXISTS "compliance_cases"; -DROP TABLE IF EXISTS "agent_circuit_breakers"; -- ── Drop new columns on existing tables ─────────────────────────────────────── @@ -33,4 +35,3 @@ ALTER TABLE "referral_conversions" -- ── Drop enums ──────────────────────────────────────────────────────────────── DROP TYPE IF EXISTS "CaseStatus"; -DROP TYPE IF EXISTS "TreasuryTier"; diff --git a/prisma/migrations/20261008090000_recurring_withdrawals/rollback.sql b/prisma/migrations/20261008090000_recurring_withdrawals/rollback.sql new file mode 100644 index 00000000..db99dd47 --- /dev/null +++ b/prisma/migrations/20261008090000_recurring_withdrawals/rollback.sql @@ -0,0 +1,10 @@ +-- rollback.sql — reverse of 20261008090000_recurring_withdrawals/migration.sql +-- Remove recurring withdrawal plans and their migration-specific enum types. +-- WARNING: DATA LOSS — recurring withdrawal plans are permanently removed. +-- Restore from a snapshot if this data is needed. +-- Safe to run multiple times. + +DROP TABLE IF EXISTS "recurring_withdrawal_plans"; + +DROP TYPE IF EXISTS "RecurringWithdrawalPlanStatus"; +DROP TYPE IF EXISTS "WithdrawalAmountMode"; diff --git a/prisma/migrations/20261008091000_support_tickets/rollback.sql b/prisma/migrations/20261008091000_support_tickets/rollback.sql new file mode 100644 index 00000000..7035d491 --- /dev/null +++ b/prisma/migrations/20261008091000_support_tickets/rollback.sql @@ -0,0 +1,13 @@ +-- rollback.sql — reverse of 20261008091000_support_tickets/migration.sql +-- Remove support tickets, their messages, and migration-specific enum types. +-- WARNING: DATA LOSS — support tickets and messages are permanently removed. +-- Restore from a snapshot if this data is needed. +-- Safe to run multiple times. + +DROP TABLE IF EXISTS "ticket_messages"; +DROP TABLE IF EXISTS "support_tickets"; + +DROP TYPE IF EXISTS "MessageAuthorRole"; +DROP TYPE IF EXISTS "TicketPriority"; +DROP TYPE IF EXISTS "TicketStatus"; +DROP TYPE IF EXISTS "TicketCategory"; diff --git a/prisma/migrations/20261008092000_webauthn/rollback.sql b/prisma/migrations/20261008092000_webauthn/rollback.sql new file mode 100644 index 00000000..1e73eeab --- /dev/null +++ b/prisma/migrations/20261008092000_webauthn/rollback.sql @@ -0,0 +1,8 @@ +-- rollback.sql — reverse of 20261008092000_webauthn/migration.sql +-- Remove WebAuthn credentials and outstanding challenges. +-- WARNING: DATA LOSS — registered WebAuthn credentials and pending challenges +-- are permanently removed. Users will need to register credentials again. +-- Safe to run multiple times. + +DROP TABLE IF EXISTS "webauthn_challenges"; +DROP TABLE IF EXISTS "webauthn_credentials"; diff --git a/prisma/migrations/20261019_001_guardian_social_recovery/migration.sql b/prisma/migrations/20261019_001_guardian_social_recovery/migration.sql new file mode 100644 index 00000000..5f243a8e --- /dev/null +++ b/prisma/migrations/20261019_001_guardian_social_recovery/migration.sql @@ -0,0 +1,79 @@ +CREATE TYPE "RecoveryRequestStatus" AS ENUM ('PENDING', 'CANCELLED', 'REJECTED', 'COMPLETED'); + +ALTER TABLE "users" ADD COLUMN "authPublicKey" TEXT; +UPDATE "users" SET "authPublicKey" = "walletAddress"; +ALTER TABLE "users" ALTER COLUMN "authPublicKey" SET NOT NULL; +CREATE UNIQUE INDEX "users_authPublicKey_key" ON "users"("authPublicKey"); + +CREATE TABLE "recovery_policies" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, + "ownerUserId" TEXT NOT NULL, + "requiredApprovals" INTEGER NOT NULL DEFAULT 2, + "recoveryDelayHours" INTEGER NOT NULL DEFAULT 72, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "recovery_policies_pkey" PRIMARY KEY ("id"), + CONSTRAINT "recovery_policies_ownerUserId_fkey" + FOREIGN KEY ("ownerUserId") REFERENCES "users"("id") ON DELETE CASCADE, + CONSTRAINT "recovery_policies_quorum_check" CHECK ("requiredApprovals" BETWEEN 2 AND 5), + CONSTRAINT "recovery_policies_delay_check" CHECK ("recoveryDelayHours" = 72) +); +CREATE UNIQUE INDEX "recovery_policies_ownerUserId_key" + ON "recovery_policies"("ownerUserId"); + +CREATE TABLE "recovery_guardians" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, + "ownerUserId" TEXT NOT NULL, + "guardianUserId" TEXT NOT NULL, + "acceptedAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "recovery_guardians_pkey" PRIMARY KEY ("id"), + CONSTRAINT "recovery_guardians_ownerUserId_fkey" + FOREIGN KEY ("ownerUserId") REFERENCES "users"("id") ON DELETE CASCADE, + CONSTRAINT "recovery_guardians_guardianUserId_fkey" + FOREIGN KEY ("guardianUserId") REFERENCES "users"("id") ON DELETE CASCADE, + CONSTRAINT "recovery_guardians_not_self_check" CHECK ("ownerUserId" <> "guardianUserId") +); +CREATE UNIQUE INDEX "recovery_guardians_ownerUserId_guardianUserId_key" + ON "recovery_guardians"("ownerUserId", "guardianUserId"); +CREATE INDEX "recovery_guardians_guardianUserId_acceptedAt_idx" + ON "recovery_guardians"("guardianUserId", "acceptedAt"); + +CREATE TABLE "recovery_requests" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, + "ownerUserId" TEXT NOT NULL, + "proposedAuthPublicKey" TEXT NOT NULL, + "status" "RecoveryRequestStatus" NOT NULL DEFAULT 'PENDING', + "initiatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "quorumReachedAt" TIMESTAMP(3), + "completesAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "cancellationTokenHash" TEXT, + "cancelledAt" TIMESTAMP(3), + CONSTRAINT "recovery_requests_pkey" PRIMARY KEY ("id"), + CONSTRAINT "recovery_requests_ownerUserId_fkey" + FOREIGN KEY ("ownerUserId") REFERENCES "users"("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX "recovery_requests_one_pending_per_owner" + ON "recovery_requests"("ownerUserId") WHERE "status" = 'PENDING'; +CREATE INDEX "recovery_requests_ownerUserId_status_idx" + ON "recovery_requests"("ownerUserId", "status"); +CREATE INDEX "recovery_requests_status_completesAt_idx" + ON "recovery_requests"("status", "completesAt"); + +CREATE TABLE "recovery_approvals" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, + "requestId" TEXT NOT NULL, + "guardianId" TEXT NOT NULL, + "approvedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "recovery_approvals_pkey" PRIMARY KEY ("id"), + CONSTRAINT "recovery_approvals_requestId_fkey" + FOREIGN KEY ("requestId") REFERENCES "recovery_requests"("id") ON DELETE CASCADE, + CONSTRAINT "recovery_approvals_guardianId_fkey" + FOREIGN KEY ("guardianId") REFERENCES "recovery_guardians"("id") ON DELETE CASCADE +); +CREATE UNIQUE INDEX "recovery_approvals_requestId_guardianId_key" + ON "recovery_approvals"("requestId", "guardianId"); +CREATE INDEX "recovery_approvals_guardianId_idx" + ON "recovery_approvals"("guardianId"); diff --git a/prisma/migrations/20261019_001_guardian_social_recovery/rollback.sql b/prisma/migrations/20261019_001_guardian_social_recovery/rollback.sql new file mode 100644 index 00000000..592d9522 --- /dev/null +++ b/prisma/migrations/20261019_001_guardian_social_recovery/rollback.sql @@ -0,0 +1,8 @@ +DROP TABLE "recovery_approvals"; +DROP TABLE "recovery_requests"; +DROP TABLE "recovery_guardians"; +DROP TABLE "recovery_policies"; +DROP TYPE "RecoveryRequestStatus"; +-- Keep users.authPublicKey: recovery changes this credential independently of +-- walletAddress, and dropping it would lock recovered users out or redirect +-- their asset destination if the column were copied back. diff --git a/prisma/migrations/20261019_002_peer_to_peer_transfers/migration.sql b/prisma/migrations/20261019_002_peer_to_peer_transfers/migration.sql new file mode 100644 index 00000000..5e5b9d45 --- /dev/null +++ b/prisma/migrations/20261019_002_peer_to_peer_transfers/migration.sql @@ -0,0 +1,39 @@ +-- #546 Peer-to-peer transfers. Transfers reassign existing position ownership; +-- they never mint a balance or submit an on-chain operation. +ALTER TYPE "TransactionType" ADD VALUE IF NOT EXISTS 'INTERNAL_TRANSFER'; + +ALTER TABLE "transactions" ADD COLUMN "internalTransferId" TEXT; + +CREATE TABLE "internal_transfers" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid()::TEXT, + "senderUserId" TEXT NOT NULL, + "recipientUserId" TEXT NOT NULL, + "senderHandle" TEXT NOT NULL, + "recipientHandle" TEXT NOT NULL, + "assetSymbol" TEXT NOT NULL, + "amount" DECIMAL(36,18) NOT NULL, + "note" VARCHAR(280), + "riskScore" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "internal_transfers_pkey" PRIMARY KEY ("id"), + CONSTRAINT "internal_transfers_positive_amount_check" CHECK ("amount" > 0), + CONSTRAINT "internal_transfers_distinct_users_check" CHECK ("senderUserId" <> "recipientUserId"), + CONSTRAINT "internal_transfers_senderUserId_fkey" + FOREIGN KEY ("senderUserId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "internal_transfers_recipientUserId_fkey" + FOREIGN KEY ("recipientUserId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +ALTER TABLE "transactions" + ADD CONSTRAINT "transactions_internalTransferId_fkey" + FOREIGN KEY ("internalTransferId") REFERENCES "internal_transfers"("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +CREATE INDEX "transactions_internalTransferId_idx" + ON "transactions"("internalTransferId"); +CREATE INDEX "internal_transfers_senderUserId_createdAt_idx" + ON "internal_transfers"("senderUserId", "createdAt"); +CREATE INDEX "internal_transfers_recipientUserId_createdAt_idx" + ON "internal_transfers"("recipientUserId", "createdAt"); +CREATE INDEX "internal_transfers_senderUserId_recipientHandle_idx" + ON "internal_transfers"("senderUserId", "recipientHandle"); diff --git a/prisma/migrations/20261019_002_peer_to_peer_transfers/rollback.sql b/prisma/migrations/20261019_002_peer_to_peer_transfers/rollback.sql new file mode 100644 index 00000000..1107b95d --- /dev/null +++ b/prisma/migrations/20261019_002_peer_to_peer_transfers/rollback.sql @@ -0,0 +1,38 @@ +-- Refuse to discard transfer history during rollback. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "transactions" WHERE "type" = 'INTERNAL_TRANSFER' + ) OR EXISTS (SELECT 1 FROM "internal_transfers") THEN + RAISE EXCEPTION 'Cannot roll back peer-to-peer transfers while transfer history exists'; + END IF; +END $$; + +ALTER TABLE "transactions" + DROP CONSTRAINT IF EXISTS "transactions_internalTransferId_fkey"; +DROP INDEX IF EXISTS "transactions_internalTransferId_idx"; +DROP INDEX IF EXISTS "internal_transfers_senderUserId_createdAt_idx"; +DROP INDEX IF EXISTS "internal_transfers_recipientUserId_createdAt_idx"; +DROP INDEX IF EXISTS "internal_transfers_senderUserId_recipientHandle_idx"; +ALTER TABLE "transactions" DROP COLUMN IF EXISTS "internalTransferId"; +DROP TABLE IF EXISTS "internal_transfers"; + +ALTER TABLE "transactions" ALTER COLUMN "type" TYPE TEXT; +CREATE TYPE "TransactionType_old" AS ENUM ( + 'DEPOSIT', + 'WITHDRAWAL', + 'YIELD_CLAIM', + 'REBALANCE', + 'SWAP', + 'REFERRAL_REWARD', + 'INBOUND_TRANSFER', + 'CLAIMABLE_BALANCE_CLAIM', + 'LOAN_DISBURSE', + 'LOAN_REPAYMENT', + 'LOAN_LIQUIDATION' +); +ALTER TABLE "transactions" + ALTER COLUMN "type" TYPE "TransactionType_old" + USING ("type"::"TransactionType_old"); +DROP TYPE "TransactionType"; +ALTER TYPE "TransactionType_old" RENAME TO "TransactionType"; diff --git a/prisma/migrations/20261019_003_webhook_delivery_query_index/migration.sql b/prisma/migrations/20261019_003_webhook_delivery_query_index/migration.sql new file mode 100644 index 00000000..381c6c93 --- /dev/null +++ b/prisma/migrations/20261019_003_webhook_delivery_query_index/migration.sql @@ -0,0 +1,4 @@ +-- Migration: webhook delivery query index +-- Create the index skipped by the earlier migration when this table did not yet exist. +CREATE INDEX IF NOT EXISTS "user_webhook_deliveries_endpointId_createdAt_idx" +ON "user_webhook_deliveries"("endpointId", "createdAt"); diff --git a/prisma/migrations/20261019_003_webhook_delivery_query_index/rollback.sql b/prisma/migrations/20261019_003_webhook_delivery_query_index/rollback.sql new file mode 100644 index 00000000..d368bf3a --- /dev/null +++ b/prisma/migrations/20261019_003_webhook_delivery_query_index/rollback.sql @@ -0,0 +1,4 @@ +-- rollback.sql — reverse of 20261019_003_webhook_delivery_query_index/migration.sql +-- Remove the index added after its table was created. +-- Safe to run multiple times. +DROP INDEX IF EXISTS "user_webhook_deliveries_endpointId_createdAt_idx"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d0078129..868ec86c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,6 +38,7 @@ enum TransactionType { LOAN_DISBURSE LOAN_REPAYMENT LOAN_LIQUIDATION + INTERNAL_TRANSFER } enum TransactionStatus { @@ -47,6 +48,13 @@ enum TransactionStatus { CANCELLED } +enum RecoveryRequestStatus { + PENDING + CANCELLED + REJECTED + COMPLETED +} + enum PositionStatus { ACTIVE CLOSED @@ -308,6 +316,9 @@ enum ErasureRequestStatus { model User { id String @id @default(uuid()) walletAddress String @unique + /// Wallet-signature credential. Kept separate from walletAddress so account + /// access recovery cannot redirect asset transfers or alter custody details. + authPublicKey String @unique network Network @default(MAINNET) displayName String? email String? @unique @@ -343,6 +354,8 @@ model User { sessions Session[] positions Position[] transactions Transaction[] + sentInternalTransfers InternalTransfer[] @relation("InternalTransferSender") + receivedInternalTransfers InternalTransfer[] @relation("InternalTransferRecipient") agentLogs AgentLog[] webhookSubscriptions WebhookSubscription[] fiatOrders FiatOrder[] @@ -377,6 +390,10 @@ model User { recurringWithdrawalPlans RecurringWithdrawalPlan[] supportTickets SupportTicket[] webAuthnCredentials WebAuthnCredential[] + recoveryPolicy RecoveryPolicy? + recoveryGuardians RecoveryGuardian[] @relation("RecoveryOwner") + guardianNominations RecoveryGuardian[] @relation("RecoveryGuardianUser") + recoveryRequests RecoveryRequest[] @@map("users") } @@ -516,6 +533,70 @@ model Session { @@map("sessions") } +model RecoveryPolicy { + id String @id @default(uuid()) + ownerUserId String @unique + requiredApprovals Int @default(2) + recoveryDelayHours Int @default(72) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade) + + @@map("recovery_policies") +} + +model RecoveryGuardian { + id String @id @default(uuid()) + ownerUserId String + guardianUserId String + acceptedAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) + + owner User @relation("RecoveryOwner", fields: [ownerUserId], references: [id], onDelete: Cascade) + guardianUser User @relation("RecoveryGuardianUser", fields: [guardianUserId], references: [id], onDelete: Cascade) + approvals RecoveryApproval[] + + @@unique([ownerUserId, guardianUserId]) + @@index([guardianUserId, acceptedAt]) + @@map("recovery_guardians") +} + +model RecoveryRequest { + id String @id @default(uuid()) + ownerUserId String + proposedAuthPublicKey String + status RecoveryRequestStatus @default(PENDING) + initiatedAt DateTime @default(now()) + quorumReachedAt DateTime? + completesAt DateTime? + completedAt DateTime? + cancellationTokenHash String? + cancelledAt DateTime? + + owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade) + approvals RecoveryApproval[] + + @@index([ownerUserId, status]) + @@index([status, completesAt]) + @@map("recovery_requests") +} + +model RecoveryApproval { + id String @id @default(uuid()) + requestId String + guardianId String + approvedAt DateTime @default(now()) + + request RecoveryRequest @relation(fields: [requestId], references: [id], onDelete: Cascade) + guardian RecoveryGuardian @relation(fields: [guardianId], references: [id], onDelete: Cascade) + + @@unique([requestId, guardianId]) + @@index([guardianId]) + @@map("recovery_approvals") +} + model AdminApiKey { id String @id @default(uuid()) name String @unique @@ -659,6 +740,7 @@ model Transaction { // reconciler can find its settlements with an indexed query instead of // parsing memos. loanId String? + internalTransferId String? // #532 — set the instant a confirmed settlement has been applied to the loan // row. NULL means "the money moved but the loan does not know yet", which is // exactly the state a process crash leaves behind and exactly what the @@ -666,11 +748,12 @@ model Transaction { // replaying a settlement can never double-apply it. loanSettlementAppliedAt DateTime? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - position Position? @relation(fields: [positionId], references: [id]) - fiatOrders FiatOrder[] - costBasisLot CostBasisLot? - lotDisposals LotDisposal[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + position Position? @relation(fields: [positionId], references: [id]) + fiatOrders FiatOrder[] + costBasisLot CostBasisLot? + lotDisposals LotDisposal[] + internalTransfer InternalTransfer? @relation(fields: [internalTransferId], references: [id], onDelete: Restrict) // #532 — the opposite side of CollateralLoan.transactions: every settlement // recorded against a loan, disburse or repay. @@ -686,9 +769,34 @@ model Transaction { // #532 — the loan reconciler's sweep query: confirmed settlements for a loan // that have not been applied yet. @@index([loanId, loanSettlementAppliedAt]) + @@index([internalTransferId]) @@map("transactions") } +/// Atomic platform-internal movement between two users. Paired transaction +/// rows and position ownership changes are committed with this record. +model InternalTransfer { + id String @id @default(uuid()) + senderUserId String + recipientUserId String + senderHandle String + recipientHandle String + assetSymbol String + amount Decimal @db.Decimal(36, 18) + note String? @db.VarChar(280) + riskScore Int + createdAt DateTime @default(now()) + + sender User @relation("InternalTransferSender", fields: [senderUserId], references: [id], onDelete: Restrict) + recipient User @relation("InternalTransferRecipient", fields: [recipientUserId], references: [id], onDelete: Restrict) + transactions Transaction[] + + @@index([senderUserId, createdAt]) + @@index([recipientUserId, createdAt]) + @@index([senderUserId, recipientHandle]) + @@map("internal_transfers") +} + model YieldSnapshot { id String @id @default(uuid()) positionId String diff --git a/prisma/seed.ts b/prisma/seed.ts index 39bb58d2..bb994f9a 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -8,10 +8,13 @@ async function main() { // ── 1. Test User ───────────────────────────────────────────────────────────── const user = await prisma.user.upsert({ - where: { walletAddress: 'GCKFBEIYV2U22IO2BJ4KVJOIP7XPWQGQFKKWXR65HOTVGT3WHCBLZXK' }, + where: { + walletAddress: 'GCKFBEIYV2U22IO2BJ4KVJOIP7XPWQGQFKKWXR65HOTVGT3WHCBLZXK', + }, update: {}, create: { walletAddress: 'GCKFBEIYV2U22IO2BJ4KVJOIP7XPWQGQFKKWXR65HOTVGT3WHCBLZXK', + authPublicKey: 'GCKFBEIYV2U22IO2BJ4KVJOIP7XPWQGQFKKWXR65HOTVGT3WHCBLZXK', network: 'TESTNET', displayName: 'Alex Testnet', email: 'alex@neurowealth.dev', @@ -40,7 +43,9 @@ async function main() { network: 'TESTNET', }, }) - console.warn(`✅ Protocol rates: Blend ${Number(blendRate.supplyApy) * 100}% APY, Aqua ${Number(aquaRate.supplyApy) * 100}% APY`) + console.warn( + `✅ Protocol rates: Blend ${Number(blendRate.supplyApy) * 100}% APY, Aqua ${Number(aquaRate.supplyApy) * 100}% APY` + ) // ── 3. Position ─────────────────────────────────────────────────────────────── const position = await prisma.position.create({ @@ -54,14 +59,17 @@ async function main() { status: 'ACTIVE', }, }) - console.warn(`✅ Position: ${position.depositedAmount} USDC on ${position.protocolName}`) + console.warn( + `✅ Position: ${position.depositedAmount} USDC on ${position.protocolName}` + ) // ── 4. Deposit Transaction ──────────────────────────────────────────────────── const depositTx = await prisma.transaction.create({ data: { userId: user.id, positionId: position.id, - txHash: 'c3f1e2d4a5b6789012345678901234567890abcdef1234567890abcdef123456', + txHash: + 'c3f1e2d4a5b6789012345678901234567890abcdef1234567890abcdef123456', type: 'DEPOSIT', status: 'CONFIRMED', assetSymbol: 'USDC', @@ -73,7 +81,9 @@ async function main() { confirmedAt: new Date(), }, }) - console.warn(`✅ Transaction: ${depositTx.amount} ${depositTx.assetSymbol} (${depositTx.txHash?.slice(0, 16)}...)`) + console.warn( + `✅ Transaction: ${depositTx.amount} ${depositTx.assetSymbol} (${depositTx.txHash?.slice(0, 16)}...)` + ) // ── 5. Yield Snapshot ───────────────────────────────────────────────────────── const snapshot = await prisma.yieldSnapshot.create({ @@ -92,7 +102,8 @@ async function main() { userId: user.id, action: 'DEPOSIT', status: 'SUCCESS', - reasoning: 'Blend USDC offered the highest risk-adjusted APY (8.42%) for risk tolerance 6/10. TVL healthy at $4.2M.', + reasoning: + 'Blend USDC offered the highest risk-adjusted APY (8.42%) for risk tolerance 6/10. TVL healthy at $4.2M.', inputData: { availableBalance: 5000, assetSymbol: 'USDC', @@ -134,4 +145,4 @@ main() }) .finally(async () => { await prisma.$disconnect() - }) \ No newline at end of file + }) diff --git a/src/accounting/exporters/qbo.ts b/src/accounting/exporters/qbo.ts index ef53b492..fd9d53ed 100644 --- a/src/accounting/exporters/qbo.ts +++ b/src/accounting/exporters/qbo.ts @@ -17,6 +17,7 @@ const TRANSACTION_TYPE_TO_QBO_CATEGORY: Record = { LOAN_DISBURSE: 'Transfer In', LOAN_REPAYMENT: 'Transfer Out', LOAN_LIQUIDATION: 'Transfer Out', + INTERNAL_TRANSFER: 'Transfer', } export interface QboTransaction { diff --git a/src/accounting/exporters/xeroCsv.ts b/src/accounting/exporters/xeroCsv.ts index 6b9a6db3..3bd44f31 100644 --- a/src/accounting/exporters/xeroCsv.ts +++ b/src/accounting/exporters/xeroCsv.ts @@ -16,6 +16,7 @@ const TRANSACTION_TYPE_TO_XERO_CATEGORY: Record = { LOAN_DISBURSE: 'Transfer In', LOAN_REPAYMENT: 'Transfer Out', LOAN_LIQUIDATION: 'Transfer Out', + INTERNAL_TRANSFER: 'Transfer', } export interface XeroTransaction { diff --git a/src/auth/scopes.ts b/src/auth/scopes.ts index 9c326d82..2b9c49f1 100644 --- a/src/auth/scopes.ts +++ b/src/auth/scopes.ts @@ -20,6 +20,7 @@ export const USER_SCOPES = [ 'transactions:read', 'deposit:write', 'withdraw:write', + 'transfer:write', 'alerts:manage', 'fiat:write', 'recurring_deposits:write', @@ -90,6 +91,16 @@ export const USER_SCOPE_CATALOG: Record = { 'Requires allowWithdrawals on the key AND the platform kill-switch ' + 'USER_API_KEY_WITHDRAWALS_ENABLED to be on.', }, + 'transfer:write': { + scope: 'transfer:write', + enforcement: 'enforced', + description: 'Transfer assets to another platform user.', + risk: 'money', + grants: + 'Moves existing unlocked positions to another platform user without an on-chain transaction.', + requires: + 'Transfers still pass compliance, frozen-account, balance, and sub-account permission checks.', + }, 'alerts:manage': { scope: 'alerts:manage', enforcement: 'enforced', diff --git a/src/compliance/scoring.ts b/src/compliance/scoring.ts index 677ebb82..f49fb9eb 100644 --- a/src/compliance/scoring.ts +++ b/src/compliance/scoring.ts @@ -17,13 +17,15 @@ * mean/stddev z-score, a handful of large past * transactions can't numb the model to a new * one that's equally large. - * VELOCITY (0.20) — count/volume of deposits+withdrawals in a + * VELOCITY (0.20) — count/volume of deposits, withdrawals, and + * internal transfers in a * window, plus round-trip detection (an * opposite-direction transaction of a similar * amount shortly before/after). - * STRUCTURING (0.20) — repeated deposits/withdrawals just under a + * STRUCTURING (0.20) — repeated deposits, withdrawals, and internal + * transfers just under a * configured reporting-style threshold. - * NEW_DESTINATION (0.15) — transfer to a Stellar address with no prior + * NEW_DESTINATION (0.15) — transfer to an address/recipient with no prior * history on this account. * ACCOUNT_AGE (0.05) — how new the account is at transaction time. * SUB_ACCOUNT_FANOUT (0.10) — a parent account whose children are @@ -58,6 +60,7 @@ export type TransactionTypeLike = | 'REBALANCE' | 'SWAP' | 'REFERRAL_REWARD' + | 'INTERNAL_TRANSFER' /** A prior transaction on the account, used as scoring context. */ export interface HistoricalTransaction { @@ -432,13 +435,13 @@ export function computeVelocityFeature( if (transaction.isAgentDriven) return AGENT_EXEMPT_RESULT('VELOCITY') const isMoneyMovement = (t: TransactionTypeLike) => - t === 'DEPOSIT' || t === 'WITHDRAWAL' + t === 'DEPOSIT' || t === 'WITHDRAWAL' || t === 'INTERNAL_TRANSFER' if (!isMoneyMovement(transaction.type)) { return featureResult( 'VELOCITY', 0, [], - 'Velocity only applies to deposits/withdrawals.' + 'Velocity only applies to deposits, withdrawals, and internal transfers.' ) } @@ -504,7 +507,7 @@ export function computeVelocityFeature( 'VELOCITY', score, reasonCodes, - `${count} deposit/withdrawal(s) totalling ${volume} in the last ${cfg.windowMs}ms` + + `${count} money movement(s) totalling ${volume} in the last ${cfg.windowMs}ms` + (roundTripMatch ? '; matches an opposite-direction transaction of a similar amount within the round-trip window' : '.') @@ -520,12 +523,16 @@ export function computeStructuringFeature( ) { if (transaction.isAgentDriven) return AGENT_EXEMPT_RESULT('STRUCTURING') - if (transaction.type !== 'DEPOSIT' && transaction.type !== 'WITHDRAWAL') { + if ( + transaction.type !== 'DEPOSIT' && + transaction.type !== 'WITHDRAWAL' && + transaction.type !== 'INTERNAL_TRANSFER' + ) { return featureResult( 'STRUCTURING', 0, [], - 'Structuring only applies to deposits/withdrawals.' + 'Structuring only applies to deposits, withdrawals, and internal transfers.' ) } diff --git a/src/controllers/auth-controller.ts b/src/controllers/auth-controller.ts index 4b9ffc0e..44591479 100644 --- a/src/controllers/auth-controller.ts +++ b/src/controllers/auth-controller.ts @@ -5,6 +5,7 @@ // #2FA – adds TOTP second-factor challenge step after wallet-signature // verification; enrollment/disable live in services/totp.service.ts. import { Request, Response } from 'express' +import type { Session } from '@prisma/client' import { randomBytes } from 'crypto' import { Keypair } from '@stellar/stellar-sdk' import { JwtAdapter, config } from '../config' @@ -56,6 +57,48 @@ const REFRESH_ERRORS: Record< }, } +async function createSessionForCurrentCredential(params: { + userId: string + authPublicKey: string + pair: Awaited> + userAgent: string | null + ipAddress: string | null + deviceType: string + approxLocation: string | null +}): Promise { + return db.$transaction(async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "users" WHERE "id" = ${params.userId} FOR UPDATE` + const user = await tx.user.findUnique({ + where: { id: params.userId }, + select: { + authPublicKey: true, + isActive: true, + walletAddress: true, + network: true, + }, + }) + if (!user?.isActive || user.authPublicKey !== params.authPublicKey) + return null + + return tx.session.create({ + data: { + userId: params.userId, + token: params.pair.accessToken, + walletAddress: user.walletAddress, + network: user.network, + expiresAt: params.pair.expiresAt, + ipAddress: params.ipAddress, + userAgent: params.userAgent, + deviceType: params.deviceType, + approxLocation: params.approxLocation, + lastSeenAt: new Date(), + lastSeenIp: params.ipAddress, + ...newRefreshTokenFields(params.pair), + }, + }) + }) +} + // ── Controllers ──────────────────────────────────────────────────────────── /** @@ -129,13 +172,23 @@ export async function verify(req: Request, res: Response): Promise { try { let user = await db.user.findUnique({ - where: { walletAddress: stellarPubKey }, + where: { authPublicKey: stellarPubKey }, }) if (!user) { + const existingWallet = await db.user.findUnique({ + where: { walletAddress: stellarPubKey }, + select: { id: true }, + }) + if (existingWallet) { + res.status(401).json({ error: 'Invalid signature credential' }) + return + } + user = await db.user.create({ data: { walletAddress: stellarPubKey, + authPublicKey: stellarPubKey, network, positions: { create: { @@ -194,22 +247,19 @@ export async function verify(req: Request, res: Response): Promise { const deviceType = parseDeviceType(userAgent) const approxLocation = resolveApproxLocation(ipAddress) - const session = await db.session.create({ - data: { - userId: user.id, - token: pair.accessToken, - walletAddress: stellarPubKey, - network, - expiresAt: pair.expiresAt, - ipAddress, - userAgent, - deviceType, - approxLocation, - lastSeenAt: new Date(), - lastSeenIp: ipAddress, - ...newRefreshTokenFields(pair), - }, + const session = await createSessionForCurrentCredential({ + userId: user.id, + authPublicKey: stellarPubKey, + pair, + ipAddress, + userAgent, + deviceType, + approxLocation, }) + if (!session) { + res.status(401).json({ error: 'Authentication credential changed' }) + return + } logger.info(`[Auth] Session created for user ${user.id}`) @@ -296,22 +346,19 @@ export async function verifyTotp(req: Request, res: Response): Promise { const deviceType = parseDeviceType(userAgent) const approxLocation = resolveApproxLocation(ipAddress) - const session = await db.session.create({ - data: { - userId: user.id, - token: pair.accessToken, - walletAddress: stellarPubKey, - network, - expiresAt: pair.expiresAt, - ipAddress, - userAgent, - deviceType, - approxLocation, - lastSeenAt: new Date(), - lastSeenIp: ipAddress, - ...newRefreshTokenFields(pair), - }, + const session = await createSessionForCurrentCredential({ + userId: user.id, + authPublicKey: stellarPubKey, + pair, + ipAddress, + userAgent, + deviceType, + approxLocation, }) + if (!session) { + res.status(401).json({ error: 'Authentication credential changed' }) + return + } logger.info(`[Auth] Session created for user ${user.id} (post-2FA)`) diff --git a/src/events/types.ts b/src/events/types.ts index 121168e7..cdf1b094 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -57,6 +57,11 @@ export const SOCKET_ONLY_EVENT_TYPES = [ 'security.session_revoked', /** #515 — suspicious session anomaly detected (impossible location, device change, subnet jump). */ 'security.session_anomaly', + 'security.recovery_requested', + 'security.recovery_completed', + 'security.recovery_rejected', + 'security.recovery_guardian_invited', + 'security.recovery_guardian_accepted', /** #548 — admin impersonation session started. */ 'account.impersonation_started', ] as const @@ -110,6 +115,11 @@ export const EVENT_TYPE_TOPIC: Record = { 'security.new_session': 'alerts', 'security.session_revoked': 'alerts', 'security.session_anomaly': 'alerts', + 'security.recovery_requested': 'alerts', + 'security.recovery_completed': 'alerts', + 'security.recovery_rejected': 'alerts', + 'security.recovery_guardian_invited': 'alerts', + 'security.recovery_guardian_accepted': 'alerts', 'account.impersonation_started': 'account', } diff --git a/src/index.ts b/src/index.ts index c578c355..bd589f6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -95,6 +95,7 @@ import { } from './middleware/corsandbody' import { setSpanUser } from './telemetry/spans' import { scheduleOutboundNotifications } from './services/outboundNotifications' +import { scheduleRecoveryCompletion } from './services/account-recovery.service' // ── Readiness state ─────────────────────────────────────────────────────────── @@ -135,6 +136,7 @@ let outboundNotificationsHandle: NodeJS.Timeout | null = null let linkedExternalWalletSyncHandle: NodeJS.Timeout | null = null let loanAccrualHandle: NodeJS.Timeout | null = null let loanLiquidationHandle: NodeJS.Timeout | null = null +let recoveryCompletionHandle: NodeJS.Timeout | null = null function allServicesReady(): boolean { return Object.values(serviceStatus).every((s) => s.ready) @@ -317,12 +319,7 @@ function deprecatedApiWarning( next() } -interface ApiRoute { - path: string - handlers: express.RequestHandler[] -} - -const apiRoutes: ApiRoute[] = API_ROUTES +const apiRoutes = API_ROUTES // ── Application routes ──────────────────────────────────────────────────────── @@ -368,6 +365,10 @@ async function gracefulShutdown(signal: string): Promise { clearInterval(outboundNotificationsHandle) outboundNotificationsHandle = null } + if (recoveryCompletionHandle) { + clearInterval(recoveryCompletionHandle) + recoveryCompletionHandle = null + } if (dataRetentionHandle) { clearInterval(dataRetentionHandle) @@ -591,8 +592,7 @@ async function initServices(): Promise { webAuthnConfig() logger.info('[Startup] WebAuthn configuration validated ✓') } catch (error) { - const msg = - error instanceof Error ? error.message : String(error) + const msg = error instanceof Error ? error.message : String(error) logger.error( '[Startup] WebAuthn configuration validation failed — cannot continue', { error: msg } @@ -766,6 +766,7 @@ async function main(): Promise { messageDeliverySweepHandle = scheduleMessageDeliverySweep() signerRotationHandle = scheduleSignerRotation() outboundNotificationsHandle = scheduleOutboundNotifications() + recoveryCompletionHandle = scheduleRecoveryCompletion() linkedExternalWalletSyncHandle = scheduleLinkedExternalWalletSync() // #532 — accrual first, then the monitor: the monitor values loans, and it // must not value them on interest the accrual job has not yet recorded. diff --git a/src/lending/service.ts b/src/lending/service.ts index 96137404..63793eb8 100644 --- a/src/lending/service.ts +++ b/src/lending/service.ts @@ -242,12 +242,38 @@ export async function originateLoan( } } - const ltvRatio = principal / collateralValue const liquidationLtvThreshold = config.lending.liquidationLtvThreshold const rate = await resolveBorrowRate(borrowedAsset, position.user.network) const created = await db .$transaction(async (tx) => { + // Serialize loan creation with internal transfers that reassign position + // ownership; the transfer path locks these rows before checking loan + // status and changing the balance. + await tx.$queryRaw` + SELECT "id" FROM "positions" + WHERE "id" = ${input.positionId} + FOR UPDATE + ` + const latestPosition = await tx.position.findUnique({ + where: { id: input.positionId }, + select: { currentValue: true, status: true }, + }) + const latestCollateralValue = Number(latestPosition?.currentValue) + if ( + !latestPosition || + latestPosition.status !== 'ACTIVE' || + !Number.isFinite(latestCollateralValue) || + latestCollateralValue < config.lending.minCollateralValue || + !isWithinLtvCap(principal, latestCollateralValue, cap) + ) { + throw new AppError( + 409, + 'Collateral changed before loan origination; refresh and retry' + ) + } + const lockedLtvRatio = principal / latestCollateralValue + // Re-check inside the transaction. Two concurrent requests can both pass // the check above; the partial unique index is what actually settles it, // and this read keeps the common case from relying on a constraint error. @@ -273,11 +299,11 @@ export async function originateLoan( underlyingBorrowApy: rate.underlyingBorrowApy, platformSpreadApy: rate.platformSpreadApy, interestRateApy: rate.interestRateApy, - ltvRatio, + ltvRatio: lockedLtvRatio, liquidationLtvThreshold, status: 'ACTIVE', lastValuedAt: new Date(), - lastValuedCollateral: collateralValue, + lastValuedCollateral: latestCollateralValue, }, }) @@ -343,7 +369,7 @@ export async function originateLoan( principal, borrowedAsset, interestRateApy: rate.interestRateApy, - ltvRatio, + ltvRatio: Number(created.loan.ltvRatio), }) return { @@ -353,7 +379,7 @@ export async function originateLoan( outboxOpId: created.op.id, principal, interestRateApy: rate.interestRateApy, - ltvRatio, + ltvRatio: Number(created.loan.ltvRatio), liquidationLtvThreshold, } } diff --git a/src/routes/recovery.ts b/src/routes/recovery.ts new file mode 100644 index 00000000..f1ef1181 --- /dev/null +++ b/src/routes/recovery.ts @@ -0,0 +1,791 @@ +import { + Router, + Request, + Response, + NextFunction, + RequestHandler, +} from 'express' +import crypto from 'node:crypto' +import { StrKey } from '@stellar/stellar-sdk' +import { z } from 'zod' +import db from '../db' +import { requireAuth } from '../middleware/authenticate' +import { sensitiveRateLimiter } from '../middleware/rateLimiter' +import { validate } from '../middleware/validate' +import { publishUserEvent } from '../events/publisher' +import { stellarVerification } from '../utils/stellar/stellar-verification' +import { + dispatchRecoveryNotifications, + hashRecoveryToken, + lockRecoveryOwner, + queueRecoveryEmail, + RECOVERY_DELAY_HOURS, + writeRecoveryAudit, +} from '../services/account-recovery.service' +import { logger } from '../utils/logger' + +const router = Router() +const emailSchema = z + .string() + .trim() + .email() + .transform((value) => value.toLowerCase()) +const guardianInput = z.object({ + guardianUserId: z.string().uuid(), + requiredApprovals: z.number().int().min(2).max(5).optional(), +}) +const revokeGuardianInput = z.object({ guardianId: z.string().uuid() }) +const initiateInput = z.object({ + email: emailSchema, + newAuthPublicKey: z.string().refine(StrKey.isValidEd25519PublicKey, { + message: 'A valid Stellar public key is required', + }), + signature: z.string().min(1).max(512), +}) +const idParams = z.object({ id: z.string().uuid() }) +const cancelInput = z.object({ + requestId: z.string().uuid(), + token: z.string().min(32).max(256), +}) + +const handle = + (fn: (req: Request, res: Response) => Promise): RequestHandler => + (req, res, next) => { + void fn(req, res).catch((error: unknown) => next(error)) + } + +function fail(status: number, message: string): Error & { status: number } { + return Object.assign(new Error(message), { status }) +} + +function isHttpError(error: unknown): error is Error & { status: number } { + return ( + error instanceof Error && + 'status' in error && + typeof error.status === 'number' + ) +} + +async function cancelRequest( + requestId: string, + ownerUserId?: string, + token?: string +): Promise { + return db.$transaction(async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "recovery_requests" WHERE "id" = ${requestId} FOR UPDATE` + const request = await tx.recoveryRequest.findFirst({ + where: { id: requestId, status: 'PENDING' }, + select: { + id: true, + ownerUserId: true, + cancellationTokenHash: true, + completedAt: true, + }, + }) + if (!request || request.completedAt) return false + + if (ownerUserId) { + if (request.ownerUserId !== ownerUserId) return false + } else if ( + !token || + !request.cancellationTokenHash || + !crypto.timingSafeEqual( + Buffer.from(hashRecoveryToken(token), 'hex'), + Buffer.from(request.cancellationTokenHash, 'hex') + ) + ) { + return false + } + + const now = new Date() + await tx.recoveryRequest.update({ + where: { id: request.id }, + data: { + status: 'CANCELLED', + cancelledAt: now, + cancellationTokenHash: null, + }, + }) + await writeRecoveryAudit(tx, request.ownerUserId, 'RECOVERY_CANCELLED', { + requestId: request.id, + cancellationChannel: ownerUserId + ? 'authenticated_session' + : 'email_token', + }) + return true + }) +} + +router.get( + '/guardians', + requireAuth, + handle(async (req, res) => { + const userId = req.auth!.userId + const [policy, nominated, incoming] = await Promise.all([ + db.recoveryPolicy.findUnique({ where: { ownerUserId: userId } }), + db.recoveryGuardian.findMany({ + where: { ownerUserId: userId, revokedAt: null }, + include: { + guardianUser: { + select: { + id: true, + displayName: true, + emailIdentity: { select: { email: true } }, + }, + }, + }, + orderBy: { createdAt: 'asc' }, + }), + db.recoveryGuardian.findMany({ + where: { guardianUserId: userId, acceptedAt: null, revokedAt: null }, + include: { owner: { select: { id: true, displayName: true } } }, + orderBy: { createdAt: 'asc' }, + }), + ]) + res.json({ + policy: policy + ? { + requiredApprovals: policy.requiredApprovals, + recoveryDelayHours: RECOVERY_DELAY_HOURS, + } + : null, + guardians: nominated, + nominations: incoming, + }) + }) +) + +router.get( + '/requests', + requireAuth, + handle(async (req, res) => { + const userId = req.auth!.userId + const guardianships = await db.recoveryGuardian.findMany({ + where: { + guardianUserId: userId, + acceptedAt: { not: null }, + revokedAt: null, + }, + select: { ownerUserId: true }, + }) + const requests = await db.recoveryRequest.findMany({ + where: { + status: 'PENDING', + OR: [ + { ownerUserId: userId }, + { ownerUserId: { in: guardianships.map((row) => row.ownerUserId) } }, + ], + }, + include: { + _count: { select: { approvals: true } }, + owner: { select: { id: true, displayName: true } }, + }, + orderBy: { initiatedAt: 'desc' }, + }) + res.json({ + requests: requests.map((request) => ({ + id: request.id, + owner: request.ownerUserId === userId ? undefined : request.owner, + proposedAuthPublicKey: + request.ownerUserId === userId + ? request.proposedAuthPublicKey + : undefined, + initiatedAt: request.initiatedAt, + quorumReachedAt: request.quorumReachedAt, + completesAt: request.completesAt, + approvalCount: request._count.approvals, + })), + }) + }) +) + +router.post( + '/guardians', + requireAuth, + sensitiveRateLimiter, + validate({ body: guardianInput }), + handle(async (req, res) => { + const ownerUserId = req.auth!.userId + const { guardianUserId, requiredApprovals } = guardianInput.parse(req.body) + if (guardianUserId === ownerUserId) + throw fail(400, 'You cannot nominate yourself') + + const guardian = await db.user.findUnique({ + where: { id: guardianUserId }, + select: { + id: true, + isActive: true, + emailIdentity: { select: { email: true, status: true } }, + }, + }) + if (!guardian?.isActive) throw fail(404, 'Eligible guardian not found') + if (guardian.emailIdentity?.status !== 'VERIFIED') + throw fail(400, 'Guardians must have a verified email address') + const guardianEmail = guardian.emailIdentity.email + + const nomination = await db.$transaction(async (tx) => { + await lockRecoveryOwner(tx, ownerUserId) + const [currentPolicy, currentGuardians, activeRequest] = + await Promise.all([ + tx.recoveryPolicy.findUnique({ where: { ownerUserId } }), + tx.recoveryGuardian.findMany({ + where: { ownerUserId, revokedAt: null }, + select: { id: true, guardianUserId: true }, + }), + tx.recoveryRequest.findFirst({ + where: { ownerUserId, status: 'PENDING' }, + select: { id: true }, + }), + ]) + if (activeRequest) + throw fail(409, 'Guardians cannot change during recovery') + if ( + currentGuardians.some((item) => item.guardianUserId === guardianUserId) + ) + throw fail(409, 'This user is already nominated') + if (currentGuardians.length >= 5) + throw fail(400, 'A maximum of five guardians is allowed') + const quorum = requiredApprovals ?? currentPolicy?.requiredApprovals ?? 2 + if (quorum < 2 || quorum > 5) + throw fail(400, 'Required approvals must be between two and five') + const row = await tx.recoveryGuardian.create({ + data: { ownerUserId, guardianUserId }, + }) + await tx.recoveryPolicy.upsert({ + where: { ownerUserId }, + create: { + ownerUserId, + requiredApprovals: quorum, + recoveryDelayHours: RECOVERY_DELAY_HOURS, + }, + update: { requiredApprovals: quorum }, + }) + await writeRecoveryAudit(tx, ownerUserId, 'RECOVERY_GUARDIAN_NOMINATED', { + guardianUserId, + guardianId: row.id, + requiredApprovals: quorum, + }) + await queueRecoveryEmail( + tx, + guardianEmail, + 'You have been nominated as an account recovery guardian', + 'A NeuroWealth user nominated you as a recovery guardian. Sign in to your account and explicitly accept or decline the nomination in Account Recovery settings. Do not accept unless you know and trust this user.', + '

A NeuroWealth user nominated you as an account recovery guardian. Sign in and explicitly accept or decline in Account Recovery settings. Do not accept unless you know and trust this user.

' + ) + return { guardian: row, requiredApprovals: quorum } + }) + + dispatchRecoveryNotifications() + await publishUserEvent( + guardianUserId, + 'alerts', + 'security.recovery_guardian_invited', + { + guardianId: nomination.guardian.id, + } + ) + res.status(201).json(nomination) + }) +) + +router.post( + '/guardians/:id/accept', + requireAuth, + sensitiveRateLimiter, + validate({ params: idParams }), + handle(async (req, res) => { + const guardian = await db.recoveryGuardian.findFirst({ + where: { + id: req.params.id, + guardianUserId: req.auth!.userId, + revokedAt: null, + acceptedAt: null, + }, + include: { owner: { select: { id: true } } }, + }) + if (!guardian) throw fail(404, 'Pending guardian nomination not found') + const email = await db.emailIdentity.findUnique({ + where: { userId: req.auth!.userId }, + select: { status: true }, + }) + if (email?.status !== 'VERIFIED') + throw fail( + 403, + 'A verified email address is required to accept a nomination' + ) + + const acceptedAt = new Date() + await db.$transaction(async (tx) => { + await lockRecoveryOwner(tx, guardian.ownerUserId) + const pending = await tx.recoveryGuardian.findFirst({ + where: { + id: guardian.id, + guardianUserId: req.auth!.userId, + acceptedAt: null, + revokedAt: null, + }, + }) + if (!pending) throw fail(404, 'Pending guardian nomination not found') + await tx.recoveryGuardian.update({ + where: { id: pending.id }, + data: { acceptedAt }, + }) + await writeRecoveryAudit( + tx, + guardian.ownerUserId, + 'RECOVERY_GUARDIAN_ACCEPTED', + { + guardianId: guardian.id, + guardianUserId: req.auth!.userId, + } + ) + }) + await publishUserEvent( + guardian.ownerUserId, + 'alerts', + 'security.recovery_guardian_accepted', + { + guardianId: guardian.id, + } + ) + res.json({ guardianId: guardian.id, acceptedAt }) + }) +) + +router.post( + '/guardians/:id/decline', + requireAuth, + sensitiveRateLimiter, + validate({ params: idParams }), + handle(async (req, res) => { + const guardian = await db.recoveryGuardian.findFirst({ + where: { + id: req.params.id, + guardianUserId: req.auth!.userId, + acceptedAt: null, + revokedAt: null, + }, + }) + if (!guardian) throw fail(404, 'Pending guardian nomination not found') + await db.$transaction(async (tx) => { + await lockRecoveryOwner(tx, guardian.ownerUserId) + const pending = await tx.recoveryGuardian.findFirst({ + where: { + id: guardian.id, + guardianUserId: req.auth!.userId, + acceptedAt: null, + revokedAt: null, + }, + }) + if (!pending) throw fail(404, 'Pending guardian nomination not found') + await tx.recoveryGuardian.update({ + where: { id: pending.id }, + data: { revokedAt: new Date() }, + }) + await writeRecoveryAudit( + tx, + guardian.ownerUserId, + 'RECOVERY_GUARDIAN_DECLINED', + { + guardianId: guardian.id, + guardianUserId: req.auth!.userId, + } + ) + }) + res.status(204).end() + }) +) + +router.post( + '/guardians/revoke', + requireAuth, + sensitiveRateLimiter, + validate({ body: revokeGuardianInput }), + handle(async (req, res) => { + const { guardianId } = revokeGuardianInput.parse(req.body) + const guardian = await db.recoveryGuardian.findFirst({ + where: { + id: guardianId, + ownerUserId: req.auth!.userId, + revokedAt: null, + }, + }) + if (!guardian) throw fail(404, 'Guardian not found') + const activeRequest = await db.recoveryRequest.findFirst({ + where: { ownerUserId: req.auth!.userId, status: 'PENDING' }, + select: { id: true }, + }) + if (activeRequest) + throw fail(409, 'Guardians cannot change during recovery') + await db.$transaction(async (tx) => { + await lockRecoveryOwner(tx, req.auth!.userId) + const activeRequest = await tx.recoveryRequest.findFirst({ + where: { ownerUserId: req.auth!.userId, status: 'PENDING' }, + select: { id: true }, + }) + if (activeRequest) + throw fail(409, 'Guardians cannot change during recovery') + const activeGuardian = await tx.recoveryGuardian.findFirst({ + where: { + id: guardian.id, + ownerUserId: req.auth!.userId, + revokedAt: null, + }, + }) + if (!activeGuardian) throw fail(404, 'Guardian not found') + await tx.recoveryGuardian.update({ + where: { id: activeGuardian.id }, + data: { revokedAt: new Date() }, + }) + await writeRecoveryAudit( + tx, + req.auth!.userId, + 'RECOVERY_GUARDIAN_REVOKED', + { + guardianId: guardian.id, + } + ) + }) + res.status(204).end() + }) +) + +router.post( + '/initiate', + sensitiveRateLimiter, + validate({ body: initiateInput }), + handle(async (req, res) => { + const { email, newAuthPublicKey, signature } = initiateInput.parse(req.body) + const genericResponse = { + accepted: true, + message: + 'If the account is eligible, recovery instructions have been sent to its registered email address.', + } + const challenge = await db.authNonce.findUnique({ + where: { stellarPubKey: newAuthPublicKey }, + }) + if ( + !challenge || + challenge.expiresAt <= new Date() || + !stellarVerification.verifyStellarSignature( + newAuthPublicKey, + challenge.nonce, + signature + ) + ) { + res.status(202).json(genericResponse) + return + } + const consumed = await db.authNonce.deleteMany({ + where: { + stellarPubKey: newAuthPublicKey, + nonce: challenge.nonce, + expiresAt: { gt: new Date() }, + }, + }) + if (consumed.count !== 1) { + res.status(202).json(genericResponse) + return + } + + const identity = await db.emailIdentity.findUnique({ + where: { email }, + include: { + user: { + select: { + id: true, + isActive: true, + walletAddress: true, + authPublicKey: true, + }, + }, + }, + }) + if ( + !identity || + identity.status !== 'VERIFIED' || + !identity.user.isActive || + identity.user.walletAddress === newAuthPublicKey || + identity.user.authPublicKey === newAuthPublicKey + ) { + res.status(202).json(genericResponse) + return + } + + const existing = await db.user.findFirst({ + where: { + OR: [ + { walletAddress: newAuthPublicKey }, + { authPublicKey: newAuthPublicKey }, + ], + }, + select: { id: true }, + }) + if (existing) { + res.status(202).json(genericResponse) + return + } + const cancellationToken = crypto.randomBytes(32).toString('hex') + const initiation = await db.$transaction(async (tx) => { + await lockRecoveryOwner(tx, identity.userId) + const [policy, guardians, pending] = await Promise.all([ + tx.recoveryPolicy.findUnique({ + where: { ownerUserId: identity.userId }, + }), + tx.recoveryGuardian.findMany({ + where: { + ownerUserId: identity.userId, + acceptedAt: { not: null }, + revokedAt: null, + }, + include: { + guardianUser: { + select: { + emailIdentity: { select: { email: true, status: true } }, + }, + }, + }, + }), + tx.recoveryRequest.findFirst({ + where: { ownerUserId: identity.userId, status: 'PENDING' }, + select: { id: true }, + }), + ]) + if ( + pending || + !policy || + guardians.length < policy.requiredApprovals || + guardians.some( + (guardian) => + guardian.guardianUser.emailIdentity?.status !== 'VERIFIED' + ) + ) + return null + + const row = await tx.recoveryRequest.create({ + data: { + ownerUserId: identity.userId, + proposedAuthPublicKey: newAuthPublicKey, + cancellationTokenHash: hashRecoveryToken(cancellationToken), + }, + }) + await writeRecoveryAudit(tx, identity.userId, 'RECOVERY_INITIATED', { + requestId: row.id, + proposedAuthPublicKey: newAuthPublicKey, + }) + const cancelUrl = `${process.env.APP_URL || 'https://neurowealth.app'}/recovery/cancel?requestId=${encodeURIComponent(row.id)}&token=${encodeURIComponent(cancellationToken)}` + const ownerNotice = + `An account access recovery was requested. If you did not request this, cancel it immediately: ${cancelUrl}. ` + + `Recovery cannot take effect until ${RECOVERY_DELAY_HOURS} hours after guardian quorum is reached.` + const guardianNotice = `An account recovery request has been initiated for a user who nominated you. Sign in to NeuroWealth to review and explicitly approve or reject it. Recovery has a mandatory ${RECOVERY_DELAY_HOURS}-hour delay after quorum.` + await queueRecoveryEmail( + tx, + identity.email, + 'Important: account recovery requested', + ownerNotice, + `

An account access recovery was requested. If you did not request this, cancel it immediately.

Recovery cannot take effect until ${RECOVERY_DELAY_HOURS} hours after guardian quorum is reached.

` + ) + for (const guardianEmail of guardians.map( + (guardian) => guardian.guardianUser.emailIdentity!.email + )) { + await queueRecoveryEmail( + tx, + guardianEmail, + 'Account recovery approval requested', + guardianNotice, + `

${guardianNotice}

` + ) + } + return { + request: row, + } + }) + if (!initiation) { + res.status(202).json(genericResponse) + return + } + + dispatchRecoveryNotifications() + await publishUserEvent( + identity.userId, + 'alerts', + 'security.recovery_requested', + { + requestId: initiation.request.id, + initiatedAt: initiation.request.initiatedAt.toISOString(), + } + ) + res.status(202).json(genericResponse) + }) +) + +router.post( + '/:id/approve', + requireAuth, + sensitiveRateLimiter, + validate({ params: idParams }), + handle(async (req, res) => { + const result = await db.$transaction(async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "recovery_requests" WHERE "id" = ${req.params.id} FOR UPDATE` + const recovery = await tx.recoveryRequest.findFirst({ + where: { id: req.params.id, status: 'PENDING' }, + select: { id: true, ownerUserId: true, quorumReachedAt: true }, + }) + if (!recovery) throw fail(404, 'Pending recovery request not found') + if (recovery.ownerUserId === req.auth!.userId) + throw fail(403, 'Account owners cannot approve their own recovery') + const guardian = await tx.recoveryGuardian.findFirst({ + where: { + ownerUserId: recovery.ownerUserId, + guardianUserId: req.auth!.userId, + acceptedAt: { not: null }, + revokedAt: null, + }, + }) + if (!guardian) + throw fail(403, 'You are not an accepted guardian for this account') + const approval = await tx.recoveryApproval.create({ + data: { requestId: recovery.id, guardianId: guardian.id }, + }) + const [count, policy] = await Promise.all([ + tx.recoveryApproval.count({ where: { requestId: recovery.id } }), + tx.recoveryPolicy.findUnique({ + where: { ownerUserId: recovery.ownerUserId }, + }), + ]) + if (!policy) throw fail(409, 'Recovery policy is unavailable') + let completesAt: Date | null = null + if (!recovery.quorumReachedAt && count >= policy.requiredApprovals) { + const quorumReachedAt = new Date() + completesAt = new Date( + quorumReachedAt.getTime() + RECOVERY_DELAY_HOURS * 60 * 60 * 1000 + ) + await tx.recoveryRequest.update({ + where: { id: recovery.id }, + data: { quorumReachedAt, completesAt }, + }) + } + await writeRecoveryAudit( + tx, + recovery.ownerUserId, + 'RECOVERY_APPROVAL_RECORDED', + { + requestId: recovery.id, + guardianId: guardian.id, + approvalId: approval.id, + approvalCount: count, + quorumReached: count >= policy.requiredApprovals, + } + ) + return { + approval, + count, + requiredApprovals: policy.requiredApprovals, + completesAt, + } + }) + res.status(201).json(result) + }) +) + +router.post( + '/:id/reject', + requireAuth, + sensitiveRateLimiter, + validate({ params: idParams }), + handle(async (req, res) => { + const ownerUserId = await db.$transaction(async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "recovery_requests" WHERE "id" = ${req.params.id} FOR UPDATE` + const request = await tx.recoveryRequest.findFirst({ + where: { id: req.params.id, status: 'PENDING' }, + select: { id: true, ownerUserId: true }, + }) + if (!request) throw fail(404, 'Pending recovery request not found') + const guardian = await tx.recoveryGuardian.findFirst({ + where: { + ownerUserId: request.ownerUserId, + guardianUserId: req.auth!.userId, + acceptedAt: { not: null }, + revokedAt: null, + }, + }) + if (!guardian) + throw fail(403, 'You are not an accepted guardian for this account') + await tx.recoveryRequest.update({ + where: { id: request.id }, + data: { + status: 'REJECTED', + cancellationTokenHash: null, + }, + }) + await writeRecoveryAudit(tx, request.ownerUserId, 'RECOVERY_REJECTED', { + requestId: request.id, + guardianId: guardian.id, + }) + const identity = await tx.emailIdentity.findUnique({ + where: { userId: request.ownerUserId }, + select: { email: true, status: true }, + }) + if (identity?.status === 'VERIFIED') { + await queueRecoveryEmail( + tx, + identity.email, + 'Account recovery request rejected', + 'A nominated guardian rejected your account recovery request. The request cannot complete. Contact your guardians or initiate a new request if needed.', + '

A nominated guardian rejected your account recovery request. The request cannot complete. Contact your guardians or initiate a new request if needed.

' + ) + } + return request.ownerUserId + }) + dispatchRecoveryNotifications() + await publishUserEvent( + ownerUserId, + 'alerts', + 'security.recovery_rejected', + { + requestId: req.params.id, + rejectedAt: new Date().toISOString(), + } + ) + res.json({ rejected: true }) + }) +) + +router.post( + '/:id/cancel', + requireAuth, + sensitiveRateLimiter, + validate({ params: idParams }), + handle(async (req, res) => { + const cancelled = await cancelRequest(req.params.id, req.auth!.userId) + if (!cancelled) throw fail(404, 'Pending recovery request not found') + res.json({ cancelled: true }) + }) +) + +router.post( + '/cancel', + sensitiveRateLimiter, + validate({ body: cancelInput }), + handle(async (req, res) => { + const { requestId, token } = cancelInput.parse(req.body) + const cancelled = await cancelRequest(requestId, undefined, token) + if (!cancelled) throw fail(404, 'Pending recovery request not found') + res.json({ cancelled: true }) + }) +) + +router.use( + (error: unknown, _req: Request, res: Response, next: NextFunction) => { + if (isHttpError(error)) { + res.status(error.status).json({ error: error.message }) + return + } + logger.error('[AccountRecovery] Request failed', { + error: error instanceof Error ? error.message : String(error), + }) + next(error) + } +) + +export default router diff --git a/src/routes/registry.ts b/src/routes/registry.ts index 4fcbb5e2..54a92b8c 100644 --- a/src/routes/registry.ts +++ b/src/routes/registry.ts @@ -48,6 +48,7 @@ import protectionFundRouter from './protection-fund' import recurringDepositRouter from './recurring-deposits' import recurringWithdrawalRouter from './recurring-withdrawals' import referralsRouter from './referrals' +import recoveryRouter from './recovery' import sessionsRouter from './sessions' import settingsRouter from './settings' import stellarRouter from './stellar' @@ -57,6 +58,7 @@ import subAccountsRouter from './sub-accounts' import supportTicketsRouter from './support-tickets' import telegramRouter from './telegram' import transactionsRouter from './transactions' +import transferRouter from './transfers' import vaultRouter from './vault' import webAuthnRouter from './webauthn' import webhooksRouter from './webhooks' @@ -94,9 +96,11 @@ export const API_ROUTES: ApiRoute[] = [ { path: 'telegram', handlers: [webhookRateLimiter, telegramRouter] }, { path: 'portfolio', handlers: [portfolioRouter] }, { path: 'transactions', handlers: [transactionsRouter] }, + { path: 'recovery', handlers: [recoveryRouter] }, { path: 'protocols', handlers: [protocolsRouter] }, { path: 'deposit', handlers: [depositRouter] }, { path: 'withdraw', handlers: [withdrawRouter] }, + { path: 'transfer', handlers: [transferRouter] }, // #532 — collateral loans. Mounted beside withdraw rather than inside // portfolio: a loan is its own lifecycle with its own approval and rate // limits, and it must not inherit portfolio's read-only scope set. diff --git a/src/routes/transactions.ts b/src/routes/transactions.ts index da55b2b2..2f9cc168 100644 --- a/src/routes/transactions.ts +++ b/src/routes/transactions.ts @@ -38,6 +38,7 @@ const listSchema = z.object({ 'REFERRAL_REWARD', 'INBOUND_TRANSFER', 'CLAIMABLE_BALANCE_CLAIM', + 'INTERNAL_TRANSFER', ]) .optional(), status: z.enum(['PENDING', 'CONFIRMED', 'FAILED', 'CANCELLED']).optional(), @@ -67,7 +68,18 @@ router.get( async (req: Request, res: Response) => { const txHash = String(req.params.txHash) - const tx = await db.transaction.findUnique({ where: { txHash } }) + const tx = await db.transaction.findUnique({ + where: { txHash }, + include: { + internalTransfer: { + select: { + senderUserId: true, + senderHandle: true, + recipientHandle: true, + }, + }, + }, + }) if (!tx || tx.userId !== req.auth?.userId) { return sendNotFound(res, 'Transaction') @@ -131,6 +143,15 @@ router.get( orderBy: [{ [query.sortBy]: query.sortOrder }, { id: 'desc' }], skip, take: limit, + include: { + internalTransfer: { + select: { + senderUserId: true, + senderHandle: true, + recipientHandle: true, + }, + }, + }, }), ]) diff --git a/src/routes/transfers.ts b/src/routes/transfers.ts new file mode 100644 index 00000000..32c75a04 --- /dev/null +++ b/src/routes/transfers.ts @@ -0,0 +1,52 @@ +import { Router, Request, Response, NextFunction } from 'express' +import { z } from 'zod' +import { requireAuth } from '../middleware/authenticate' +import { requireScope } from '../middleware/apiKeyAuth' +import { idempotent } from '../middleware/idempotency' +import { sensitiveRateLimiter } from '../middleware/rateLimiter' +import { validate } from '../middleware/validate' +import { onChainAmountSchema } from '../validators/common-validators' +import { executeInternalTransfer } from '../services/internal-transfer.service' + +const router = Router() + +const transferSchema = z.object({ + recipientHandle: z + .string() + .trim() + .toUpperCase() + .regex(/^[A-HJ-NP-Z2-9]{8}$/, 'Invalid recipient handle'), + amount: onChainAmountSchema, + assetSymbol: z.string().trim().min(1).max(32), + note: z.string().max(280).optional(), + acknowledgeGoalImpact: z.boolean().optional(), +}) + +router.post( + '/', + requireAuth, + requireScope('transfer:write'), + idempotent({ required: true, failClosed: true, ttlSeconds: 86400 }), + sensitiveRateLimiter, + validate({ body: transferSchema, errorMessage: 'Validation error' }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await executeInternalTransfer({ + senderUserId: req.auth!.userId, + ...req.body, + }) + res.status(201).json({ + transferId: result.transferId, + transactionId: result.transactionId, + recipientHandle: result.recipientHandle, + assetSymbol: result.assetSymbol, + amount: result.amount, + status: 'CONFIRMED', + }) + } catch (error) { + next(error) + } + } +) + +export default router diff --git a/src/services/account-recovery.service.ts b/src/services/account-recovery.service.ts new file mode 100644 index 00000000..64a325eb --- /dev/null +++ b/src/services/account-recovery.service.ts @@ -0,0 +1,231 @@ +import { Prisma } from '@prisma/client' +import crypto from 'node:crypto' +import db from '../db' +import { dispatchOutboundNotifications } from './outboundNotifications' +import { closeUserSockets } from '../ws/server' +import { invalidateAllUserCaches } from '../utils/user-cache-invalidation' +import { publishUserEvent } from '../events/publisher' +import { logger } from '../utils/logger' + +export const RECOVERY_DELAY_HOURS = 72 + +export function hashRecoveryToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex') +} + +export async function lockRecoveryOwner( + tx: Prisma.TransactionClient, + ownerUserId: string +): Promise { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${ownerUserId}))` +} + +export async function queueRecoveryEmail( + tx: Prisma.TransactionClient, + to: string, + subject: string, + text: string, + html: string +): Promise { + await tx.outboundNotification.create({ + data: { channel: 'email', payload: { to, subject, text, html } }, + }) +} + +export function dispatchRecoveryNotifications(): void { + dispatchOutboundNotifications().catch((error) => + logger.error('[AccountRecovery] Immediate notification dispatch failed', { + error: error instanceof Error ? error.message : String(error), + }) + ) +} + +export async function writeRecoveryAudit( + tx: Prisma.TransactionClient, + ownerUserId: string, + action: string, + details: Prisma.InputJsonObject +): Promise { + await tx.adminAuditLog.create({ + data: { + adminName: 'SYSTEM', + action, + target: ownerUserId, + result: 'ok', + details, + }, + }) +} + +export async function completeRecoveryRequest( + requestId: string +): Promise { + const completed = await db.$transaction(async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "recovery_requests" WHERE "id" = ${requestId} FOR UPDATE` + const request = await tx.recoveryRequest.findFirst({ + where: { + id: requestId, + status: 'PENDING', + completesAt: { lte: new Date() }, + }, + include: { + owner: { + select: { + id: true, + isActive: true, + walletAddress: true, + emailIdentity: { select: { email: true, status: true } }, + }, + }, + approvals: true, + }, + }) + if (!request || !request.owner.isActive) return null + + const policy = await tx.recoveryPolicy.findUnique({ + where: { ownerUserId: request.ownerUserId }, + }) + if ( + !policy || + request.approvals.length < policy.requiredApprovals || + !request.quorumReachedAt + ) + return null + + const keyOwner = await tx.user.findFirst({ + where: { + OR: [ + { walletAddress: request.proposedAuthPublicKey }, + { authPublicKey: request.proposedAuthPublicKey }, + ], + }, + select: { id: true }, + }) + if (keyOwner && keyOwner.id !== request.ownerUserId) { + await tx.recoveryRequest.update({ + where: { id: request.id }, + data: { + status: 'CANCELLED', + cancelledAt: new Date(), + cancellationTokenHash: null, + }, + }) + await writeRecoveryAudit( + tx, + request.ownerUserId, + 'RECOVERY_COMPLETION_BLOCKED', + { + requestId: request.id, + reason: 'proposed_signing_key_already_assigned', + } + ) + return null + } + + const now = new Date() + await tx.user.update({ + where: { id: request.ownerUserId }, + data: { authPublicKey: request.proposedAuthPublicKey }, + }) + const [sessions, apiKeys, totp, passkeys] = await Promise.all([ + tx.session.updateMany({ + where: { userId: request.ownerUserId, revokedAt: null }, + data: { + revokedAt: now, + revokedReason: 'account_recovery', + refreshTokenHash: null, + refreshTokenPrefix: null, + refreshTokenUsedAt: null, + refreshTokenExpiresAt: null, + }, + }), + tx.userApiKey.updateMany({ + where: { userId: request.ownerUserId, revokedAt: null }, + data: { revokedAt: now }, + }), + tx.totpCredential.deleteMany({ where: { userId: request.ownerUserId } }), + tx.webAuthnCredential.deleteMany({ + where: { userId: request.ownerUserId }, + }), + ]) + await tx.recoveryRequest.update({ + where: { id: request.id }, + data: { + status: 'COMPLETED', + completedAt: now, + cancellationTokenHash: null, + }, + }) + await writeRecoveryAudit(tx, request.ownerUserId, 'RECOVERY_COMPLETED', { + requestId: request.id, + approvals: request.approvals.length, + authPublicKeyChanged: true, + sessionsRevoked: sessions.count, + apiKeysRevoked: apiKeys.count, + totpCredentialsReset: totp.count, + passkeysRemoved: passkeys.count, + }) + if (request.owner.emailIdentity?.status === 'VERIFIED') { + await queueRecoveryEmail( + tx, + request.owner.emailIdentity.email, + 'Account recovery completed', + 'Your account access recovery is complete. All existing sessions were revoked. If you did not request this, contact support immediately.', + '

Your account access recovery is complete. All existing sessions were revoked. If you did not request this, contact support immediately.

' + ) + } + return { ownerUserId: request.ownerUserId, requestId: request.id } + }) + + if (!completed) return false + + dispatchRecoveryNotifications() + closeUserSockets(completed.ownerUserId, 'Account recovery completed') + await invalidateAllUserCaches(completed.ownerUserId) + await publishUserEvent( + completed.ownerUserId, + 'alerts', + 'security.recovery_completed', + { + requestId: completed.requestId, + completedAt: new Date().toISOString(), + } + ) + + logger.warn('[AccountRecovery] Account access recovered', { + requestId: completed.requestId, + ownerUserId: completed.ownerUserId, + }) + return true +} + +export async function sweepDueRecoveries(): Promise { + const due = await db.recoveryRequest.findMany({ + where: { status: 'PENDING', completesAt: { lte: new Date() } }, + select: { id: true }, + take: 100, + orderBy: { completesAt: 'asc' }, + }) + for (const request of due) { + try { + await completeRecoveryRequest(request.id) + } catch (error) { + logger.error('[AccountRecovery] Failed to complete due recovery', { + requestId: request.id, + error: error instanceof Error ? error.message : String(error), + }) + } + } +} + +export function scheduleRecoveryCompletion(): NodeJS.Timeout { + const timer = setInterval(() => { + sweepDueRecoveries().catch((error) => + logger.error('[AccountRecovery] Recovery completion sweep failed', { + error: error instanceof Error ? error.message : String(error), + }) + ) + }, 60_000) + timer.unref() + return timer +} diff --git a/src/services/internal-transfer.service.ts b/src/services/internal-transfer.service.ts new file mode 100644 index 00000000..8f2f46ad --- /dev/null +++ b/src/services/internal-transfer.service.ts @@ -0,0 +1,507 @@ +import { Prisma } from '@prisma/client' +import db from '../db' +import { scoreTransaction, TransactionTypeLike } from '../compliance/scoring' +import { getOrCreateReferralCode } from '../referral/service' + +const HOLD_SCORE = 70 +const ACTIVE_CASE_STATUSES = [ + 'OPEN', + 'TRIAGE', + 'INVESTIGATING', + 'ESCALATED', + 'PENDING_SAR', +] as const + +export class InternalTransferError extends Error { + constructor( + message: string, + public readonly statusCode: number + ) { + super(message) + this.name = 'InternalTransferError' + } +} + +type TransferInput = { + senderUserId: string + recipientHandle: string + amount: string | number + assetSymbol: string + note?: string + acknowledgeGoalImpact?: boolean +} + +type TransferPosition = { + id: string + protocolName: string + assetAddress: string | null + currentValue: Prisma.Decimal + depositedAmount: Prisma.Decimal + yieldEarned: Prisma.Decimal +} + +function accountIsFrozen(user: { isActive: boolean }, hasActiveCase: boolean) { + return !user.isActive || hasActiveCase +} + +/** + * Move beneficial ownership of an existing position balance between users. + * No Stellar operation is created: both position legs, transaction history, + * tax-lot ownership, and audit events commit in one database transaction. + */ +export async function executeInternalTransfer(input: TransferInput) { + const amount = new Prisma.Decimal(input.amount) + if (!amount.isFinite() || amount.lte(0)) { + throw new InternalTransferError('Amount must be greater than zero', 400) + } + + return db.$transaction( + async (tx) => { + const normalizedHandle = input.recipientHandle.trim().toUpperCase() + const referralCode = await tx.referralCode.findUnique({ + where: { code: normalizedHandle }, + include: { + owner: { + select: { + id: true, + network: true, + isActive: true, + createdAt: true, + referralCode: { select: { code: true } }, + }, + }, + }, + }) + if (!referralCode) { + throw new InternalTransferError('Recipient not found', 404) + } + + const recipient = referralCode.owner + if (recipient.id === input.senderUserId) { + throw new InternalTransferError('Self-transfers are not allowed', 400) + } + + const sender = await tx.user.findUnique({ + where: { id: input.senderUserId }, + select: { id: true, network: true, isActive: true, createdAt: true }, + }) + if (!sender) { + throw new InternalTransferError('Sender account not found', 404) + } + if (sender.network !== recipient.network) { + throw new InternalTransferError( + 'Transfers require accounts on the same network', + 409 + ) + } + + const userIds = [sender.id, recipient.id].sort() + for (const userId of userIds) { + // Share the same lock key as withdrawals; also lock the recipient so + // concurrent withdrawals cannot spend a credit before it commits. + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${userId}), hashtext(${input.assetSymbol}))` + } + + const [users, activeCases] = await Promise.all([ + tx.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, isActive: true }, + }), + tx.complianceCase.findMany({ + where: { + userId: { in: userIds }, + status: { in: [...ACTIVE_CASE_STATUSES] }, + }, + select: { userId: true }, + }), + ]) + const frozenUserIds = new Set( + activeCases.map((caseRow) => caseRow.userId) + ) + const isFrozen = (userId: string) => { + const user = users.find((item) => item.id === userId) + return !user || accountIsFrozen(user, frozenUserIds.has(userId)) + } + if (isFrozen(sender.id) || isFrozen(recipient.id)) { + throw new InternalTransferError( + 'Transfers are unavailable for a frozen account', + 423 + ) + } + + const parentLink = await tx.subAccount.findUnique({ + where: { + parentUserId_childUserId: { + parentUserId: sender.id, + childUserId: recipient.id, + }, + }, + select: { status: true, permissions: true }, + }) + if ( + parentLink && + (parentLink.status !== 'ACTIVE' || + !parentLink.permissions.includes('DEPOSIT')) + ) { + throw new InternalTransferError( + 'Recipient sub-account does not allow deposits from this parent', + 403 + ) + } + + const [history, previousTransfers] = await Promise.all([ + tx.transaction.findMany({ + where: { userId: sender.id }, + orderBy: { createdAt: 'desc' }, + take: 500, + select: { + type: true, + amount: true, + assetSymbol: true, + createdAt: true, + }, + }), + tx.internalTransfer.findMany({ + where: { senderUserId: sender.id }, + select: { recipientHandle: true }, + }), + ]) + const supportedTypes: TransactionTypeLike[] = [ + 'DEPOSIT', + 'WITHDRAWAL', + 'YIELD_CLAIM', + 'REBALANCE', + 'SWAP', + 'REFERRAL_REWARD', + 'INTERNAL_TRANSFER', + ] + const now = new Date() + const score = scoreTransaction({ + transaction: { + id: 'internal-transfer-preview', + userId: sender.id, + type: 'INTERNAL_TRANSFER', + amount: amount.toNumber(), + assetSymbol: input.assetSymbol, + createdAt: now, + destinationAddress: normalizedHandle, + isAgentDriven: false, + }, + account: { + userId: sender.id, + accountCreatedAt: sender.createdAt, + knownDestinationAddresses: [ + ...previousTransfers.map((transfer) => transfer.recipientHandle), + ], + transactionHistory: history + .filter((transaction) => + supportedTypes.includes(transaction.type as TransactionTypeLike) + ) + .map((transaction) => ({ + type: transaction.type as TransactionTypeLike, + amount: Number(transaction.amount), + assetSymbol: transaction.assetSymbol, + createdAt: transaction.createdAt, + isAgentDriven: transaction.type === 'REBALANCE', + })), + }, + }) + if (score.totalScore >= HOLD_SCORE) { + throw new InternalTransferError( + 'Transfer held for compliance review', + 409 + ) + } + + await tx.$queryRaw` + SELECT "id" FROM "positions" + WHERE "userId" = ${sender.id} + AND "assetSymbol" = ${input.assetSymbol} + AND "status" = 'ACTIVE' + ORDER BY "id" + FOR UPDATE + ` + const positions = (await tx.position.findMany({ + where: { + userId: sender.id, + assetSymbol: input.assetSymbol, + status: 'ACTIVE', + liquidityLocked: false, + collateralLoans: { none: { status: 'ACTIVE' } }, + }, + orderBy: [{ openedAt: 'asc' }, { id: 'asc' }], + select: { + id: true, + protocolName: true, + assetAddress: true, + currentValue: true, + depositedAmount: true, + yieldEarned: true, + }, + })) as TransferPosition[] + const pendingWithdrawals = await tx.transaction.aggregate({ + where: { + userId: sender.id, + assetSymbol: input.assetSymbol, + type: 'WITHDRAWAL', + status: 'PENDING', + }, + _sum: { amount: true }, + }) + if (new Prisma.Decimal(pendingWithdrawals._sum.amount ?? 0).gt(0)) { + throw new InternalTransferError( + 'Transfers are unavailable while a withdrawal is pending', + 409 + ) + } + const positionBalance = positions.reduce( + (sum, position) => sum.plus(position.currentValue), + new Prisma.Decimal(0) + ) + const available = positionBalance + if (available.lt(amount)) { + throw new InternalTransferError( + 'Insufficient available balance after pending withdrawal reservations', + 409 + ) + } + + const activeGoals = await tx.savingsGoal.findMany({ + where: { userId: sender.id, status: 'ACTIVE' }, + select: { targetAmount: true }, + }) + if ( + !input.acknowledgeGoalImpact && + activeGoals.some((goal) => + positionBalance.minus(amount).lt(goal.targetAmount) + ) + ) { + throw new InternalTransferError( + 'Transfer would reduce the balance below an active savings goal', + 409 + ) + } + + const plan: Array<{ + position: TransferPosition + amount: Prisma.Decimal + principal: Prisma.Decimal + yield: Prisma.Decimal + }> = [] + let remaining = amount + for (const position of positions) { + if (remaining.lte(0)) break + if (position.currentValue.lte(0)) continue + const debit = Prisma.Decimal.min(position.currentValue, remaining) + const ratio = debit.div(position.currentValue) + plan.push({ + position, + amount: debit, + principal: position.depositedAmount.mul(ratio), + yield: position.yieldEarned.mul(ratio), + }) + remaining = remaining.minus(debit) + } + + const senderHandle = await getOrCreateReferralCode(sender.id, tx) + const transfer = await tx.internalTransfer.create({ + data: { + senderUserId: sender.id, + recipientUserId: recipient.id, + senderHandle: senderHandle.code, + recipientHandle: normalizedHandle, + assetSymbol: input.assetSymbol, + amount, + note: input.note?.trim() || null, + riskScore: score.totalScore, + }, + }) + const senderTransaction = await tx.transaction.create({ + data: { + userId: sender.id, + internalTransferId: transfer.id, + type: 'INTERNAL_TRANSFER', + status: 'CONFIRMED', + assetSymbol: input.assetSymbol, + amount, + network: sender.network, + memo: input.note?.trim() || null, + confirmedAt: now, + }, + }) + + const destinationPositions = new Map() + for (const item of plan) { + const debited = await tx.position.updateMany({ + where: { + id: item.position.id, + currentValue: { gte: item.amount }, + }, + data: { + currentValue: { decrement: item.amount }, + depositedAmount: { decrement: item.principal }, + yieldEarned: { decrement: item.yield }, + }, + }) + if (debited.count !== 1) { + throw new InternalTransferError( + 'Position balance changed during transfer; retry the request', + 409 + ) + } + const key = item.position.protocolName + let destinationId = destinationPositions.get(key) + if (!destinationId) { + const destination = await tx.position.findFirst({ + where: { + userId: recipient.id, + protocolName: key, + assetSymbol: input.assetSymbol, + status: 'ACTIVE', + }, + select: { id: true }, + }) + if (destination) { + destinationId = destination.id + destinationPositions.set(key, destination.id) + } else { + const created = await tx.position.create({ + data: { + userId: recipient.id, + protocolName: key, + assetSymbol: input.assetSymbol, + assetAddress: item.position.assetAddress, + depositedAmount: 0, + currentValue: 0, + yieldEarned: 0, + }, + select: { id: true }, + }) + destinationId = created.id + destinationPositions.set(key, created.id) + } + } + await tx.position.update({ + where: { id: destinationId }, + data: { + currentValue: { increment: item.amount }, + depositedAmount: { increment: item.principal }, + yieldEarned: { increment: item.yield }, + }, + }) + } + + const lots = await tx.costBasisLot.findMany({ + where: { + userId: sender.id, + assetSymbol: input.assetSymbol, + remainingAmount: { gt: 0 }, + }, + orderBy: [{ acquiredAt: 'asc' }, { id: 'asc' }], + }) + const principalToMove = Prisma.Decimal.min( + amount, + Prisma.Decimal.max( + new Prisma.Decimal(0), + plan.reduce( + (sum, item) => sum.plus(item.principal), + new Prisma.Decimal(0) + ) + ) + ) + let taxAmountRemaining = principalToMove + for (const lot of lots) { + if (taxAmountRemaining.lte(0)) break + const lotAmount = Prisma.Decimal.min( + lot.remainingAmount, + taxAmountRemaining + ) + await tx.costBasisLot.update({ + where: { id: lot.id }, + data: { remainingAmount: { decrement: lotAmount } }, + }) + const recipientTransaction = await tx.transaction.create({ + data: { + userId: recipient.id, + internalTransferId: transfer.id, + type: 'INTERNAL_TRANSFER', + status: 'CONFIRMED', + assetSymbol: input.assetSymbol, + amount: lotAmount, + network: recipient.network, + confirmedAt: now, + }, + }) + await tx.costBasisLot.create({ + data: { + userId: recipient.id, + transactionId: recipientTransaction.id, + assetSymbol: input.assetSymbol, + originalAmount: lotAmount, + remainingAmount: lotAmount, + acquisitionPrice: lot.acquisitionPrice, + priceSource: lot.priceSource, + acquiredAt: lot.acquiredAt, + }, + }) + taxAmountRemaining = taxAmountRemaining.minus(lotAmount) + } + + const lotAmountsMoved = principalToMove.minus(taxAmountRemaining) + const untaxedAmount = amount.minus(lotAmountsMoved) + if (untaxedAmount.gt(0)) { + const recipientTransaction = await tx.transaction.create({ + data: { + userId: recipient.id, + internalTransferId: transfer.id, + type: 'INTERNAL_TRANSFER', + status: 'CONFIRMED', + assetSymbol: input.assetSymbol, + amount: untaxedAmount, + network: recipient.network, + confirmedAt: now, + }, + }) + } + + await tx.adminAuditLog.createMany({ + data: [ + { + adminName: sender.id, + action: 'P2P_TRANSFER_SENT', + target: recipient.id, + result: 'ok', + details: { + transferId: transfer.id, + assetSymbol: input.assetSymbol, + amount: amount.toString(), + riskScore: score.totalScore, + }, + }, + { + adminName: recipient.id, + action: 'P2P_TRANSFER_RECEIVED', + target: sender.id, + result: 'ok', + details: { + transferId: transfer.id, + assetSymbol: input.assetSymbol, + amount: amount.toString(), + riskScore: score.totalScore, + }, + }, + ], + }) + + return { + transferId: transfer.id, + transactionId: senderTransaction.id, + recipientHandle: normalizedHandle, + assetSymbol: input.assetSymbol, + amount: amount.toString(), + riskScore: score.totalScore, + } + }, + { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted } + ) +} diff --git a/src/utils/api-formatters.ts b/src/utils/api-formatters.ts index 2b643716..40fdea7f 100644 --- a/src/utils/api-formatters.ts +++ b/src/utils/api-formatters.ts @@ -6,6 +6,11 @@ export const mapTransactionToResponse = (tx: any) => ({ amount: Number(tx.amount), assetSymbol: tx.assetSymbol, protocolName: tx.protocolName, + counterpartyHandle: tx.internalTransfer + ? tx.userId === tx.internalTransfer.senderUserId + ? tx.internalTransfer.recipientHandle + : tx.internalTransfer.senderHandle + : null, createdAt: tx.createdAt.toISOString(), }) @@ -273,6 +278,11 @@ const USER_EVENT_PAYLOAD_ALLOWLIST: Record = { // `error` is the user's own op failure text, already sent to their webhooks. 'outbox.op_failed': ['opId', 'kind', 'attempts', 'error'], 'portfolio.updated': ['protocolName', 'positionsAffected', 'reason'], + 'security.recovery_requested': ['requestId', 'initiatedAt'], + 'security.recovery_completed': ['requestId', 'completedAt'], + 'security.recovery_rejected': ['requestId', 'rejectedAt'], + 'security.recovery_guardian_invited': ['guardianId'], + 'security.recovery_guardian_accepted': ['guardianId'], } /** diff --git a/src/whatsapp/formatters.ts b/src/whatsapp/formatters.ts index 06ad31f7..17f2a107 100644 --- a/src/whatsapp/formatters.ts +++ b/src/whatsapp/formatters.ts @@ -30,6 +30,7 @@ type TxSummary = { status: string amount: number assetSymbol: string + counterpartyHandle?: string | null } // Minimal view of a protocol's current supply rate. `supplyApy` is a @@ -119,7 +120,10 @@ export function formatTransactionsReply(input: { // Show a shortened hash (first 8 chars) to keep lines compact, or // "pending" if the transaction has no on-chain hash yet. const hash = tx.txHash ? `${tx.txHash.slice(0, 8)}...` : 'pending' - return `• ${tx.type} ${tx.amount} ${tx.assetSymbol} (${tx.status}) [${hash}]` + const counterparty = tx.counterpartyHandle + ? ` with ${tx.counterpartyHandle}` + : '' + return `• ${tx.type} ${tx.amount} ${tx.assetSymbol}${counterparty} (${tx.status}) [${hash}]` }) return [ @@ -138,6 +142,9 @@ export function formatTransactionDetailReply(input: TxSummary): string { `Type: *${input.type}*`, `Status: *${input.status}*`, `Amount: *${input.amount} ${input.assetSymbol}*`, + ...(input.counterpartyHandle + ? [`Counterparty: *${input.counterpartyHandle}*`] + : []), // `||` (not `??`) so an empty-string hash also falls back to "pending". `Hash: _${input.txHash || 'pending'}_`, ].join('\n') diff --git a/tests/integration/deposit-withdraw.integration.test.ts b/tests/integration/deposit-withdraw.integration.test.ts index be594c01..5c15159c 100644 --- a/tests/integration/deposit-withdraw.integration.test.ts +++ b/tests/integration/deposit-withdraw.integration.test.ts @@ -125,6 +125,7 @@ async function seedAuthAndWallet(): Promise<{ const user = await db.user.create({ data: { walletAddress, + authPublicKey: walletAddress, network: 'TESTNET', displayName: 'IT Test', email: `it-${Date.now()}-${Math.random()}@example.com`, diff --git a/tests/integration/outbox/dispatcher.integration.test.ts b/tests/integration/outbox/dispatcher.integration.test.ts index 05350c47..61db34f4 100644 --- a/tests/integration/outbox/dispatcher.integration.test.ts +++ b/tests/integration/outbox/dispatcher.integration.test.ts @@ -58,9 +58,11 @@ function uuid(): string { } async function seedUser(overrides: { isActive?: boolean } = {}) { + const walletAddress = `G${uuid().replace(/-/g, '').slice(0, 47)}`.slice(0, 56) return db.user.create({ data: { - walletAddress: `G${uuid().replace(/-/g, '').slice(0, 47)}`.slice(0, 56), + walletAddress, + authPublicKey: walletAddress, network: 'TESTNET', email: `${uuid()}@example.com`, isActive: overrides.isActive ?? true, diff --git a/tests/integration/tax-report.integration.test.ts b/tests/integration/tax-report.integration.test.ts index 75022084..c26360f2 100644 --- a/tests/integration/tax-report.integration.test.ts +++ b/tests/integration/tax-report.integration.test.ts @@ -48,6 +48,7 @@ async function seedUserWithSession(): Promise<{ const user = await db.user.create({ data: { walletAddress, + authPublicKey: walletAddress, network: 'TESTNET', displayName: 'Tax IT', email: `tax-${Date.now()}-${Math.random()}@example.com`, diff --git a/tests/regression.test.ts b/tests/regression.test.ts index ac4b9086..a7144ea6 100644 --- a/tests/regression.test.ts +++ b/tests/regression.test.ts @@ -19,6 +19,7 @@ describe('Regression Tests - Critical Prisma-Backed Flows', () => { const user = await prisma.user.create({ data: { walletAddress: userId, + authPublicKey: userId, network: 'MAINNET', }, }) @@ -50,6 +51,7 @@ describe('Regression Tests - Critical Prisma-Backed Flows', () => { const user = await prisma.user.create({ data: { walletAddress: userId, + authPublicKey: userId, network: 'MAINNET', }, }) diff --git a/tests/unit/compliance/scoring.test.ts b/tests/unit/compliance/scoring.test.ts index 90d31e8b..7760aa7f 100644 --- a/tests/unit/compliance/scoring.test.ts +++ b/tests/unit/compliance/scoring.test.ts @@ -282,6 +282,22 @@ describe('computeVelocityFeature', () => { expect(result.reasonCodes).toContain('VELOCITY_HIGH_FREQUENCY') }) + it('includes internal transfers in high-frequency scoring', () => { + const history = Array.from({ length: 8 }, (_, i) => + hist({ + type: 'INTERNAL_TRANSFER', + createdAt: new Date(NOW.getTime() - (i + 1) * 60 * 60 * 1000), + }) + ) + const result = computeVelocityFeature( + tx({ type: 'INTERNAL_TRANSFER' }), + history, + cfg + ) + expect(result.triggered).toBe(true) + expect(result.reasonCodes).toContain('VELOCITY_HIGH_FREQUENCY') + }) + it('ignores transactions outside the velocity window', () => { const history = Array.from({ length: 8 }, () => hist({ createdAt: new Date(NOW.getTime() - 30 * DAY) }) @@ -387,6 +403,28 @@ describe('computeStructuringFeature', () => { expect(result.reasonCodes).toContain('STRUCTURING_SUB_THRESHOLD_PATTERN') }) + it('counts repeated internal transfers in the structuring feature', () => { + const history = [ + hist({ + type: 'INTERNAL_TRANSFER', + amount: 9_400, + createdAt: new Date(NOW.getTime() - 1 * DAY), + }), + hist({ + type: 'INTERNAL_TRANSFER', + amount: 9_600, + createdAt: new Date(NOW.getTime() - 2 * DAY), + }), + ] + const result = computeStructuringFeature( + tx({ type: 'INTERNAL_TRANSFER', amount: 9_500 }), + history, + cfg + ) + expect(result.triggered).toBe(true) + expect(result.reasonCodes).toContain('STRUCTURING_SUB_THRESHOLD_PATTERN') + }) + it('does not count occurrences outside the structuring window', () => { const history = [ hist({ amount: 9_400, createdAt: new Date(NOW.getTime() - 60 * DAY) }), @@ -434,6 +472,16 @@ describe('computeNewDestinationFeature', () => { expect(result.triggered).toBe(false) }) + it('treats a first-time internal recipient handle as a new destination', () => { + const result = computeNewDestinationFeature( + tx({ type: 'INTERNAL_TRANSFER', destinationAddress: 'ABCD2345' }), + account(), + cfg + ) + expect(result.triggered).toBe(true) + expect(result.reasonCodes).toContain('NEW_DESTINATION_NO_HISTORY') + }) + it('is triggered (moderately) for a new destination with a small amount', () => { const result = computeNewDestinationFeature( tx({ destinationAddress: 'GNEW', amount: 100 }), diff --git a/tests/unit/services/account-recovery.service.test.ts b/tests/unit/services/account-recovery.service.test.ts new file mode 100644 index 00000000..d0adadb5 --- /dev/null +++ b/tests/unit/services/account-recovery.service.test.ts @@ -0,0 +1,151 @@ +import db from '../../../src/db' +import { + completeRecoveryRequest, + hashRecoveryToken, + RECOVERY_DELAY_HOURS, +} from '../../../src/services/account-recovery.service' +import { dispatchOutboundNotifications } from '../../../src/services/outboundNotifications' +import { closeUserSockets } from '../../../src/ws/server' +import { invalidateAllUserCaches } from '../../../src/utils/user-cache-invalidation' +import { publishUserEvent } from '../../../src/events/publisher' + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: { + $transaction: jest.fn(), + emailIdentity: { findUnique: jest.fn() }, + }, +})) +jest.mock('../../../src/services/outboundNotifications', () => ({ + dispatchOutboundNotifications: jest.fn().mockResolvedValue(undefined), +})) +jest.mock('../../../src/ws/server', () => ({ + closeUserSockets: jest.fn(), +})) +jest.mock('../../../src/utils/user-cache-invalidation', () => ({ + invalidateAllUserCaches: jest.fn(), +})) +jest.mock('../../../src/events/publisher', () => ({ + publishUserEvent: jest.fn(), +})) +jest.mock('../../../src/utils/logger', () => ({ + logger: { warn: jest.fn(), error: jest.fn() }, +})) + +const mockDb = db as unknown as { + $transaction: jest.Mock + emailIdentity: { findUnique: jest.Mock } +} + +function transactionClient() { + return { + $queryRaw: jest.fn(), + recoveryRequest: { + findFirst: jest.fn(), + update: jest.fn(), + }, + recoveryPolicy: { findUnique: jest.fn() }, + user: { findFirst: jest.fn(), update: jest.fn() }, + session: { updateMany: jest.fn() }, + userApiKey: { updateMany: jest.fn() }, + totpCredential: { deleteMany: jest.fn() }, + webAuthnCredential: { deleteMany: jest.fn() }, + adminAuditLog: { create: jest.fn() }, + outboundNotification: { create: jest.fn() }, + } +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('account recovery service', () => { + it('uses a fixed 72-hour delay and hashes cancellation tokens', () => { + const token = 'a'.repeat(64) + expect(RECOVERY_DELAY_HOURS).toBe(72) + expect(hashRecoveryToken(token)).toBe(hashRecoveryToken(token)) + expect(hashRecoveryToken(token)).not.toBe(token) + }) + + it('changes only the sign-in key and invalidates all other credentials at completion', async () => { + const tx = transactionClient() + tx.recoveryRequest.findFirst.mockResolvedValue({ + id: 'request-1', + ownerUserId: 'owner-1', + proposedAuthPublicKey: 'GNEWKEY', + owner: { id: 'owner-1', isActive: true, walletAddress: 'GASSETWALLET' }, + approvals: [{ id: 'approval-1' }, { id: 'approval-2' }], + quorumReachedAt: new Date(Date.now() - 73 * 60 * 60 * 1000), + }) + tx.recoveryPolicy.findUnique.mockResolvedValue({ requiredApprovals: 2 }) + tx.user.findFirst.mockResolvedValue(null) + tx.session.updateMany.mockResolvedValue({ count: 3 }) + tx.userApiKey.updateMany.mockResolvedValue({ count: 2 }) + tx.totpCredential.deleteMany.mockResolvedValue({ count: 1 }) + tx.webAuthnCredential.deleteMany.mockResolvedValue({ count: 2 }) + mockDb.$transaction.mockImplementation((work) => work(tx)) + mockDb.emailIdentity.findUnique.mockResolvedValue(null) + + await expect(completeRecoveryRequest('request-1')).resolves.toBe(true) + + expect(tx.user.update).toHaveBeenCalledWith({ + where: { id: 'owner-1' }, + data: { authPublicKey: 'GNEWKEY' }, + }) + expect(tx.session.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: 'owner-1', revokedAt: null }, + data: expect.objectContaining({ + revokedAt: expect.any(Date), + revokedReason: 'account_recovery', + refreshTokenHash: null, + }), + }) + ) + expect(tx.userApiKey.updateMany).toHaveBeenCalled() + expect(tx.totpCredential.deleteMany).toHaveBeenCalledWith({ + where: { userId: 'owner-1' }, + }) + expect(tx.webAuthnCredential.deleteMany).toHaveBeenCalledWith({ + where: { userId: 'owner-1' }, + }) + expect(tx.adminAuditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + action: 'RECOVERY_COMPLETED', + target: 'owner-1', + details: expect.objectContaining({ + sessionsRevoked: 3, + apiKeysRevoked: 2, + totpCredentialsReset: 1, + passkeysRemoved: 2, + }), + }), + }) + ) + expect(closeUserSockets).toHaveBeenCalledWith( + 'owner-1', + 'Account recovery completed' + ) + expect(invalidateAllUserCaches).toHaveBeenCalledWith('owner-1') + expect(publishUserEvent).toHaveBeenCalledWith( + 'owner-1', + 'alerts', + 'security.recovery_completed', + expect.any(Object) + ) + expect(dispatchOutboundNotifications).toHaveBeenCalled() + }) + + it('does not change credentials before the cooling-off deadline', async () => { + const tx = transactionClient() + tx.recoveryRequest.findFirst.mockResolvedValue(null) + mockDb.$transaction.mockImplementation((work) => work(tx)) + + await expect(completeRecoveryRequest('request-1')).resolves.toBe(false) + + expect(tx.user.update).not.toHaveBeenCalled() + expect(tx.session.updateMany).not.toHaveBeenCalled() + expect(closeUserSockets).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/services/internal-transfer.service.test.ts b/tests/unit/services/internal-transfer.service.test.ts new file mode 100644 index 00000000..ab2c5eb8 --- /dev/null +++ b/tests/unit/services/internal-transfer.service.test.ts @@ -0,0 +1,226 @@ +import { Prisma } from '@prisma/client' +import db from '../../../src/db' +import { + executeInternalTransfer, + InternalTransferError, +} from '../../../src/services/internal-transfer.service' + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: { $transaction: jest.fn() }, +})) +jest.mock('../../../src/referral/service', () => ({ + getOrCreateReferralCode: jest.fn().mockResolvedValue({ + code: 'SEND2345', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }), +})) + +const mockDb = db as unknown as { $transaction: jest.Mock } + +function transactionClient(balance = '10', lots: object[] = []) { + let transactionCount = 0 + const tx = { + $executeRaw: jest.fn(), + $queryRaw: jest.fn(), + referralCode: { + findUnique: jest.fn().mockResolvedValue({ + owner: { + id: 'recipient', + network: 'MAINNET', + isActive: true, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + }, + }), + }, + user: { + findUnique: jest.fn().mockResolvedValue({ + id: 'sender', + network: 'MAINNET', + isActive: true, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + }), + findMany: jest.fn().mockResolvedValue([ + { id: 'sender', isActive: true }, + { id: 'recipient', isActive: true }, + ]), + }, + complianceCase: { findMany: jest.fn().mockResolvedValue([]) }, + subAccount: { findUnique: jest.fn().mockResolvedValue(null) }, + transaction: { + findMany: jest.fn().mockResolvedValue([]), + aggregate: jest.fn().mockResolvedValue({ _sum: { amount: null } }), + create: jest.fn().mockImplementation(() => ({ + id: `transaction-${++transactionCount}`, + })), + }, + internalTransfer: { + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({ id: 'transfer-1' }), + }, + position: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'sender-position', + protocolName: 'Test Protocol', + assetAddress: null, + currentValue: new Prisma.Decimal(balance), + depositedAmount: new Prisma.Decimal(balance), + yieldEarned: new Prisma.Decimal(0), + }, + ]), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: 'recipient-position' }), + update: jest.fn().mockResolvedValue({}), + }, + savingsGoal: { findMany: jest.fn().mockResolvedValue([]) }, + costBasisLot: { + findMany: jest.fn().mockResolvedValue(lots), + update: jest.fn().mockResolvedValue({}), + create: jest.fn().mockResolvedValue({}), + }, + adminAuditLog: { createMany: jest.fn().mockResolvedValue({ count: 2 }) }, + } + return tx +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('executeInternalTransfer', () => { + it('moves an existing position balance atomically and audits both sides', async () => { + const acquiredAt = new Date('2025-06-01T00:00:00.000Z') + const tx = transactionClient('10', [ + { + id: 'sender-lot', + remainingAmount: new Prisma.Decimal('10'), + acquisitionPrice: new Prisma.Decimal('1'), + priceSource: 'STABLECOIN_ASSUMPTION', + acquiredAt, + }, + ]) + mockDb.$transaction.mockImplementation((work) => work(tx)) + + await expect( + executeInternalTransfer({ + senderUserId: 'sender', + recipientHandle: 'abcd2345', + amount: '3', + assetSymbol: 'USDC', + }) + ).resolves.toMatchObject({ + transferId: 'transfer-1', + transactionId: 'transaction-1', + recipientHandle: 'ABCD2345', + amount: '3', + }) + + expect(tx.position.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: 'sender-position', + currentValue: { gte: new Prisma.Decimal('3') }, + }, + data: { + currentValue: { decrement: new Prisma.Decimal('3') }, + depositedAmount: { decrement: new Prisma.Decimal('3') }, + yieldEarned: { decrement: new Prisma.Decimal('0') }, + }, + }) + ) + expect(tx.position.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'recipient-position' }, + data: expect.objectContaining({ + currentValue: { increment: new Prisma.Decimal('3') }, + }), + }) + ) + expect(tx.adminAuditLog.createMany).toHaveBeenCalledWith({ + data: expect.arrayContaining([ + expect.objectContaining({ action: 'P2P_TRANSFER_SENT' }), + expect.objectContaining({ action: 'P2P_TRANSFER_RECEIVED' }), + ]), + }) + expect(tx.internalTransfer.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ riskScore: expect.any(Number) }), + }) + ) + expect(tx.costBasisLot.update).toHaveBeenCalledWith({ + where: { id: 'sender-lot' }, + data: { remainingAmount: { decrement: new Prisma.Decimal('3') } }, + }) + expect(tx.costBasisLot.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: 'recipient', + transactionId: 'transaction-2', + originalAmount: new Prisma.Decimal('3'), + remainingAmount: new Prisma.Decimal('3'), + acquisitionPrice: new Prisma.Decimal('1'), + priceSource: 'STABLECOIN_ASSUMPTION', + acquiredAt, + }), + }) + }) + + it('rejects an amount exceeding unlocked balance before creating transfer records', async () => { + const tx = transactionClient('1') + mockDb.$transaction.mockImplementation((work) => work(tx)) + + await expect( + executeInternalTransfer({ + senderUserId: 'sender', + recipientHandle: 'ABCD2345', + amount: '2', + assetSymbol: 'USDC', + }) + ).rejects.toMatchObject>({ + message: + 'Insufficient available balance after pending withdrawal reservations', + statusCode: 409, + }) + + expect(tx.internalTransfer.create).not.toHaveBeenCalled() + }) + + it('rejects a frozen recipient before debiting the sender', async () => { + const tx = transactionClient() + tx.complianceCase.findMany.mockResolvedValue([{ userId: 'recipient' }]) + mockDb.$transaction.mockImplementation((work) => work(tx)) + + await expect( + executeInternalTransfer({ + senderUserId: 'sender', + recipientHandle: 'ABCD2345', + amount: '2', + assetSymbol: 'USDC', + }) + ).rejects.toMatchObject({ statusCode: 423 }) + + expect(tx.position.updateMany).not.toHaveBeenCalled() + expect(tx.internalTransfer.create).not.toHaveBeenCalled() + }) + + it('rejects a parent transfer when the recipient sub-account lacks deposit permission', async () => { + const tx = transactionClient() + tx.subAccount.findUnique.mockResolvedValue({ + status: 'ACTIVE', + permissions: ['WITHDRAW'], + }) + mockDb.$transaction.mockImplementation((work) => work(tx)) + + await expect( + executeInternalTransfer({ + senderUserId: 'sender', + recipientHandle: 'ABCD2345', + amount: '2', + assetSymbol: 'USDC', + }) + ).rejects.toMatchObject({ statusCode: 403 }) + + expect(tx.position.updateMany).not.toHaveBeenCalled() + }) +})