diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..89edbab --- /dev/null +++ b/.env.test @@ -0,0 +1,28 @@ +# Test environment for CI contract smoke tests (.github/workflows/api-contract.yml). +# Non-secret stubs only — must satisfy src/config/env.ts format validation. +NODE_ENV=test +PORT=3000 + +DATABASE_URL=postgresql://user:pass@localhost:5432/db + +STELLAR_NETWORK=testnet +STELLAR_RPC_URL=https://rpc.example.com +STELLAR_AGENT_SECRET_KEY=SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +VAULT_CONTRACT_ID=CDUMMYVAULTCONTRACTID +USDC_TOKEN_ADDRESS=CDUMMYUSDC + +ANTHROPIC_API_KEY=sk-ant-smoke-key + +WALLET_ENCRYPTION_KEY=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 + +JWT_SEED=smoke-jwt-seed-0123456789abcdef0123456789 +JWT_SESSION_TTL_HOURS=24 +JWT_NONCE_TTL_MS=300000 +JWT_CLEANUP_INTERVAL_MS=86400000 + +TWILIO_AUTH_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +INTERNAL_SERVICE_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +TRUSTED_IPS=127.0.0.1 +CORS_ORIGINS=* +HTTP_CLIENT_TIMEOUT_MS=1000 diff --git a/.github/workflows/api-contract.yml b/.github/workflows/api-contract.yml index 4569b8f..6f89fec 100644 --- a/.github/workflows/api-contract.yml +++ b/.github/workflows/api-contract.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install Redocly CLI run: npm install -g @redocly/cli@latest @@ -36,30 +36,74 @@ jobs: name: Contract smoke tests runs-on: ubuntu-latest needs: validate-spec + services: + postgres: + image: postgres:14.4 + env: + POSTGRES_USER: user + POSTGRES_PASSWORD: pass + POSTGRES_DB: db + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U user" + --health-interval=10s + --health-timeout=5s + --health-retries=5 steps: - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' - name: Install dependencies run: npm ci + - name: Prisma generate + run: npx prisma generate + env: + DATABASE_URL: postgresql://user:pass@localhost:5432/db + + - name: Prisma migrate deploy + run: npx prisma migrate deploy + env: + DATABASE_URL: postgresql://user:pass@localhost:5432/db + - name: Start server run: | cp .env.test .env npm run build - node dist/index.js & - # Wait up to 15 seconds for the server to accept connections - for i in $(seq 1 15); do - curl -sf http://localhost:3000/health && break + node dist/index.js > /tmp/server.log 2>&1 & + echo $! > /tmp/server.pid + # initServices() boots the DB, event listener, and agent loop before + # app.listen(), so allow generous time — and fail loudly with the + # server log rather than letting a later step report a bare failure. + for i in $(seq 1 60); do + if curl -sf http://localhost:3000/health > /dev/null 2>&1; then + echo "Server ready after ${i}s" + exit 0 + fi + if ! kill -0 "$(cat /tmp/server.pid)" 2>/dev/null; then + echo "::error::Server process exited during startup" + cat /tmp/server.log + exit 1 + fi sleep 1 done + echo "::error::Server did not become ready within 60s" + cat /tmp/server.log + exit 1 env: NODE_ENV: test + # This job boots the real server, and startEventListener() calls + # getLatestLedger() with no client timeout before app.listen(). Point + # it at a reachable RPC (as production-smoke.yml does) so an + # unresolvable host cannot stall startup. dotenv does not override + # ambient vars, so this wins over .env.test. + STELLAR_RPC_URL: https://soroban-testnet.stellar.org - name: Health liveness check run: | diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 0838e24..63dbd5b 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -35,8 +35,8 @@ jobs: STELLAR_AGENT_SECRET_KEY: SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX VAULT_CONTRACT_ID: CDUMMYVAULTCONTRACTID USDC_TOKEN_ADDRESS: CDUMMYUSDC - ANTHROPIC_API_KEY: smoke-anthropic-key - JWT_SEED: smoke-jwt-seed + ANTHROPIC_API_KEY: sk-ant-smoke-key + JWT_SEED: smoke-jwt-seed-0123456789abcdef0123456789 JWT_SESSION_TTL_HOURS: '24' JWT_NONCE_TTL_MS: '300000' JWT_CLEANUP_INTERVAL_MS: '86400000' @@ -55,7 +55,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' # Install dependencies @@ -103,7 +103,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' - name: Install dependencies @@ -115,10 +115,13 @@ jobs: - name: License check (block GPL/AGPL/LGPL) # Prevents copyleft licences from entering the dependency tree. - # Allowed: MIT, ISC, Apache-2.0, BSD-2-Clause, BSD-3-Clause, 0BSD, BlueOak-1.0.0, CC0-1.0 + # Allowed: MIT, ISC, Apache-2.0, BSD-2-Clause, BSD-3-Clause, 0BSD, BlueOak-1.0.0, CC0-1.0, + # Python-2.0 (PSF — permissive, transitive argparse@2), + # CC-BY-4.0 (attribution-only, transitive caniuse-lite data), + # Unlicense (public-domain dedication, transitive fast-sha256) run: | npx license-checker --onlyAllow \ - 'MIT;ISC;Apache-2.0;BSD-2-Clause;BSD-3-Clause;0BSD;BlueOak-1.0.0;CC0-1.0' \ + 'MIT;ISC;Apache-2.0;BSD-2-Clause;BSD-3-Clause;0BSD;BlueOak-1.0.0;CC0-1.0;Python-2.0;CC-BY-4.0;Unlicense' \ --excludePrivatePackages # ── Issue #100: migration smoke gate ────────────────────────────────────── @@ -145,19 +148,26 @@ jobs: --health-retries=5 env: NODE_ENV: test + # scripts/smoke-health.sh probes port 3001 — the server must listen there. + PORT: '3001' DATABASE_URL: postgresql://smoke_user:smoke_pass@localhost:5433/smoke_db # Non-secret stubs required by env.ts at module-load time STELLAR_NETWORK: testnet - STELLAR_RPC_URL: https://rpc.example.com + # This job boots the real server; startEventListener() calls + # getLatestLedger() with no client timeout before app.listen(), so the + # endpoint must resolve or startup stalls. + STELLAR_RPC_URL: https://soroban-testnet.stellar.org STELLAR_AGENT_SECRET_KEY: SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX VAULT_CONTRACT_ID: CDUMMYVAULTCONTRACTID USDC_TOKEN_ADDRESS: CDUMMYUSDC - ANTHROPIC_API_KEY: smoke-anthropic-key - JWT_SEED: smoke-jwt-seed + ANTHROPIC_API_KEY: sk-ant-smoke-key + JWT_SEED: smoke-jwt-seed-0123456789abcdef0123456789 JWT_SESSION_TTL_HOURS: '24' JWT_NONCE_TTL_MS: '300000' JWT_CLEANUP_INTERVAL_MS: '86400000' WALLET_ENCRYPTION_KEY: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 + # Required by env.ts and asserted again by initServices(). + TWILIO_AUTH_TOKEN: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX steps: - name: Checkout repository @@ -166,7 +176,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' - name: Install dependencies @@ -175,8 +185,11 @@ jobs: - name: Prisma generate run: npx prisma generate - - name: Check migration status (staging gate) - run: npx prisma migrate status + # Informational only: on a fresh smoke database every migration is + # pending, and `migrate status` exits non-zero whenever that is true. + # The real gate is "Confirm no pending migrations after deploy" below. + - name: Check migration status (pre-deploy, informational) + run: npx prisma migrate status || true - name: Apply migrations to smoke DB run: npx prisma migrate deploy @@ -189,6 +202,10 @@ jobs: exit 1 fi + # smoke-health.sh runs the compiled server, so the artifact must exist. + - name: Build project + run: npm run build + - name: Run smoke test run: npm run smoke @@ -205,7 +222,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install markdown-link-check run: npm install -g markdown-link-check @@ -229,7 +246,7 @@ jobs: for file in docs/*.md; do if [ -f "$file" ]; then # Count opening and closing code fences - open=$(grep -c '^\`\`\`' "$file" || true) + open=$(grep -c '^```' "$file" || true) if [ $((open % 2)) -ne 0 ]; then echo "::error file=$file::Unclosed code fence detected" exit 1 @@ -237,23 +254,10 @@ jobs: fi done - - name: Install kubectl - uses: azure/setup-kubectl@v4 - with: - version: 'v1.29.0' - - - name: Validate Kubernetes manifests - run: | - # Validate all YAML manifests in deploy/k8s - for manifest in deploy/k8s/*.yaml; do - if [ -f "$manifest" ]; then - echo "Validating $manifest" - kubectl apply --dry-run=client -f "$manifest" || { - echo "::error file=$manifest::Kubernetes manifest validation failed" - exit 1 - } - fi - done + # Kubernetes manifest validation lives in k8s-validate.yml (kubeconform, + # fully offline) and triggers whenever deploy/k8s changes. kubectl's + # client dry-run needs a live cluster for API discovery, so it can never + # pass on a bare runner and was removed from this job. - name: Check referenced file paths in docs run: | diff --git a/.github/workflows/production-smoke.yml b/.github/workflows/production-smoke.yml index e50eff6..3421f05 100644 --- a/.github/workflows/production-smoke.yml +++ b/.github/workflows/production-smoke.yml @@ -41,13 +41,13 @@ jobs: STELLAR_AGENT_SECRET_KEY: SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX VAULT_CONTRACT_ID: CDUMMYVAULTCONTRACTID USDC_TOKEN_ADDRESS: CDUMMYUSDC - ANTHROPIC_API_KEY: smoke-anthropic-key + ANTHROPIC_API_KEY: sk-ant-smoke-key JWT_SEED: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 JWT_SESSION_TTL_HOURS: '24' JWT_NONCE_TTL_MS: '300000' JWT_CLEANUP_INTERVAL_MS: '86400000' WALLET_ENCRYPTION_KEY: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 - TWILIO_AUTH_TOKEN: smoke-twilio-token + TWILIO_AUTH_TOKEN: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX steps: - name: Checkout repository @@ -56,7 +56,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: npm - name: Install dependencies (build) @@ -78,4 +78,4 @@ jobs: run: npx prisma@5.22.0 generate - name: Run startup smoke check (/health) - run: npm run smoke:health + run: npm run smoke diff --git a/.gitignore b/.gitignore index 018d7ee..842f360 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ coverage/ .agents/ skills-lock.json CLAUDE.md -plan.md \ No newline at end of file +plan.md +tasks/ \ No newline at end of file diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 011f3fc..6e7c849 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -396,6 +396,62 @@ Response 404: "error": "User not found" } +### GET /api/portfolio/:userId/tax-report + +- Auth: required (requireAuth + enforceUserAccess) +- Path params: + - userId: uuid string +- Query params: + - year: integer (2000–2100), required — calendar year, UTC boundaries + - format: enum(json, csv), default json +- Request body: none + +Realized gain/loss report computed with FIFO cost-basis lot accounting over +confirmed on-chain withdrawals. Money fields are decimal strings; null means +"unpriced" (excluded from totals), never zero. See docs/TAX_REPORT.md for +methodology and known limitations. + +Example request: +GET /api/portfolio/550e8400-e29b-41d4-a716-446655440001/tax-report?year=2026 + +Response 200 (json): +{ +"userId": "550e8400-e29b-41d4-a716-446655440001", +"year": 2026, +"method": "FIFO", +"disposals": [ +{ +"disposedAt": "2026-06-15T00:00:00.000Z", +"assetSymbol": "USDC", +"amount": "40", +"withdrawalTxHash": "c1d2...", +"acquiredAt": "2026-01-15T00:00:00.000Z", +"acquisitionTxHash": "a1b2...", +"acquisitionPrice": "1", +"disposalPrice": "1", +"costBasis": "40", +"proceeds": "40", +"realizedGain": "0", +"priced": true +} +], +"totals": { "proceeds": "40", "costBasis": "40", "realizedGain": "0", "pricedDisposalCount": 1 }, +"caveats": { "unpricedDisposalCount": 0, "unpricedAssets": [], "stablecoinAssumption": "...", "rebalancesNotIncluded": "..." } +} + +Response 200 (format=csv): text/csv attachment (tax-report-2026.csv), header +row plus one row per disposal, formula-injection-safe cells. + +Response 401: +{ +"error": "Unauthorized" +} + +Response 404: +{ +"error": "User not found" +} + --- ## Transactions diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md index a1962db..1aaeaab 100644 --- a/docs/DOCUMENTATION_INDEX.md +++ b/docs/DOCUMENTATION_INDEX.md @@ -8,6 +8,7 @@ - **[CODE_STRUCTURE.md](CODE_STRUCTURE.md)** - Understand code organization and design - **[IMPLEMENTATION_DETAILS.md](IMPLEMENTATION_DETAILS.md)** - Deep dive into implementation - **[API_REFERENCE.md](API_REFERENCE.md)** - Complete backend endpoint reference +- **[TAX_REPORT.md](TAX_REPORT.md)** - Tax reporting & FIFO cost-basis lot tracking (#284) ### For DevOps/Deployment diff --git a/docs/TAX_REPORT.md b/docs/TAX_REPORT.md new file mode 100644 index 0000000..6af8cc6 --- /dev/null +++ b/docs/TAX_REPORT.md @@ -0,0 +1,149 @@ +# Tax Reporting & Cost-Basis Lot Tracking + +Answers "what's my realized gain/loss this year?" (#284). Every confirmed +on-chain deposit creates a **cost-basis lot**; every confirmed on-chain +withdrawal consumes open lots **FIFO** and records immutable **disposal** rows +snapshotting cost basis, proceeds, and realized gain at disposal time. The +report endpoint is a pure read over that ledger. + +The design principle throughout: **tax bookkeeping is derived data**. It is +written transactionally alongside the deposit/withdrawal it derives from, but a +tax problem must never block or roll back a confirmed on-chain transaction — +failures are loud (structured error log + alert) and repairable by an +idempotent backfill, never silent. + +## Data model + +| Model | Meaning | +| --- | --- | +| `CostBasisLot` | One per confirmed DEPOSIT Transaction (`transactionId` unique). Carries `originalAmount`, `remainingAmount`, nullable `acquisitionPrice` + `priceSource`, `acquiredAt`. | +| `LotDisposal` | One lot's share of a withdrawal. A withdrawal may span many lots (`@@unique([transactionId, lotId])`). Snapshots `disposalPrice`, `costBasis`, `proceeds`, `realizedGain` — nullable, where null means **unpriced, never zero**. | + +The schema carries no accounting-method column: FIFO ordering (acquiredAt asc, +id tiebreak) lives in `src/tax/fifo.ts`, so LIFO/HIFO could be added later +without a schema change. + +## Write path (who creates lots) + +The **Stellar event listener is authoritative**, matching how Positions work: + +- `handleDepositEvent` → `createLotForDeposit(...)` on the same transaction + handle as the deposit's Transaction/Position writes. +- `handleWithdrawEvent` → `recordDisposalsForWithdrawal(...)`, likewise — and + it runs even when no Position matched, because the confirmed Transaction is + the disposal source of truth. +- The HTTP deposit/withdraw controller does **not** create lots (it never + touched Positions either). A Transaction only seen over HTTP and never + re-observed by the event listener gets no lot/disposal — accepted and + reconcilable (see below). +- Rebalances are **not** disposals (see Known limitations). + +Both service functions are idempotent under event replay and the batch-failure +fallback path: lot creation relies on the `transactionId` unique constraint +(P2002 → quiet skip), disposal recording on an exists-check plus +`(transactionId, lotId)` uniqueness. + +### Failure behavior (invariants) + +- `remainingAmount` never goes negative; disposal is **all-or-nothing** per + withdrawal. If open lots can't cover the amount, **nothing is written**, a + critical alert fires, and the withdrawal proceeds untouched. Partial rows + would poison later repair; with nothing written, re-running the recorder + after backfill produces the correct ledger. +- Alert emission is fire-and-forget — no awaited network I/O inside the DB + transaction. + +## Pricing + +| Asset | Price | Source | +| --- | --- | --- | +| USDC | `1.0` USD per token | `STABLECOIN_ASSUMPTION` (surfaced in report caveats) | +| anything else | `null` | — | + +Unpriced lots/disposals keep null money fields, are flagged `priced: false`, +and are **excluded from report totals** with a visible caveat +(`unpricedDisposalCount`, `unpricedAssets`). Never silently zeroed. + +### Units + +Lot amounts inherit the Transaction's `amount` units verbatim, so lots and +Positions are internally consistent with each other by construction. The +`1.0` USDC price is **per token**. Event parsers pass the on-chain `amount` +through unscaled — if the vault contract emits stroop-scaled (1e7) integer +amounts on your deployment, priced totals will be scaled by the same factor. +**Verify one real deposit event's persisted `Transaction.amount` against the +wallet-visible token amount before trusting priced totals on a new network.** + +## Endpoint + +``` +GET /api/v1/portfolio/:userId/tax-report?year=&format=json|csv +``` + +- Auth: `requireAuth` + `enforceUserAccess` (own report only). The userId is a + path param deliberately — `enforceUserAccess` only checks + `params.userId`/`body.userId`, so a query-param userId would bypass it. +- `year` is bounded 2000–2100; boundaries are **UTC** (`disposedAt` in + `[Jan 1 00:00 UTC, next Jan 1)`). A disposal belongs to the year it was + disposed in, regardless of when the lot was acquired. +- A year with no activity returns a valid empty report (200). +- `format=csv` returns an RFC 4180 attachment (`tax-report-.csv`). + Cells starting with `=` `+` `-` `@` tab or CR are prefixed with `'` + (spreadsheet formula-injection guard, `src/utils/csv.ts`). + +Money values are decimal strings. `totals` sums only fully priced disposals. + +## Backfill + +``` +npx ts-node scripts/backfill-cost-basis-lots.ts [--dry-run] +``` + +Replays all CONFIRMED DEPOSIT/WITHDRAWAL Transactions in `confirmedAt` order +through the same service functions. **Run once when deploying this feature**: +without it, tracking starts forward-only and every pre-existing user's first +withdrawal fires a false-positive "insufficient lots" critical alert. Safe to +re-run any time (idempotent); also the repair tool after any lot-creation +failure alert. + +## Reconciliation queries + +Confirmed deposits missing a lot: + +```sql +SELECT t.id, t."userId", t."txHash", t.amount +FROM transactions t +LEFT JOIN cost_basis_lots l ON l."transactionId" = t.id +WHERE t.type = 'DEPOSIT' AND t.status = 'CONFIRMED' AND l.id IS NULL; +``` + +Confirmed withdrawals with no disposal rows: + +```sql +SELECT t.id, t."userId", t."txHash", t.amount +FROM transactions t +LEFT JOIN lot_disposals d ON d."transactionId" = t.id +WHERE t.type = 'WITHDRAWAL' AND t.status = 'CONFIRMED' AND d.id IS NULL +GROUP BY t.id; +``` + +Non-empty results → run the backfill script, then re-check. Rows that persist +indicate an insufficient-lots condition (see the paired critical alert). + +## Known limitations (v1) + +1. **FIFO only.** No LIFO/HIFO/specific-identification election. +2. **Rebalances are not disposals.** Rebalance events carry no per-user + amounts (protocol/APY only) and are same-asset protocol moves; some tax + regimes may treat them differently — not modeled. +3. **Non-USDC assets are unpriced** and excluded from totals (flagged in + caveats). No market price feed is integrated. +4. **USDC 1:1 USD assumption** — actual market price may deviate slightly. +5. **HTTP-controller-only transactions** never re-seen by the event listener + get no lots/disposals (consistent with Position behavior). +6. **UTC year boundaries** — users in other timezones may expect local-time + year edges. +7. **Forward-only unless the backfill script is run** at deploy. +8. Yield claims, referral rewards, and swaps do not create or consume lots; + only DEPOSIT/WITHDRAWAL Transactions participate. +9. This is bookkeeping output, **not tax advice**; jurisdictions differ. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 23fed91..7f5c59c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -480,6 +480,95 @@ paths: '404': $ref: '#/components/responses/NotFound' + /api/v1/portfolio/{userId}/tax-report: + get: + tags: [portfolio] + operationId: getPortfolioTaxReport + summary: Get realized gain/loss tax report + description: | + Returns the user's realized gain/loss report for a calendar year (UTC + boundaries), computed with FIFO cost-basis lot accounting over + confirmed on-chain withdrawals. Totals include only fully priced + disposals; unpriced assets are flagged in `caveats`, never zeroed. + See docs/TAX_REPORT.md for methodology and known limitations. The + authenticated user can only access their own report + (enforceUserAccess). With `format=csv` the report's disposal rows are + returned as an RFC 4180 CSV attachment with spreadsheet + formula-injection guarding. + security: + - BearerAuth: [] + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + description: User ID (UUID v4) + - in: query + name: year + required: true + schema: + type: integer + minimum: 2000 + maximum: 2100 + description: Calendar year (UTC) the report covers + - in: query + name: format + schema: + type: string + enum: [json, csv] + default: json + description: Response format + responses: + '200': + description: Tax report for the requested year + content: + application/json: + schema: + $ref: '#/components/schemas/TaxReport' + example: + userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + year: 2026 + method: FIFO + disposals: + - disposedAt: '2026-06-15T00:00:00.000Z' + assetSymbol: USDC + amount: '40' + withdrawalTxHash: c1d2e3f4a5b6c7d8e9f0 + acquiredAt: '2026-01-15T00:00:00.000Z' + acquisitionTxHash: a1b2c3d4e5f6a7b8c9d0 + acquisitionPrice: '1' + disposalPrice: '1' + costBasis: '40' + proceeds: '40' + realizedGain: '0' + priced: true + totals: + proceeds: '40' + costBasis: '40' + realizedGain: '0' + pricedDisposalCount: 1 + caveats: + unpricedDisposalCount: 0 + unpricedAssets: [] + stablecoinAssumption: USDC is priced at 1.00 USD by assumption (STABLECOIN_ASSUMPTION); no market price feed is used. + rebalancesNotIncluded: Protocol rebalances are same-asset transfers and are not treated as taxable disposals in this report. + text/csv: + schema: + type: string + description: | + One header row plus one row per disposal, CRLF line endings. + Cells beginning with `=`, `+`, `-`, `@`, tab, or CR are + prefixed with `'` to prevent spreadsheet formula injection. + example: | + disposedAt,assetSymbol,amount,withdrawalTxHash,acquiredAt,acquisitionTxHash,acquisitionPrice,disposalPrice,costBasis,proceeds,realizedGain,priced + 2026-06-15T00:00:00.000Z,USDC,40,c1d2e3f4a5b6c7d8e9f0,2026-01-15T00:00:00.000Z,a1b2c3d4e5f6a7b8c9d0,1,1,40,40,0,true + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + # ── Goal-based investing (#281) ───────────────────────────────────────────── /api/v1/portfolio/goals: post: @@ -2726,6 +2815,89 @@ components: type: string enum: [ACTIVE, CLOSED] + # ── Tax report (#284) ───────────────────────────────────────────────── + # Money fields are decimal strings (never floats) and null means + # "unpriced" — never zero. Totals sum only fully priced disposals. + TaxReport: + type: object + properties: + userId: + type: string + format: uuid + year: + type: integer + method: + type: string + enum: [FIFO] + disposals: + type: array + items: + $ref: '#/components/schemas/TaxReportDisposal' + totals: + type: object + properties: + proceeds: + type: string + costBasis: + type: string + realizedGain: + type: string + pricedDisposalCount: + type: integer + caveats: + type: object + properties: + unpricedDisposalCount: + type: integer + unpricedAssets: + type: array + items: + type: string + stablecoinAssumption: + type: string + rebalancesNotIncluded: + type: string + + TaxReportDisposal: + type: object + properties: + disposedAt: + type: string + format: date-time + assetSymbol: + type: string + example: USDC + amount: + type: string + description: Decimal string in asset units + withdrawalTxHash: + type: string + nullable: true + acquiredAt: + type: string + format: date-time + acquisitionTxHash: + type: string + nullable: true + acquisitionPrice: + type: string + nullable: true + disposalPrice: + type: string + nullable: true + costBasis: + type: string + nullable: true + proceeds: + type: string + nullable: true + realizedGain: + type: string + nullable: true + priced: + type: boolean + description: True when every money field is present; only priced disposals enter totals + # ── Transaction ─────────────────────────────────────────────────────── Transaction: type: object diff --git a/jest.config.js b/jest.config.js index ce68ec9..9454959 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,8 +13,17 @@ module.exports = { testEnvironment: 'node', roots: ['/tests'], testMatch: ['**/*.test.ts'], + // Must run before any test module so src/config/env.ts sees the test config + // at import time. See tests/setup-env.ts. + setupFiles: ['/tests/setup-env.ts'], transform: { '^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.test.json' }], + // @stellar/stellar-sdk's CJS build requires vendored @stellar/js-xdr + // *source* files, which are ESM — transpile them so jest (CJS) can load them. + '^.+\\.js$': ['ts-jest', { tsconfig: { allowJs: true } }], }, + transformIgnorePatterns: [ + '/node_modules/(?!(@stellar|\\.pnpm|@noble|uint8array-extras|smol-toml|eventsource|feaxios|base32\\.js))', + ], clearMocks: true, }; diff --git a/package-lock.json b/package-lock.json index f51b25d..ca04eb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ }, "devDependencies": { "@aws-sdk/client-ssm": "^3.1075.0", + "@redocly/cli": "^2.34.0", "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", @@ -50,9 +51,10 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.10.6", "@types/swagger-ui-express": "^4.1.8", - "@typescript-eslint/eslint-plugin": "^6.16.0", - "@typescript-eslint/parser": "^6.16.0", - "eslint": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^8.65.0", + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0", + "husky": "^9.1.7", "jest": "^29.7.0", "nodemon": "^3.0.2", "prettier": "^3.1.1", @@ -3370,6 +3372,21 @@ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, + "node_modules/@redocly/cli": { + "version": "2.40.0", + "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.40.0.tgz", + "integrity": "sha512-1uQ4GeNjhApy9EtypZgp70ZN5GC2JFfst3UkNEXSqkXgVIPGdEAnlz5Xwgax/4cEUGOvaZoM3X25iSQcqbplFg==", + "dev": true, + "license": "MIT", + "bin": { + "openapi": "bin/cli.js", + "redocly": "bin/cli.js" + }, + "engines": { + "node": ">=22.12.0 || >=20.19.0 <21.0.0", + "npm": ">=10" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -3895,11 +3912,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "dev": true, - "license": "MIT" - }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "dev": true, @@ -3991,11 +4003,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/semver": { - "version": "7.7.1", - "dev": true, - "license": "MIT" - }, "node_modules/@types/send": { "version": "1.2.1", "dev": true, @@ -4084,114 +4091,159 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.21.0", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/type-utils": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.4", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "6.21.0", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/@typescript-eslint/type-utils": { - "version": "6.21.0", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "6.21.0", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", "dev": true, "license": "MIT", "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -4199,72 +4251,88 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "6.21.0", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "semver": "^7.5.4" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "eslint-visitor-keys": "^3.4.1" + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.3", "dev": true, @@ -4414,14 +4482,6 @@ "version": "1.1.1", "license": "MIT" }, - "node_modules/array-union": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/asap": { "version": "2.0.6", "dev": true, @@ -4447,7 +4507,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.16.1", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -5273,17 +5335,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/doctrine": { "version": "3.0.0", "dev": true, @@ -5780,32 +5831,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "dev": true, @@ -6329,25 +6354,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "11.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/google-logging-utils": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", @@ -6492,6 +6498,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "license": "MIT", @@ -7704,14 +7726,6 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/meriyah": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", @@ -7776,19 +7790,44 @@ } }, "node_modules/minimatch": { - "version": "9.0.3", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/minimist": { "version": "1.2.8", "dev": true, @@ -7932,25 +7971,6 @@ "url": "https://opencollective.com/nodemon" } }, - "node_modules/nodemon/node_modules/balanced-match": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/nodemon/node_modules/brace-expansion": { - "version": "5.0.7", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/nodemon/node_modules/has-flag": { "version": "3.0.0", "dev": true, @@ -7959,20 +7979,6 @@ "node": ">=4" } }, - "node_modules/nodemon/node_modules/minimatch": { - "version": "10.2.5", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/nodemon/node_modules/supports-color": { "version": "5.5.0", "dev": true, @@ -8208,14 +8214,6 @@ "version": "0.1.13", "license": "MIT" }, - "node_modules/path-type": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -9307,6 +9305,54 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tmpl": { "version": "1.0.5", "dev": true, @@ -9354,14 +9400,16 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "1.4.3", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/ts-jest": { diff --git a/package.json b/package.json index e59c1be..b776dc1 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,12 @@ "scripts": { "start": "node dist/src/app.js", "dev": "nodemon --exec ts-node src/app.ts", - "build": "tsc", + "build": "tsc -p tsconfig.build.json", "test": "jest", "test:unit": "jest tests/unit", "test:integration": "jest tests/integration", "prisma:generate": "npx prisma generate", - "prepare": "husky", + "prepare": "husky || true", "test:cors": "jest tests/cors.test.ts", "test:watch": "jest --watch", "test:coverage": "jest --coverage", @@ -30,7 +30,7 @@ "author": "", "license": "ISC", "engines": { - "node": ">=18.0.0", + "node": ">=22.0.0", "npm": ">=9.0.0" }, "lint-staged": { @@ -89,7 +89,7 @@ }, "devDependencies": { "@aws-sdk/client-ssm": "^3.1075.0", - "@redocly/cli": "^1.34.5", + "@redocly/cli": "^2.34.0", "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", @@ -98,9 +98,10 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.10.6", "@types/swagger-ui-express": "^4.1.8", - "@typescript-eslint/eslint-plugin": "^6.16.0", - "@typescript-eslint/parser": "^6.16.0", - "eslint": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^8.65.0", + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0", + "husky": "^9.1.7", "jest": "^29.7.0", "nodemon": "^3.0.2", "prettier": "^3.1.1", @@ -109,5 +110,8 @@ "ts-jest": "^29.1.1", "ts-node": "^10.9.2", "typescript": "^5.3.3" + }, + "overrides": { + "axios": "^1.18.0" } } diff --git a/prisma/migrations/20260627000000_add_webhook_tables/rollback.sql b/prisma/migrations/20260627000000_add_webhook_tables/rollback.sql new file mode 100644 index 0000000..7340547 --- /dev/null +++ b/prisma/migrations/20260627000000_add_webhook_tables/rollback.sql @@ -0,0 +1,13 @@ +-- Rollback for 20260627000000_add_webhook_tables +-- Drops the webhook tables and the WebhookDeliveryStatus enum. +-- WARNING: Destroys all webhook subscriptions and delivery history. + +ALTER TABLE "webhook_deliveries" DROP CONSTRAINT IF EXISTS "webhook_deliveries_subscriptionId_fkey"; + +ALTER TABLE "webhook_subscriptions" DROP CONSTRAINT IF EXISTS "webhook_subscriptions_userId_fkey"; + +DROP TABLE IF EXISTS "webhook_deliveries"; + +DROP TABLE IF EXISTS "webhook_subscriptions"; + +DROP TYPE IF EXISTS "WebhookDeliveryStatus"; diff --git a/prisma/migrations/20260627_add_transaction_events/migration.sql b/prisma/migrations/20260627_add_transaction_events/migration.sql index 5931d17..111e5d8 100644 --- a/prisma/migrations/20260627_add_transaction_events/migration.sql +++ b/prisma/migrations/20260627_add_transaction_events/migration.sql @@ -21,11 +21,11 @@ CREATE INDEX "TransactionEvent_transactionId_occurredAt_idx" -- AddForeignKey ALTER TABLE "TransactionEvent" ADD CONSTRAINT "TransactionEvent_transactionId_fkey" - FOREIGN KEY ("transactionId") REFERENCES "Transaction"("id") + FOREIGN KEY ("transactionId") REFERENCES "transactions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- Backfill: create CONFIRMED events for already-completed transactions INSERT INTO "TransactionEvent" ("id", "transactionId", "event", "occurredAt") SELECT gen_random_uuid()::text, id, 'CONFIRMED', "updatedAt" -FROM "Transaction" -WHERE status = 'confirmed'; \ No newline at end of file +FROM "transactions" +WHERE status = 'CONFIRMED'; \ No newline at end of file diff --git a/prisma/migrations/20260716000000_add_protocol_risk_scores/rollback.sql b/prisma/migrations/20260716000000_add_protocol_risk_scores/rollback.sql new file mode 100644 index 0000000..6c61c4d --- /dev/null +++ b/prisma/migrations/20260716000000_add_protocol_risk_scores/rollback.sql @@ -0,0 +1,8 @@ +-- Rollback for 20260716000000_add_protocol_risk_scores +-- Drops the protocol risk score table and the AuditStatus enum. +-- WARNING: Destroys all computed protocol risk scores (recomputable by the +-- risk scoring job after re-applying the migration). + +DROP TABLE IF EXISTS "protocol_risk_scores"; + +DROP TYPE IF EXISTS "AuditStatus"; diff --git a/prisma/migrations/20260717510000_add_protocol_rate_raw_response/rollback.sql b/prisma/migrations/20260717510000_add_protocol_rate_raw_response/rollback.sql new file mode 100644 index 0000000..e64bac1 --- /dev/null +++ b/prisma/migrations/20260717510000_add_protocol_rate_raw_response/rollback.sql @@ -0,0 +1,5 @@ +-- Rollback for 20260717510000_add_protocol_rate_raw_response +-- Drops the rawResponse debug/audit column from protocol_rates. +-- WARNING: Destroys captured raw provider payloads for existing rows. + +ALTER TABLE "protocol_rates" DROP COLUMN IF EXISTS "rawResponse"; diff --git a/prisma/migrations/20260721000000_add_savings_goals/rollback.sql b/prisma/migrations/20260721000000_add_savings_goals/rollback.sql new file mode 100644 index 0000000..c57948c --- /dev/null +++ b/prisma/migrations/20260721000000_add_savings_goals/rollback.sql @@ -0,0 +1,16 @@ +-- Rollback for 20260721000000_add_savings_goals +-- Drops the savings goals table and the GoalStatus enum. +-- WARNING: Destroys all user savings goals and their progress state. +-- +-- NOTE: The 'GOAL_PROGRESS' value added to the "AgentAction" enum is NOT +-- removed. PostgreSQL cannot drop a single enum value, and any AgentLog rows +-- already written with that action would be orphaned. The value is left in +-- place; it is inert once the savings goals table is gone. + +ALTER TABLE "savings_goals" DROP CONSTRAINT IF EXISTS "savings_goals_positionId_fkey"; + +ALTER TABLE "savings_goals" DROP CONSTRAINT IF EXISTS "savings_goals_userId_fkey"; + +DROP TABLE IF EXISTS "savings_goals"; + +DROP TYPE IF EXISTS "GoalStatus"; diff --git a/prisma/migrations/20260721000000_add_tax_lot_tracking/migration.sql b/prisma/migrations/20260721000000_add_tax_lot_tracking/migration.sql new file mode 100644 index 0000000..3f20a4a --- /dev/null +++ b/prisma/migrations/20260721000000_add_tax_lot_tracking/migration.sql @@ -0,0 +1,68 @@ +-- Tax reporting & cost-basis lot tracking (#284). One lot per confirmed +-- deposit Transaction; FIFO disposals recorded per withdrawal. Nullable price +-- columns mean "unpriced" (excluded from report totals), never zero. + +-- CreateEnum +CREATE TYPE "PriceSource" AS ENUM ('STABLECOIN_ASSUMPTION'); + +-- CreateTable +CREATE TABLE "cost_basis_lots" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "transactionId" TEXT NOT NULL, + "assetSymbol" TEXT NOT NULL, + "originalAmount" DECIMAL(36,18) NOT NULL, + "remainingAmount" DECIMAL(36,18) NOT NULL, + "acquisitionPrice" DECIMAL(36,18), + "priceSource" "PriceSource", + "acquiredAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "cost_basis_lots_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "lot_disposals" ( + "id" TEXT NOT NULL, + "lotId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "assetSymbol" TEXT NOT NULL, + "transactionId" TEXT NOT NULL, + "amount" DECIMAL(36,18) NOT NULL, + "disposalPrice" DECIMAL(36,18), + "costBasis" DECIMAL(36,18), + "proceeds" DECIMAL(36,18), + "realizedGain" DECIMAL(36,18), + "disposedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "lot_disposals_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "cost_basis_lots_transactionId_key" ON "cost_basis_lots"("transactionId"); + +-- CreateIndex +CREATE INDEX "cost_basis_lots_userId_assetSymbol_acquiredAt_idx" ON "cost_basis_lots"("userId", "assetSymbol", "acquiredAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "lot_disposals_transactionId_lotId_key" ON "lot_disposals"("transactionId", "lotId"); + +-- CreateIndex +CREATE INDEX "lot_disposals_userId_disposedAt_idx" ON "lot_disposals"("userId", "disposedAt"); + +-- AddForeignKey +ALTER TABLE "cost_basis_lots" ADD CONSTRAINT "cost_basis_lots_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cost_basis_lots" ADD CONSTRAINT "cost_basis_lots_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "transactions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_disposals" ADD CONSTRAINT "lot_disposals_lotId_fkey" FOREIGN KEY ("lotId") REFERENCES "cost_basis_lots"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_disposals" ADD CONSTRAINT "lot_disposals_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lot_disposals" ADD CONSTRAINT "lot_disposals_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "transactions"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260721000000_add_tax_lot_tracking/rollback.sql b/prisma/migrations/20260721000000_add_tax_lot_tracking/rollback.sql new file mode 100644 index 0000000..b2c7f65 --- /dev/null +++ b/prisma/migrations/20260721000000_add_tax_lot_tracking/rollback.sql @@ -0,0 +1,21 @@ +-- Rollback for 20260721000000_add_tax_lot_tracking +-- Drops the cost-basis lot / disposal tables and the PriceSource enum. +-- WARNING: Destroys all cost-basis and realized-gain tracking history. Lots +-- and disposals are deterministically reconstructible from CONFIRMED +-- Transactions via scripts/backfill-cost-basis-lots.ts after re-applying. + +ALTER TABLE "lot_disposals" DROP CONSTRAINT IF EXISTS "lot_disposals_transactionId_fkey"; + +ALTER TABLE "lot_disposals" DROP CONSTRAINT IF EXISTS "lot_disposals_userId_fkey"; + +ALTER TABLE "lot_disposals" DROP CONSTRAINT IF EXISTS "lot_disposals_lotId_fkey"; + +ALTER TABLE "cost_basis_lots" DROP CONSTRAINT IF EXISTS "cost_basis_lots_transactionId_fkey"; + +ALTER TABLE "cost_basis_lots" DROP CONSTRAINT IF EXISTS "cost_basis_lots_userId_fkey"; + +DROP TABLE IF EXISTS "lot_disposals"; + +DROP TABLE IF EXISTS "cost_basis_lots"; + +DROP TYPE IF EXISTS "PriceSource"; diff --git a/prisma/migrations/20260721120000_add_user_rebalance_strategy/migration.sql b/prisma/migrations/20260721120000_add_user_rebalance_strategy/migration.sql new file mode 100644 index 0000000..77b0e63 --- /dev/null +++ b/prisma/migrations/20260721120000_add_user_rebalance_strategy/migration.sql @@ -0,0 +1,9 @@ +-- Repair schema drift: User.rebalanceStrategy / User.strategyConfig exist in +-- schema.prisma but were never added by a migration, so fresh databases built +-- via `prisma migrate deploy` were missing them. Nullable columns — no backfill. + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "rebalanceStrategy" TEXT; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "strategyConfig" JSONB; diff --git a/prisma/migrations/20260721120000_add_user_rebalance_strategy/rollback.sql b/prisma/migrations/20260721120000_add_user_rebalance_strategy/rollback.sql new file mode 100644 index 0000000..9be3fb4 --- /dev/null +++ b/prisma/migrations/20260721120000_add_user_rebalance_strategy/rollback.sql @@ -0,0 +1,7 @@ +-- Rollback for 20260721120000_add_user_rebalance_strategy +-- Drops the per-user rebalance strategy columns. +-- WARNING: Destroys any user-selected strategy preferences. + +ALTER TABLE "users" DROP COLUMN IF EXISTS "strategyConfig"; + +ALTER TABLE "users" DROP COLUMN IF EXISTS "rebalanceStrategy"; diff --git a/prisma/migrations/20260721130000_add_admin_key_token_prefix/migration.sql b/prisma/migrations/20260721130000_add_admin_key_token_prefix/migration.sql new file mode 100644 index 0000000..e122b62 --- /dev/null +++ b/prisma/migrations/20260721130000_add_admin_key_token_prefix/migration.sql @@ -0,0 +1,18 @@ +-- Repair schema drift on the admin auth path. +-- +-- src/middleware/adminAuth.ts looks admin keys up by "tokenPrefix" (a SHA-256 +-- of the raw token) to narrow candidates before the bcrypt compare, and +-- src/routes/admin.ts writes it on key creation — but the column was never +-- added by a migration, so every admin-authenticated request failed with +-- "Unknown argument `tokenPrefix`". (The `db as any` cast in both modules hid +-- this from the type checker.) +-- +-- Nullable so the migration is safe on existing rows. Keys created before this +-- migration have no prefix and therefore cannot authenticate — they must be +-- re-issued via POST /api/v1/admin/keys. + +-- AlterTable +ALTER TABLE "admin_api_keys" ADD COLUMN "tokenPrefix" TEXT; + +-- CreateIndex +CREATE INDEX "admin_api_keys_tokenPrefix_idx" ON "admin_api_keys"("tokenPrefix"); diff --git a/prisma/migrations/20260721130000_add_admin_key_token_prefix/rollback.sql b/prisma/migrations/20260721130000_add_admin_key_token_prefix/rollback.sql new file mode 100644 index 0000000..fd4c110 --- /dev/null +++ b/prisma/migrations/20260721130000_add_admin_key_token_prefix/rollback.sql @@ -0,0 +1,8 @@ +-- Rollback for 20260721130000_add_admin_key_token_prefix +-- Drops the admin key lookup prefix column and its index. +-- WARNING: Reverting restores the broken state in which admin authentication +-- queries a non-existent column and every admin request fails. + +DROP INDEX IF EXISTS "admin_api_keys_tokenPrefix_idx"; + +ALTER TABLE "admin_api_keys" DROP COLUMN IF EXISTS "tokenPrefix"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d2fe112..fa08fe4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -87,6 +87,13 @@ enum ReferralStatus { EXPIRED } +// Where an acquisition/disposal USD price came from (#284). Only stablecoins +// are priced in v1; anything else is stored with a null price and surfaced as +// unpriced in the tax report — never silently zeroed. +enum PriceSource { + STABLECOIN_ASSUMPTION +} + enum GoalStatus { ACTIVE ACHIEVED @@ -116,6 +123,8 @@ model User { fiatOrders FiatOrder[] referralCode ReferralCode? referralConversion ReferralConversion? + costBasisLots CostBasisLot[] + lotDisposals LotDisposal[] savingsGoals SavingsGoal[] @@map("users") @@ -148,6 +157,11 @@ model AdminApiKey { role String scopes String[] hash String + // SHA-256 of the raw token ("sha256:"). A deterministic lookup key that + // narrows candidates before the expensive bcrypt compare against `hash`. + // Nullable only so the migration is safe on pre-existing rows; keys without + // one cannot be authenticated and must be re-issued. + tokenPrefix String? expiresAt DateTime? revokedAt DateTime? createdAt DateTime @default(now()) @@ -159,6 +173,7 @@ model AdminApiKey { @@index([role]) @@index([revokedAt]) @@index([expiresAt]) + @@index([tokenPrefix]) @@map("admin_api_keys") } @@ -234,6 +249,8 @@ model Transaction { user User @relation(fields: [userId], references: [id], onDelete: Cascade) position Position? @relation(fields: [positionId], references: [id]) fiatOrders FiatOrder[] + costBasisLot CostBasisLot? + lotDisposals LotDisposal[] @@index([userId]) @@index([positionId]) @@ -534,6 +551,64 @@ model ReferralConversion { @@map("referral_conversions") } +/// Cost-basis lot for tax reporting (#284). Exactly one lot per confirmed +/// on-chain DEPOSIT Transaction (`transactionId` unique — the idempotency +/// anchor under event replay). `remainingAmount` is decremented by FIFO +/// disposals and must never go negative. Prices are nullable: an unpriced +/// asset stays null and is excluded from report totals with a caveat — never +/// silently zeroed. The schema carries no accounting-method column; FIFO +/// consumption order lives in src/tax/fifo.ts so LIFO could be added later +/// without a schema change. +model CostBasisLot { + id String @id @default(uuid()) + userId String + transactionId String @unique + assetSymbol String + originalAmount Decimal @db.Decimal(36, 18) + remainingAmount Decimal @db.Decimal(36, 18) + acquisitionPrice Decimal? @db.Decimal(36, 18) + priceSource PriceSource? + acquiredAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + transaction Transaction @relation(fields: [transactionId], references: [id], onDelete: Cascade) + disposals LotDisposal[] + + @@index([userId, assetSymbol, acquiredAt]) + @@map("cost_basis_lots") +} + +/// One lot's share of a withdrawal (#284). A withdrawal Transaction may span +/// many lots, so `transactionId` is NOT unique — idempotency comes from +/// `@@unique([transactionId, lotId])` plus an exists-check in the recorder. +/// `userId`/`assetSymbol` are denormalized so the tax report reads without +/// joins. Money fields snapshot values at disposal time, making the report an +/// immutable ledger read; null money fields mean "unpriced", never zero. +model LotDisposal { + id String @id @default(uuid()) + lotId String + userId String + assetSymbol String + transactionId String + amount Decimal @db.Decimal(36, 18) + disposalPrice Decimal? @db.Decimal(36, 18) + costBasis Decimal? @db.Decimal(36, 18) + proceeds Decimal? @db.Decimal(36, 18) + realizedGain Decimal? @db.Decimal(36, 18) + disposedAt DateTime + createdAt DateTime @default(now()) + + lot CostBasisLot @relation(fields: [lotId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + transaction Transaction @relation(fields: [transactionId], references: [id], onDelete: Cascade) + + @@unique([transactionId, lotId]) + @@index([userId, disposedAt]) + @@map("lot_disposals") +} + /// Goal-based investing (#281). A user-stated target amount + date the agent's /// strategy selection is driven by. Required-rate projection reuses the same /// simple (non-compounding) APY convention as YieldSnapshot/calculateApy for diff --git a/scripts/backfill-cost-basis-lots.ts b/scripts/backfill-cost-basis-lots.ts new file mode 100644 index 0000000..cda7e2b --- /dev/null +++ b/scripts/backfill-cost-basis-lots.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env ts-node +/** + * Backfill cost-basis lots & disposals (#284) + * + * Replays every CONFIRMED DEPOSIT/WITHDRAWAL Transaction, per user, in + * confirmedAt order through the same service functions the event listener + * uses. Both functions are idempotent (transactionId unique / exists-check), + * so this script is safe to re-run at any time. + * + * Run once at deploy of the tax-lot-tracking migration: without it, every + * pre-existing user's first withdrawal would fire a false-positive + * "insufficient lots" critical alert. Also the documented repair tool after + * any lot-creation failure alert. See docs/TAX_REPORT.md. + * + * Usage: + * npx ts-node scripts/backfill-cost-basis-lots.ts [--dry-run] + * + * Environment: + * - Database connection required via DATABASE_URL (full env not needed; + * imports db + tax service only, not the server config) + */ + +import db from '../src/db' +import { logger } from '../src/utils/logger' +import { + createLotForDeposit, + recordDisposalsForWithdrawal, +} from '../src/tax/service' + +const DRY_RUN = process.argv.includes('--dry-run') + +async function main(): Promise { + const transactions = await db.transaction.findMany({ + where: { + status: 'CONFIRMED', + type: { in: ['DEPOSIT', 'WITHDRAWAL'] }, + }, + orderBy: [{ confirmedAt: 'asc' }, { createdAt: 'asc' }], + select: { + id: true, + userId: true, + type: true, + assetSymbol: true, + amount: true, + confirmedAt: true, + createdAt: true, + }, + }) + + logger.info('[Tax Backfill] Starting', { + transactions: transactions.length, + dryRun: DRY_RUN, + }) + + if (DRY_RUN) { + const deposits = transactions.filter((t) => t.type === 'DEPOSIT').length + logger.info('[Tax Backfill] Dry run — no writes', { + deposits, + withdrawals: transactions.length - deposits, + }) + return + } + + let processed = 0 + for (const tx of transactions) { + const effectiveAt = tx.confirmedAt ?? tx.createdAt + if (tx.type === 'DEPOSIT') { + await createLotForDeposit( + tx.userId, + tx.id, + tx.assetSymbol, + tx.amount, + effectiveAt + ) + } else { + await recordDisposalsForWithdrawal( + tx.userId, + tx.id, + tx.assetSymbol, + tx.amount, + effectiveAt + ) + } + processed++ + if (processed % 500 === 0) { + logger.info('[Tax Backfill] Progress', { + processed, + total: transactions.length, + }) + } + } + + logger.info('[Tax Backfill] Complete', { processed }) +} + +main() + .catch((err) => { + logger.error('[Tax Backfill] Failed', { + error: err instanceof Error ? err.message : String(err), + }) + process.exitCode = 1 + }) + .finally(() => db.$disconnect()) diff --git a/src/agent/riskScoring.ts b/src/agent/riskScoring.ts index 1046004..0441e3b 100644 --- a/src/agent/riskScoring.ts +++ b/src/agent/riskScoring.ts @@ -14,13 +14,17 @@ * factors precisely so a user can reconcile them against the docs. */ -import { AuditStatusValue, computeProtocolAgeDays, getProtocolMetadata } from '../config/protocolRiskMetadata'; +import { + AuditStatusValue, + computeProtocolAgeDays, + getProtocolMetadata, +} from '../config/protocolRiskMetadata' /** A single historical rate sample for one protocol (asset-agnostic here). */ export interface RateSample { - supplyApy: number; - tvl: number | null; - fetchedAt: Date; + supplyApy: number + tvl: number | null + fetchedAt: Date } export interface RiskScoreFactors { @@ -28,25 +32,25 @@ export interface RiskScoreFactors { * Rolling TVL growth/decline signal in [0,1]. 1 = strongly growing (lower * risk), 0.5 = flat/unknown, 0 = strongly declining (higher risk). */ - tvlTrendFactor: number; + tvlTrendFactor: number /** * APY stability signal in [0,1]. 1 = very stable APY (lower risk), 0 = highly * volatile APY (higher risk). Derived from the standard deviation of APY over * the trailing window. */ - apyVolatilityFactor: number; - auditStatus: AuditStatusValue; - protocolAgeDays: number; + apyVolatilityFactor: number + auditStatus: AuditStatusValue + protocolAgeDays: number /** Number of samples the factors were computed from. */ - sampleCount: number; + sampleCount: number /** True when history is too sparse to compute volatility/trend meaningfully. */ - insufficientHistory: boolean; + insufficientHistory: boolean } export interface RiskScoreResult extends RiskScoreFactors { - protocolName: string; + protocolName: string /** Normalized 0-100, higher = lower risk. */ - score: number; + score: number } // ── Tunables (mirror docs/PROTOCOL_RISK_SCORING.md) ────────────────────────── @@ -56,22 +60,22 @@ export interface RiskScoreResult extends RiskScoreFactors { * cannot meaningfully characterize the distribution, so the protocol is flagged * `insufficientHistory` and scored conservatively low rather than neutral. */ -export const MIN_SAMPLES_FOR_HISTORY = 3; +export const MIN_SAMPLES_FOR_HISTORY = 3 /** Trailing window over which volatility/trend are measured. */ -export const TRAILING_WINDOW_DAYS = 30; +export const TRAILING_WINDOW_DAYS = 30 /** * APY standard deviation (in percentage points) mapped to the volatility floor. * At/above this stdev the apyVolatilityFactor is 0; at 0 stdev it is 1. */ -export const APY_STDEV_FLOOR = 5; +export const APY_STDEV_FLOOR = 5 /** Protocol age (days) at which the age contribution saturates to full credit. */ -export const AGE_SATURATION_DAYS = 730; // ~2 years +export const AGE_SATURATION_DAYS = 730 // ~2 years /** Score (0-100) assigned to protocols flagged insufficientHistory. */ -export const INSUFFICIENT_HISTORY_SCORE = 20; +export const INSUFFICIENT_HISTORY_SCORE = 20 /** Component weights. Must sum to 1. */ export const WEIGHTS = { @@ -79,31 +83,31 @@ export const WEIGHTS = { volatility: 0.25, tvlTrend: 0.2, age: 0.2, -} as const; +} as const const AUDIT_FACTOR: Record = { THIRD_PARTY_AUDITED: 1, SELF_REPORTED: 0.5, UNAUDITED: 0, -}; +} function clamp01(n: number): number { - if (Number.isNaN(n)) return 0; - if (n < 0) return 0; - if (n > 1) return 1; - return n; + if (Number.isNaN(n)) return 0 + if (n < 0) return 0 + if (n > 1) return 1 + return n } function mean(values: number[]): number { - if (values.length === 0) return 0; - return values.reduce((s, v) => s + v, 0) / values.length; + if (values.length === 0) return 0 + return values.reduce((s, v) => s + v, 0) / values.length } function stdev(values: number[]): number { - if (values.length < 2) return 0; - const m = mean(values); - const variance = mean(values.map((v) => (v - m) ** 2)); - return Math.sqrt(variance); + if (values.length < 2) return 0 + const m = mean(values) + const variance = mean(values.map((v) => (v - m) ** 2)) + return Math.sqrt(variance) } /** @@ -116,11 +120,15 @@ function stdev(values: number[]): number { * effective sampleCount; if that drops the count below MIN_SAMPLES_FOR_HISTORY * the protocol is flagged `insufficientHistory` rather than scored on thin data. */ -export function filterToWindow(samples: RateSample[], now: Date, windowDays = TRAILING_WINDOW_DAYS): RateSample[] { - const cutoff = now.getTime() - windowDays * 24 * 60 * 60 * 1000; +export function filterToWindow( + samples: RateSample[], + now: Date, + windowDays = TRAILING_WINDOW_DAYS +): RateSample[] { + const cutoff = now.getTime() - windowDays * 24 * 60 * 60 * 1000 return samples .filter((s) => s.fetchedAt.getTime() >= cutoff) - .sort((a, b) => a.fetchedAt.getTime() - b.fetchedAt.getTime()); + .sort((a, b) => a.fetchedAt.getTime() - b.fetchedAt.getTime()) } /** @@ -128,10 +136,10 @@ export function filterToWindow(samples: RateSample[], now: Date, windowDays = TR * Higher stdev → lower factor. Linear falloff to 0 at APY_STDEV_FLOOR. */ export function computeApyVolatilityFactor(samples: RateSample[]): number { - const apys = samples.map((s) => s.supplyApy).filter((v) => Number.isFinite(v)); - if (apys.length < 2) return 0; - const sd = stdev(apys); - return clamp01(1 - sd / APY_STDEV_FLOOR); + const apys = samples.map((s) => s.supplyApy).filter((v) => Number.isFinite(v)) + if (apys.length < 2) return 0 + const sd = stdev(apys) + return clamp01(1 - sd / APY_STDEV_FLOOR) } /** @@ -143,18 +151,18 @@ export function computeApyVolatilityFactor(samples: RateSample[]): number { export function computeTvlTrendFactor(samples: RateSample[]): number { const tvls = samples .map((s) => s.tvl) - .filter((v): v is number => v !== null && Number.isFinite(v) && v > 0); - if (tvls.length < 2) return 0.5; - const first = tvls[0]; - const last = tvls[tvls.length - 1]; - const relChange = (last - first) / first; // e.g. +0.2 = +20% growth + .filter((v): v is number => v !== null && Number.isFinite(v) && v > 0) + if (tvls.length < 2) return 0.5 + const first = tvls[0] + const last = tvls[tvls.length - 1] + const relChange = (last - first) / first // e.g. +0.2 = +20% growth // Map [-0.5, +0.5] relative change onto [0,1], centered at 0.5 (flat). - return clamp01(0.5 + relChange); + return clamp01(0.5 + relChange) } /** Age credit in [0,1], linear from 0 days to AGE_SATURATION_DAYS. */ export function computeAgeFactor(protocolAgeDays: number): number { - return clamp01(protocolAgeDays / AGE_SATURATION_DAYS); + return clamp01(protocolAgeDays / AGE_SATURATION_DAYS) } /** @@ -164,16 +172,20 @@ export function computeAgeFactor(protocolAgeDays: number): number { * @param samples All available rate history for the protocol (any age). * @param now The reference "now" (injected for deterministic tests). */ -export function computeRiskScore(protocolName: string, samples: RateSample[], now: Date): RiskScoreResult { - const meta = getProtocolMetadata(protocolName); - const protocolAgeDays = computeProtocolAgeDays(meta.inceptionDate, now); +export function computeRiskScore( + protocolName: string, + samples: RateSample[], + now: Date +): RiskScoreResult { + const meta = getProtocolMetadata(protocolName) + const protocolAgeDays = computeProtocolAgeDays(meta.inceptionDate, now) - const windowed = filterToWindow(samples, now); - const sampleCount = windowed.length; - const insufficientHistory = sampleCount < MIN_SAMPLES_FOR_HISTORY; + const windowed = filterToWindow(samples, now) + const sampleCount = windowed.length + const insufficientHistory = sampleCount < MIN_SAMPLES_FOR_HISTORY - const apyVolatilityFactor = computeApyVolatilityFactor(windowed); - const tvlTrendFactor = computeTvlTrendFactor(windowed); + const apyVolatilityFactor = computeApyVolatilityFactor(windowed) + const tvlTrendFactor = computeTvlTrendFactor(windowed) const factors: RiskScoreFactors = { tvlTrendFactor, @@ -182,25 +194,25 @@ export function computeRiskScore(protocolName: string, samples: RateSample[], no protocolAgeDays, sampleCount, insufficientHistory, - }; + } // A protocol without enough history cannot be characterized on // volatility/trend. Rather than let audit + age alone produce a // misleadingly high score, cap it at a conservative floor. if (insufficientHistory) { - return { protocolName, score: INSUFFICIENT_HISTORY_SCORE, ...factors }; + return { protocolName, score: INSUFFICIENT_HISTORY_SCORE, ...factors } } - const auditFactor = AUDIT_FACTOR[meta.auditStatus]; - const ageFactor = computeAgeFactor(protocolAgeDays); + const auditFactor = AUDIT_FACTOR[meta.auditStatus] + const ageFactor = computeAgeFactor(protocolAgeDays) const weighted = WEIGHTS.audit * auditFactor + WEIGHTS.volatility * apyVolatilityFactor + WEIGHTS.tvlTrend * tvlTrendFactor + - WEIGHTS.age * ageFactor; + WEIGHTS.age * ageFactor - const score = Math.round(clamp01(weighted) * 100); + const score = Math.round(clamp01(weighted) * 100) - return { protocolName, score, ...factors }; + return { protocolName, score, ...factors } } diff --git a/src/agent/router.ts b/src/agent/router.ts index 091513f..84308d6 100644 --- a/src/agent/router.ts +++ b/src/agent/router.ts @@ -2,18 +2,28 @@ * Router - Compares APYs and triggers rebalancing when conditions are met */ -import { logger } from '../utils/logger'; -import { getCorrelationId } from '../utils/correlation'; -import { ProtocolComparison, RebalanceDetails, RebalanceThresholds, RebalanceStrategy, UserStrategyPreferences } from './types'; -import { scanAllProtocols, getCurrentOnChainApy } from './scanner'; -import { triggerRebalance as submitRebalance } from '../stellar/contract'; -import { MaxYieldStrategy, TargetAllocationStrategy, GoalTrackingStrategy } from './strategies'; -import db from '../db'; +import { logger } from '../utils/logger' +import { getCorrelationId } from '../utils/correlation' +import { + ProtocolComparison, + RebalanceDetails, + RebalanceThresholds, + RebalanceStrategy, + UserStrategyPreferences, +} from './types' +import { scanAllProtocols, getCurrentOnChainApy } from './scanner' +import { triggerRebalance as submitRebalance } from '../stellar/contract' +import { + MaxYieldStrategy, + TargetAllocationStrategy, + GoalTrackingStrategy, +} from './strategies' +import db from '../db' const DEFAULT_THRESHOLDS: RebalanceThresholds = { minimumImprovement: 0.5, // Must improve by at least 0.5% maxGasPercent: 0.1, -}; +} /** * Load current protocol risk scores keyed by protocol name. @@ -26,12 +36,12 @@ const DEFAULT_THRESHOLDS: RebalanceThresholds = { async function loadProtocolRiskScores(): Promise> { const rows = await db.protocolRiskScore.findMany({ select: { protocolName: true, score: true }, - }); - const map: Record = {}; + }) + const map: Record = {} for (const row of rows as Array<{ protocolName: string; score: number }>) { - map[row.protocolName] = row.score; + map[row.protocolName] = row.score } - return map; + return map } /** @@ -39,25 +49,30 @@ async function loadProtocolRiskScores(): Promise> { * userStrategyPreferences are present, so users who never create a goal issue * no extra query beyond this single lookup. */ -async function loadActiveGoal( - userId: string, -): Promise<{ targetAmount: number; startingAmount: number; targetDate: Date; riskCeiling: number | null } | null> { - const goal = await db.savingsGoal.findFirst({ where: { userId, status: 'ACTIVE' } }); - if (!goal) return null; +async function loadActiveGoal(userId: string): Promise<{ + targetAmount: number + startingAmount: number + targetDate: Date + riskCeiling: number | null +} | null> { + const goal = await db.savingsGoal.findFirst({ + where: { userId, status: 'ACTIVE' }, + }) + if (!goal) return null return { targetAmount: Number(goal.targetAmount), startingAmount: Number(goal.startingAmount), targetDate: goal.targetDate, riskCeiling: goal.riskCeiling, - }; + } } function toApyBasisPoints(apyPercent: number): number { if (!Number.isFinite(apyPercent) || apyPercent < 0) { - throw new Error('APY must be a non-negative number'); + throw new Error('APY must be a non-negative number') } - return Math.round(apyPercent * 100); + return Math.round(apyPercent * 100) } /** @@ -67,21 +82,25 @@ function toApyBasisPoints(apyPercent: number): number { function estimateRebalanceCosts( amount: string, maxGasPercent: number -): { gasFeePercent: number; slippagePercent: number; totalCostPercent: number } { +): { + gasFeePercent: number + slippagePercent: number + totalCostPercent: number +} { // Estimate gas fee based on amount // Typical Stellar Soroban gas: ~270-300 stroops base, plus per-instruction fees - const gasEstimateUSD = 0.50; // Estimate $0.50 base gas - const amountUSD = parseInt(amount) / 1e18; // Assuming amount is in wei - const gasFeePercent = amountUSD > 0 ? (gasEstimateUSD / amountUSD) * 100 : 0; + const gasEstimateUSD = 0.5 // Estimate $0.50 base gas + const amountUSD = parseInt(amount) / 1e18 // Assuming amount is in wei + const gasFeePercent = amountUSD > 0 ? (gasEstimateUSD / amountUSD) * 100 : 0 // Estimate DEX slippage (typically 0.1-0.5% on significant trades) - const slippagePercent = Math.min(maxGasPercent * 0.5, 0.25); + const slippagePercent = Math.min(maxGasPercent * 0.5, 0.25) return { gasFeePercent: Math.min(gasFeePercent, maxGasPercent), slippagePercent, totalCostPercent: Math.min(gasFeePercent + slippagePercent, maxGasPercent), - }; + } } /** @@ -95,31 +114,31 @@ export async function compareProtocols( ): Promise { try { // Get current on-chain APY - const currentApy = await getCurrentOnChainApy(currentProtocol); + const currentApy = await getCurrentOnChainApy(currentProtocol) if (!currentApy) { - logger.warn(`Cannot get current APY for ${currentProtocol}`); - return null; + logger.warn(`Cannot get current APY for ${currentProtocol}`) + return null } // Get best available protocol from latest scan - const allProtocols = await scanAllProtocols(); + const allProtocols = await scanAllProtocols() if (allProtocols.length === 0) { - logger.warn('No protocols available for comparison'); - return null; + logger.warn('No protocols available for comparison') + return null } - const bestProtocol = allProtocols[0]; - const rawImprovement = bestProtocol.apy - currentApy; + const bestProtocol = allProtocols[0] + const rawImprovement = bestProtocol.apy - currentApy // CRITICAL: Account for rebalance costs (gas + slippage) - const costs = estimateRebalanceCosts(amount, thresholds.maxGasPercent); - const netImprovement = rawImprovement - costs.totalCostPercent; + const costs = estimateRebalanceCosts(amount, thresholds.maxGasPercent) + const netImprovement = rawImprovement - costs.totalCostPercent // Only rebalance if NET improvement (after costs) exceeds threshold const shouldRebalance = netImprovement > thresholds.minimumImprovement && bestProtocol.name !== currentProtocol && - costs.totalCostPercent < thresholds.maxGasPercent; + costs.totalCostPercent < thresholds.maxGasPercent const comparison: ProtocolComparison = { current: { @@ -132,7 +151,7 @@ export async function compareProtocols( best: bestProtocol, improvement: netImprovement, shouldRebalance, - }; + } logger.info('Protocol comparison complete', { currentProtocol, @@ -145,15 +164,15 @@ export async function compareProtocols( totalCostPercent: costs.totalCostPercent.toFixed(4), netImprovement: netImprovement.toFixed(2), shouldRebalance, - }); + }) - return comparison; + return comparison } catch (error) { logger.error('Protocol comparison failed', { currentProtocol, error: error instanceof Error ? error.message : 'Unknown error', - }); - return null; + }) + return null } } @@ -166,29 +185,29 @@ export async function triggerRebalance( toProtocol: string, amount: string, positionIds: string[] = [], - strategyInfo?: { name: string; reasoning: string; deviationTrigger?: string }, + strategyInfo?: { name: string; reasoning: string; deviationTrigger?: string } ): Promise { - const startTime = Date.now(); + const startTime = Date.now() try { - const comparison = await compareProtocols(fromProtocol, amount); + const comparison = await compareProtocols(fromProtocol, amount) if (!comparison) { - throw new Error(`Unable to compare protocols for ${fromProtocol}`); + throw new Error(`Unable to compare protocols for ${fromProtocol}`) } - const expectedApyBasisPoints = toApyBasisPoints(comparison.best.apy); + const expectedApyBasisPoints = toApyBasisPoints(comparison.best.apy) logger.info('Rebalance triggered', { fromProtocol, toProtocol, amount, expectedApyBasisPoints, - }); + }) const onChainTransaction = await submitRebalance( toProtocol, - expectedApyBasisPoints, - ); + expectedApyBasisPoints + ) if (positionIds.length > 0) { const representativePosition = await db.position.findFirst({ @@ -202,7 +221,7 @@ export async function triggerRebalance( }, }, }, - }); + }) if (representativePosition) { await db.transaction.create({ @@ -218,13 +237,13 @@ export async function triggerRebalance( protocolName: toProtocol, memo: `Agent rebalance from ${fromProtocol} to ${toProtocol}`, } as any, - }); + }) } else { logger.warn('No position found to persist rebalance transaction', { fromProtocol, toProtocol, positionIds, - }); + }) } } @@ -235,29 +254,35 @@ export async function triggerRebalance( txHash: onChainTransaction.hash, timestamp: new Date(), improvedBy: comparison.improvement, - }; + } - const duration = Date.now() - startTime; + const duration = Date.now() - startTime // Log to database – attribute to the actual user(s) for each affected position if (positionIds.length > 0) { const affectedPositions = await db.position.findMany({ where: { id: { in: positionIds } }, select: { id: true, userId: true }, - }); + }) // Deduplicate: one log per (userId, positionId) pair - const seen = new Set(); + const seen = new Set() for (const pos of affectedPositions) { - const key = `${pos.userId}:${pos.id}`; - if (seen.has(key)) continue; - seen.add(key); - await logAgentAction('REBALANCE', 'SUCCESS', { - rebalanceDetail, - strategyName: strategyInfo?.name, - reasoning: strategyInfo?.reasoning, - deviationTrigger: strategyInfo?.deviationTrigger, - }, pos.userId, pos.id); + const key = `${pos.userId}:${pos.id}` + if (seen.has(key)) continue + seen.add(key) + await logAgentAction( + 'REBALANCE', + 'SUCCESS', + { + rebalanceDetail, + strategyName: strategyInfo?.name, + reasoning: strategyInfo?.reasoning, + deviationTrigger: strategyInfo?.deviationTrigger, + }, + pos.userId, + pos.id + ) } } else { // No positions linked – log as system-level (userId stays null) @@ -266,19 +291,20 @@ export async function triggerRebalance( strategyName: strategyInfo?.name, reasoning: strategyInfo?.reasoning, deviationTrigger: strategyInfo?.deviationTrigger, - }); + }) } logger.info('Rebalance successful', { txHash: onChainTransaction.hash, duration, improvedBy: comparison.improvement.toFixed(2), - }); + }) - return rebalanceDetail; + return rebalanceDetail } catch (error) { - const duration = Date.now() - startTime; - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const duration = Date.now() - startTime + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' logger.error('Rebalance failed', { fromProtocol, @@ -286,15 +312,15 @@ export async function triggerRebalance( amount, error: errorMessage, duration, - }); + }) await logAgentAction('REBALANCE', 'FAILED', { fromProtocol, toProtocol, error: errorMessage, - }); + }) - return null; + return null } } @@ -306,30 +332,27 @@ export async function executeRebalanceIfNeeded( currentProtocol: string, userPositions: Array<{ id: string; amount: string; userId?: string }>, thresholds?: RebalanceThresholds, - userStrategyPreferences?: UserStrategyPreferences[], + userStrategyPreferences?: UserStrategyPreferences[] ): Promise { try { const totalAmount = userPositions - .reduce( - (sum, pos) => sum + BigInt(pos.amount), - BigInt(0) - ) - .toString(); + .reduce((sum, pos) => sum + BigInt(pos.amount), BigInt(0)) + .toString() - const effectiveThresholds = thresholds ?? getThresholds(); + const effectiveThresholds = thresholds ?? getThresholds() // Use strategy engine when user preferences are present if (userStrategyPreferences && userStrategyPreferences.length > 0) { - const currentApy = await getCurrentOnChainApy(currentProtocol); + const currentApy = await getCurrentOnChainApy(currentProtocol) if (!currentApy) { - logger.warn(`Cannot get current APY for ${currentProtocol}`); - return null; + logger.warn(`Cannot get current APY for ${currentProtocol}`) + return null } - const allProtocols = await scanAllProtocols(); + const allProtocols = await scanAllProtocols() if (allProtocols.length === 0) { - logger.warn('No protocols available for comparison'); - return null; + logger.warn('No protocols available for comparison') + return null } // An ACTIVE savings goal (#281) takes priority over the stored strategy @@ -337,23 +360,26 @@ export async function executeRebalanceIfNeeded( // the agent chase whatever rate that goal actually needs, not a static // preference that predates the goal. Users with no goal fall through to // the existing preference logic completely unchanged. - const goalUserId = userStrategyPreferences[0]?.userId; - const activeGoal = goalUserId ? await loadActiveGoal(goalUserId) : null; + const goalUserId = userStrategyPreferences[0]?.userId + const activeGoal = goalUserId ? await loadActiveGoal(goalUserId) : null - const preferredStrategy = userStrategyPreferences[0]?.strategyName; + const preferredStrategy = userStrategyPreferences[0]?.strategyName const strategy: RebalanceStrategy = activeGoal ? new GoalTrackingStrategy() : preferredStrategy === 'TARGET_ALLOCATION' ? new TargetAllocationStrategy() - : new MaxYieldStrategy(); + : new MaxYieldStrategy() // Risk ceiling is opt-in per user (or per goal). Only when a ceiling is // set do we load the current ProtocolRiskScore rows and pass them to the // strategy — the no-ceiling path issues no extra query and behaves // exactly as before. - const riskCeiling = activeGoal?.riskCeiling ?? userStrategyPreferences[0]?.riskCeiling ?? undefined; + const riskCeiling = + activeGoal?.riskCeiling ?? + userStrategyPreferences[0]?.riskCeiling ?? + undefined const protocolRiskScores = - riskCeiling !== undefined ? await loadProtocolRiskScores() : undefined; + riskCeiling !== undefined ? await loadProtocolRiskScores() : undefined const decision = await strategy.analyze({ currentProtocol, @@ -371,58 +397,62 @@ export async function executeRebalanceIfNeeded( targetDate: activeGoal.targetDate, } : undefined, - }); + }) if (!decision.shouldRebalance) { logger.info('No rebalance needed (strategy)', { strategy: strategy.name, reasoning: decision.reasoning, - }); - return null; + }) + return null } return await triggerRebalance( currentProtocol, decision.targetProtocol, totalAmount, - userPositions.map(pos => pos.id), + userPositions.map((pos) => pos.id), { name: strategy.name, reasoning: decision.reasoning, deviationTrigger: decision.deviationTrigger, - }, - ); + } + ) } // Default: existing compareProtocols flow (backward compatible) - const comparison = await compareProtocols(currentProtocol, totalAmount, effectiveThresholds); + const comparison = await compareProtocols( + currentProtocol, + totalAmount, + effectiveThresholds + ) if (!comparison || !comparison.shouldRebalance) { logger.info('No rebalance needed', { reason: comparison ? `Net improvement ${comparison.improvement.toFixed(2)}% (after fees) below threshold` : 'Unable to compare protocols', - }); - return null; + }) + return null } return await triggerRebalance( currentProtocol, comparison.best.name, totalAmount, - userPositions.map(pos => pos.id), + userPositions.map((pos) => pos.id), { name: 'MAX_YIELD', reasoning: `Moving from ${currentProtocol} to ${comparison.best.name} — net gain ${comparison.improvement.toFixed(2)}% after costs`, - deviationTrigger: `APY delta: ${(comparison.best.apy - (comparison.current.apy)).toFixed(2)}%`, - }, - ); + deviationTrigger: `APY delta: ${(comparison.best.apy - comparison.current.apy).toFixed(2)}%`, + } + ) } catch (error) { logger.error('Rebalance execution check failed', { currentProtocol, error: error instanceof Error ? error.message : 'Unknown error', - }); - return null; + }) + return null } } @@ -441,16 +471,18 @@ export async function logAgentAction( status: 'SUCCESS' | 'FAILED' | 'SKIPPED', data?: Record, userId?: string, - positionId?: string, + positionId?: string ): Promise { - const correlationId = getCorrelationId(); + const correlationId = getCorrelationId() const inputWithCorrelation = data?.input || correlationId ? { - ...(typeof data?.input === 'object' && data.input !== null ? data.input : {}), + ...(typeof data?.input === 'object' && data.input !== null + ? data.input + : {}), ...(correlationId ? { correlationId } : {}), } - : undefined; + : undefined try { await db.agentLog.create({ @@ -459,19 +491,23 @@ export async function logAgentAction( positionId: positionId ?? null, action: action as any, status: status as any, - inputData: inputWithCorrelation ? JSON.stringify(inputWithCorrelation) : data?.input ? JSON.stringify(data.input) : undefined, + inputData: inputWithCorrelation + ? JSON.stringify(inputWithCorrelation) + : data?.input + ? JSON.stringify(data.input) + : undefined, outputData: data?.output ? JSON.stringify(data.output) : undefined, reasoning: data?.reasoning as string | undefined, errorMessage: data?.error as string | undefined, }, - }); + }) } catch (error) { logger.error('Failed to log agent action', { action, userId, positionId, error: error instanceof Error ? error.message : 'Unknown error', - }); + }) } } @@ -483,8 +519,6 @@ export function getThresholds(): RebalanceThresholds { minimumImprovement: parseFloat( process.env.REBALANCE_THRESHOLD_PERCENT || '0.5' ), - maxGasPercent: parseFloat( - process.env.MAX_GAS_PERCENT || '0.1' - ), - }; + maxGasPercent: parseFloat(process.env.MAX_GAS_PERCENT || '0.1'), + } } diff --git a/src/agent/scanner.ts b/src/agent/scanner.ts index cb5dcdc..7ee1ea2 100644 --- a/src/agent/scanner.ts +++ b/src/agent/scanner.ts @@ -2,65 +2,70 @@ * Scanner - Fetches real APY rates from Stellar yield protocols */ -import { logger } from '../utils/logger'; -import { YieldProtocol, ProtocolRate } from './types'; -import db from '../db'; -import { fetchWithRetry } from '../utils/fetchWithRetry'; +import { logger } from '../utils/logger' +import { YieldProtocol, ProtocolRate } from './types' +import db from '../db' +import { fetchWithRetry } from '../utils/fetchWithRetry' -const ASSET_SYMBOL = 'USDC'; -const MINIMUM_TVL = 10000; +const ASSET_SYMBOL = 'USDC' +const MINIMUM_TVL = 10000 // Metrics tracking -const metrics: Record = {}; +const metrics: Record< + string, + { duration: number; failures: number; lastFetched: number } +> = {} function recordMetric(name: string, duration: number, failed: boolean) { - const prev = metrics[name] || { duration: 0, failures: 0, lastFetched: 0 }; + const prev = metrics[name] || { duration: 0, failures: 0, lastFetched: 0 } metrics[name] = { duration, failures: failed ? prev.failures + 1 : 0, lastFetched: Date.now(), - }; + } } function isStale(name: string, maxAgeMs = 300000): boolean { - const m = metrics[name]; - if (!m) return true; - return Date.now() - m.lastFetched > maxAgeMs; + const m = metrics[name] + if (!m) return true + return Date.now() - m.lastFetched > maxAgeMs } /** * Fetch APY from Blend protocol (real) */ async function fetchBlendApy(): Promise { - const start = Date.now(); + const start = Date.now() try { - const network = process.env.STELLAR_NETWORK?.toLowerCase() === 'mainnet' - ? 'https://api.blend.capital' - : 'https://testnet-api.blend.capital'; + const network = + process.env.STELLAR_NETWORK?.toLowerCase() === 'mainnet' + ? 'https://api.blend.capital' + : 'https://testnet-api.blend.capital' - const poolId = process.env.BLEND_POOL_ID || 'GBUQWP3BOUZX34PISXEAMBNIZJLNCLVNX77MHAHVXHVVB4CMYAOK6BAC'; + const poolId = + process.env.BLEND_POOL_ID || + 'GBUQWP3BOUZX34PISXEAMBNIZJLNCLVNX77MHAHVXHVVB4CMYAOK6BAC' - const data = await fetchWithRetry( - `${network}/api/v1/pool/${poolId}`, - { timeout: 5000, retries: 3 } - ); + const data = await fetchWithRetry(`${network}/api/v1/pool/${poolId}`, { + timeout: 5000, + retries: 3, + }) // Extract USDC reserve APY and TVL from response - const reserve = data?.reserves?.find((r: any) => - r.asset?.code === 'USDC' || r.asset?.symbol === 'USDC' - ); + const reserve = data?.reserves?.find( + (r: any) => r.asset?.code === 'USDC' || r.asset?.symbol === 'USDC' + ) const apyRate = reserve?.supplyApy ? parseFloat(reserve.supplyApy) * 100 - : null; + : null - const tvl = reserve?.totalSupply - ? parseFloat(reserve.totalSupply) - : null; + const tvl = reserve?.totalSupply ? parseFloat(reserve.totalSupply) : null - if (apyRate === null) throw new Error('Could not parse Blend APY from response'); + if (apyRate === null) + throw new Error('Could not parse Blend APY from response') - recordMetric('Blend', Date.now() - start, false); + recordMetric('Blend', Date.now() - start, false) return { name: 'Blend', @@ -69,12 +74,13 @@ async function fetchBlendApy(): Promise { assetSymbol: ASSET_SYMBOL, lastUpdated: new Date(), isAvailable: true, - }; + } } catch (error) { - recordMetric('Blend', Date.now() - start, true); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - logger.error('Blend APY fetch failed', { error: errorMessage }); - return null; + recordMetric('Blend', Date.now() - start, true) + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + logger.error('Blend APY fetch failed', { error: errorMessage }) + return null } } @@ -82,33 +88,35 @@ async function fetchBlendApy(): Promise { * Fetch APY from Stellar DEX pools (real via Horizon) */ async function fetchStellarDexApy(): Promise { - const start = Date.now(); + const start = Date.now() try { - const horizonUrl = process.env.HORIZON_URL || 'https://horizon.stellar.org'; - const usdcIssuer = process.env.USDC_ISSUER || 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + const horizonUrl = process.env.HORIZON_URL || 'https://horizon.stellar.org' + const usdcIssuer = + process.env.USDC_ISSUER || + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' const data = await fetchWithRetry( `${horizonUrl}/liquidity_pools?reserves=${ASSET_SYMBOL}:${usdcIssuer}&limit=10&order=desc`, { timeout: 5000, retries: 3 } - ); + ) - const pools = data?._embedded?.records || []; - if (pools.length === 0) throw new Error('No Stellar DEX pools found'); + const pools = data?._embedded?.records || [] + if (pools.length === 0) throw new Error('No Stellar DEX pools found') // Aggregate: weighted average fee APY by TVL - let totalTvl = 0; - let weightedApy = 0; + let totalTvl = 0 + let weightedApy = 0 for (const pool of pools) { - const tvlValue = parseFloat(pool.total_shares || '0'); - const feeApy = parseFloat(pool.fee_bp || '30') / 10000 * 365; - totalTvl += tvlValue; - weightedApy += feeApy * tvlValue; + const tvlValue = parseFloat(pool.total_shares || '0') + const feeApy = (parseFloat(pool.fee_bp || '30') / 10000) * 365 + totalTvl += tvlValue + weightedApy += feeApy * tvlValue } - const apyRate = totalTvl > 0 ? weightedApy / totalTvl : 0; + const apyRate = totalTvl > 0 ? weightedApy / totalTvl : 0 - recordMetric('Stellar DEX', Date.now() - start, false); + recordMetric('Stellar DEX', Date.now() - start, false) return { name: 'Stellar DEX', @@ -117,12 +125,13 @@ async function fetchStellarDexApy(): Promise { assetSymbol: ASSET_SYMBOL, lastUpdated: new Date(), isAvailable: true, - }; + } } catch (error) { - recordMetric('Stellar DEX', Date.now() - start, true); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - logger.error('Stellar DEX APY fetch failed', { error: errorMessage }); - return null; + recordMetric('Stellar DEX', Date.now() - start, true) + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + logger.error('Stellar DEX APY fetch failed', { error: errorMessage }) + return null } } @@ -130,25 +139,25 @@ async function fetchStellarDexApy(): Promise { * Fetch APY from Luma (real) */ async function fetchLumaApy(): Promise { - const start = Date.now(); + const start = Date.now() try { - const lumaUrl = process.env.LUMA_API_URL || 'https://api.luma.finance'; + const lumaUrl = process.env.LUMA_API_URL || 'https://api.luma.finance' const data = await fetchWithRetry( `${lumaUrl}/v1/rates?asset=${ASSET_SYMBOL}`, { timeout: 5000, retries: 3 } - ); + ) - const rate = data?.rates?.find((r: any) => - r.asset === ASSET_SYMBOL || r.symbol === ASSET_SYMBOL - ); + const rate = data?.rates?.find( + (r: any) => r.asset === ASSET_SYMBOL || r.symbol === ASSET_SYMBOL + ) - if (!rate) throw new Error('USDC rate not found in Luma response'); + if (!rate) throw new Error('USDC rate not found in Luma response') - const apyRate = parseFloat(rate.apy) * 100; - const tvl = rate.tvl ? parseFloat(rate.tvl) : undefined; + const apyRate = parseFloat(rate.apy) * 100 + const tvl = rate.tvl ? parseFloat(rate.tvl) : undefined - recordMetric('Luma', Date.now() - start, false); + recordMetric('Luma', Date.now() - start, false) return { name: 'Luma', @@ -157,12 +166,13 @@ async function fetchLumaApy(): Promise { assetSymbol: ASSET_SYMBOL, lastUpdated: new Date(), isAvailable: true, - }; + } } catch (error) { - recordMetric('Luma', Date.now() - start, true); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - logger.error('Luma APY fetch failed', { error: errorMessage }); - return null; + recordMetric('Luma', Date.now() - start, true) + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + logger.error('Luma APY fetch failed', { error: errorMessage }) + return null } } @@ -170,27 +180,26 @@ async function fetchLumaApy(): Promise { * Scan all protocol APY rates */ export async function scanAllProtocols(): Promise { - const fetchPromises = [ - fetchBlendApy(), - fetchStellarDexApy(), - fetchLumaApy(), - ]; + const fetchPromises = [fetchBlendApy(), fetchStellarDexApy(), fetchLumaApy()] - const results = await Promise.allSettled(fetchPromises); - const protocols: YieldProtocol[] = []; + const results = await Promise.allSettled(fetchPromises) + const protocols: YieldProtocol[] = [] for (const result of results) { if (result.status === 'fulfilled' && result.value) { - protocols.push(result.value); + protocols.push(result.value) } else if (result.status === 'rejected') { logger.warn('Protocol fetch promise rejected', { - error: result.reason instanceof Error ? result.reason.message : 'Unknown error', - }); + error: + result.reason instanceof Error + ? result.reason.message + : 'Unknown error', + }) } } - protocols.sort((a, b) => b.apy - a.apy); - const filtered = protocols.filter(p => !p.tvl || p.tvl >= MINIMUM_TVL); + protocols.sort((a, b) => b.apy - a.apy) + const filtered = protocols.filter((p) => !p.tvl || p.tvl >= MINIMUM_TVL) // Log metrics logger.info('Protocol scan complete', { @@ -203,21 +212,21 @@ export async function scanAllProtocols(): Promise { failures: m.failures, stale: isStale(name), })), - }); + }) - await saveProtocolRates(filtered); - return filtered; + await saveProtocolRates(filtered) + return filtered } function normalizeNetwork(): string { - const network = process.env.STELLAR_NETWORK?.toLowerCase(); - const validNetworks = ['mainnet', 'testnet', 'futurenet']; + const network = process.env.STELLAR_NETWORK?.toLowerCase() + const validNetworks = ['mainnet', 'testnet', 'futurenet'] if (!network || !validNetworks.includes(network)) { throw new Error( `Invalid STELLAR_NETWORK: "${process.env.STELLAR_NETWORK}". Must be one of: ${validNetworks.join(', ')}` - ); + ) } - return network.toUpperCase(); + return network.toUpperCase() } /** @@ -225,7 +234,7 @@ function normalizeNetwork(): string { */ async function saveProtocolRates(protocols: YieldProtocol[]): Promise { try { - const networkLabel = normalizeNetwork(); + const networkLabel = normalizeNetwork() for (const protocol of protocols) { await db.protocolRate.create({ data: { @@ -234,38 +243,43 @@ async function saveProtocolRates(protocols: YieldProtocol[]): Promise { supplyApy: protocol.apy as any, tvl: protocol.tvl === undefined ? undefined : (protocol.tvl as any), network: networkLabel as any, - rawResponse: JSON.stringify({ fetchedAt: new Date(), source: protocol.name }), + rawResponse: JSON.stringify({ + fetchedAt: new Date(), + source: protocol.name, + }), }, - }); + }) } } catch (error) { logger.error('Failed to save protocol rates', { error: error instanceof Error ? error.message : 'Unknown error', - }); + }) } } -export async function getCurrentOnChainApy(protocolName: string): Promise { +export async function getCurrentOnChainApy( + protocolName: string +): Promise { try { const latestRate = await db.protocolRate.findFirst({ where: { protocolName, assetSymbol: ASSET_SYMBOL }, orderBy: { fetchedAt: 'desc' }, - }); + }) if (!latestRate) { - logger.warn(`No on-chain APY found for ${protocolName}`); - return null; + logger.warn(`No on-chain APY found for ${protocolName}`) + return null } - return latestRate.supplyApy.toNumber(); + return latestRate.supplyApy.toNumber() } catch (error) { logger.error('Failed to get current on-chain APY', { protocolName, error: error instanceof Error ? error.message : 'Unknown error', - }); - return null; + }) + return null } } export async function getBestProtocol(): Promise { - const protocols = await scanAllProtocols(); - return protocols.length > 0 ? protocols[0] : null; + const protocols = await scanAllProtocols() + return protocols.length > 0 ? protocols[0] : null } diff --git a/src/agent/snapshotter.ts b/src/agent/snapshotter.ts index f54faac..75a3312 100644 --- a/src/agent/snapshotter.ts +++ b/src/agent/snapshotter.ts @@ -2,9 +2,9 @@ * Snapshotter - Captures user balance snapshots for historical charting */ -import { logger } from '../utils/logger'; -import { UserBalance } from './types'; -import db from '../db'; +import { logger } from '../utils/logger' +import { UserBalance } from './types' +import db from '../db' /** * Capture all user balance snapshots @@ -24,24 +24,24 @@ export async function captureAllUserBalances(): Promise { }, }, }, - }); + }) if (positions.length === 0) { - logger.info('No active positions to snapshot'); - return; + logger.info('No active positions to snapshot') + return } - logger.info('Starting balance snapshot', { positions: positions.length }); + logger.info('Starting balance snapshot', { positions: positions.length }) // CRITICAL FIX: Use batch insert (createMany) instead of individual awaits // This scales much better as user base grows const snapshotData = positions.map((pos: any) => { - const yearsActive = calculateYearsActive(pos.openedAt); + const yearsActive = calculateYearsActive(pos.openedAt) const apy = calculateApy( pos.depositedAmount.toNumber(), pos.yieldEarned.toNumber(), yearsActive - ); + ) return { positionId: pos.id, @@ -49,25 +49,25 @@ export async function captureAllUserBalances(): Promise { apy: apy as any, yieldAmount: pos.yieldEarned, principalAmount: pos.depositedAmount, - }; - }); + } + }) // Single batch insert is much faster than individual creates if (snapshotData.length > 0) { await db.yieldSnapshot.createMany({ data: snapshotData, skipDuplicates: false, - }); + }) } logger.info('Balance snapshot complete', { snapshotCount: snapshotData.length, timestamp: new Date().toISOString(), - }); + }) } catch (error) { logger.error('Snapshot capture failed', { error: error instanceof Error ? error.message : 'Unknown error', - }); + }) } } @@ -75,19 +75,23 @@ export async function captureAllUserBalances(): Promise { * Calculate years a position has been active */ function calculateYearsActive(openedAt: Date): number { - const now = new Date(); - const msPerYear = 365.25 * 24 * 60 * 60 * 1000; - const yearsActive = (now.getTime() - openedAt.getTime()) / msPerYear; - return Math.max(yearsActive, 1 / 365); // At least 1 day to avoid division by zero + const now = new Date() + const msPerYear = 365.25 * 24 * 60 * 60 * 1000 + const yearsActive = (now.getTime() - openedAt.getTime()) / msPerYear + return Math.max(yearsActive, 1 / 365) // At least 1 day to avoid division by zero } /** * Calculate APY from principal and yield * APY = (yield / principal) / years * 100 */ -function calculateApy(principal: number, yieldEarned: number, years: number): number { - if (principal <= 0 || years <= 0) return 0; - return (yieldEarned / principal / years) * 100; +function calculateApy( + principal: number, + yieldEarned: number, + years: number +): number { + if (principal <= 0 || years <= 0) return 0 + return (yieldEarned / principal / years) * 100 } /** @@ -98,8 +102,8 @@ export async function getPositionHistory( days: number = 30 ): Promise { try { - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - days); + const cutoffDate = new Date() + cutoffDate.setDate(cutoffDate.getDate() - days) const snapshots = await db.yieldSnapshot.findMany({ where: { @@ -120,7 +124,7 @@ export async function getPositionHistory( orderBy: { snapshotAt: 'asc', }, - }); + }) return snapshots.map((snapshot: any) => ({ userId: snapshot.position.userId, @@ -128,26 +132,30 @@ export async function getPositionHistory( positionId, protocolName: snapshot.position.protocolName, amount: snapshot.principalAmount.toString(), - currentValue: (snapshot.principalAmount.toNumber() + snapshot.yieldAmount.toNumber()).toString(), + currentValue: ( + snapshot.principalAmount.toNumber() + snapshot.yieldAmount.toNumber() + ).toString(), apy: snapshot.apy.toNumber(), snapshotAt: snapshot.snapshotAt, - })); + })) } catch (error) { logger.error('Failed to get position history', { positionId, error: error instanceof Error ? error.message : 'Unknown error', - }); - return []; + }) + return [] } } /** * Cleanup old snapshots (older than 90 days) */ -export async function cleanupOldSnapshots(retentionDays: number = 90): Promise { +export async function cleanupOldSnapshots( + retentionDays: number = 90 +): Promise { try { - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + const cutoffDate = new Date() + cutoffDate.setDate(cutoffDate.getDate() - retentionDays) const deleted = await db.yieldSnapshot.deleteMany({ where: { @@ -155,25 +163,27 @@ export async function cleanupOldSnapshots(retentionDays: number = 90): Promise 0) { logger.info('Old snapshots cleaned up', { count: deleted.count, cutoffDate: cutoffDate.toISOString(), - }); + }) } } catch (error) { logger.error('Snapshot cleanup failed', { error: error instanceof Error ? error.message : 'Unknown error', - }); + }) } } /** * Get latest user balance snapshot */ -export async function getLatestUserBalance(positionId: string): Promise { +export async function getLatestUserBalance( + positionId: string +): Promise { try { const snapshot = await db.yieldSnapshot.findFirst({ where: { @@ -191,10 +201,10 @@ export async function getLatestUserBalance(positionId: string): Promise | undefined, + scores: Record | undefined ): YieldProtocol[] { - if (riskCeiling === undefined) return protocols; - const scoreMap = scores ?? {}; + if (riskCeiling === undefined) return protocols + const scoreMap = scores ?? {} return protocols.filter((p) => { - const score = scoreMap[p.name]; - return score !== undefined && score >= riskCeiling; - }); + const score = scoreMap[p.name] + return score !== undefined && score >= riskCeiling + }) } function estimateRebalanceCosts( amount: string, maxGasPercent: number -): { gasFeePercent: number; slippagePercent: number; totalCostPercent: number } { - const gasEstimateUSD = 0.50; - const amountUSD = parseInt(amount) / 1e18; - const gasFeePercent = amountUSD > 0 ? (gasEstimateUSD / amountUSD) * 100 : 0; - const slippagePercent = Math.min(maxGasPercent * 0.5, 0.25); +): { + gasFeePercent: number + slippagePercent: number + totalCostPercent: number +} { + const gasEstimateUSD = 0.5 + const amountUSD = parseInt(amount) / 1e18 + const gasFeePercent = amountUSD > 0 ? (gasEstimateUSD / amountUSD) * 100 : 0 + const slippagePercent = Math.min(maxGasPercent * 0.5, 0.25) return { gasFeePercent: Math.min(gasFeePercent, maxGasPercent), slippagePercent, totalCostPercent: Math.min(gasFeePercent + slippagePercent, maxGasPercent), - }; + } } export class MaxYieldStrategy implements RebalanceStrategy { - readonly name: StrategyName = 'MAX_YIELD'; + readonly name: StrategyName = 'MAX_YIELD' async analyze(params: StrategyParams): Promise { - const { currentProtocol, totalAmount, currentApy, availableProtocols, thresholds, riskCeiling, protocolRiskScores } = params; + const { + currentProtocol, + totalAmount, + currentApy, + availableProtocols, + thresholds, + riskCeiling, + protocolRiskScores, + } = params if (availableProtocols.length === 0) { return { shouldRebalance: false, targetProtocol: currentProtocol, reasoning: 'No protocols available for comparison', - }; + } } // Enforce the risk ceiling BEFORE optimizing for yield. When no ceiling is // set this is a no-op that preserves the original candidate set exactly. - const eligibleProtocols = applyRiskCeiling(availableProtocols, riskCeiling, protocolRiskScores); + const eligibleProtocols = applyRiskCeiling( + availableProtocols, + riskCeiling, + protocolRiskScores + ) if (riskCeiling !== undefined && eligibleProtocols.length === 0) { logger.info('MaxYieldStrategy: no protocols meet risk ceiling', { riskCeiling, candidates: availableProtocols.map((p) => p.name), - }); + }) return { shouldRebalance: false, targetProtocol: currentProtocol, reasoning: NO_ELIGIBLE_PROTOCOLS_REASON, details: { riskCeiling, eligibleCount: 0 }, - }; + } } - const bestProtocol = eligibleProtocols[0]; + const bestProtocol = eligibleProtocols[0] if (bestProtocol.name === currentProtocol) { return { shouldRebalance: false, targetProtocol: currentProtocol, reasoning: `Already on the highest-yielding protocol (${currentProtocol} at ${currentApy.toFixed(2)}%)`, - }; + } } - const rawImprovement = bestProtocol.apy - currentApy; - const costs = estimateRebalanceCosts(totalAmount, thresholds.maxGasPercent); - const netImprovement = rawImprovement - costs.totalCostPercent; + const rawImprovement = bestProtocol.apy - currentApy + const costs = estimateRebalanceCosts(totalAmount, thresholds.maxGasPercent) + const netImprovement = rawImprovement - costs.totalCostPercent const shouldRebalance = netImprovement > thresholds.minimumImprovement && - costs.totalCostPercent < thresholds.maxGasPercent; + costs.totalCostPercent < thresholds.maxGasPercent if (shouldRebalance) { logger.info('MaxYieldStrategy: rebalance recommended', { @@ -115,7 +131,7 @@ export class MaxYieldStrategy implements RebalanceStrategy { netImprovement: netImprovement.toFixed(2), gasCost: costs.gasFeePercent.toFixed(4), slippage: costs.slippagePercent.toFixed(4), - }); + }) } return { @@ -124,7 +140,9 @@ export class MaxYieldStrategy implements RebalanceStrategy { reasoning: shouldRebalance ? `Moving from ${currentProtocol} (${currentApy.toFixed(2)}%) to ${bestProtocol.name} (${bestProtocol.apy.toFixed(2)}%) — net gain ${netImprovement.toFixed(2)}% after gas/slippage` : `Net improvement ${netImprovement.toFixed(2)}% below threshold ${thresholds.minimumImprovement}%`, - deviationTrigger: shouldRebalance ? `APY delta: ${rawImprovement.toFixed(2)}%` : undefined, + deviationTrigger: shouldRebalance + ? `APY delta: ${rawImprovement.toFixed(2)}%` + : undefined, details: { currentApy, bestApy: bestProtocol.apy, @@ -135,93 +153,115 @@ export class MaxYieldStrategy implements RebalanceStrategy { slippagePercent: costs.slippagePercent, totalCostPercent: costs.totalCostPercent, }, - }; + } } } export class TargetAllocationStrategy implements RebalanceStrategy { - readonly name: StrategyName = 'TARGET_ALLOCATION'; + readonly name: StrategyName = 'TARGET_ALLOCATION' - private readonly targetDeviationThreshold = 0.2; + private readonly targetDeviationThreshold = 0.2 async analyze(params: StrategyParams): Promise { - const { currentProtocol, totalAmount, currentApy, availableProtocols, thresholds, userStrategyPreferences, riskCeiling, protocolRiskScores } = params; - - const relevantPrefs = userStrategyPreferences.filter(p => p.targetAllocations && Object.keys(p.targetAllocations!).length > 0); + const { + currentProtocol, + totalAmount, + currentApy, + availableProtocols, + thresholds, + userStrategyPreferences, + riskCeiling, + protocolRiskScores, + } = params + + const relevantPrefs = userStrategyPreferences.filter( + (p) => p.targetAllocations && Object.keys(p.targetAllocations!).length > 0 + ) if (relevantPrefs.length === 0) { return { shouldRebalance: false, targetProtocol: currentProtocol, reasoning: 'No target allocations configured for these users', - }; + } } - const pref = relevantPrefs[0]; - const targets = pref.targetAllocations!; - const currentTarget = targets[currentProtocol]; + const pref = relevantPrefs[0] + const targets = pref.targetAllocations! + const currentTarget = targets[currentProtocol] if (currentTarget === undefined) { return { shouldRebalance: false, targetProtocol: currentProtocol, reasoning: `No target allocation set for ${currentProtocol}`, - }; + } } - const totalTarget = Object.values(targets).reduce((sum, v) => sum + v, 0); - const targetShare = totalTarget > 0 ? currentTarget / totalTarget : 0; + const totalTarget = Object.values(targets).reduce((sum, v) => sum + v, 0) + const targetShare = totalTarget > 0 ? currentTarget / totalTarget : 0 // Candidate rebalance targets are the configured protocols other than the // current one. When a risk ceiling is set, exclude any candidate that does // not clear it (fail-closed on unknown scores) BEFORE choosing a target. // When no ceiling is set this filter is a no-op, preserving prior behavior. - const scoreMap = protocolRiskScores ?? {}; + const scoreMap = protocolRiskScores ?? {} const passesCeiling = (name: string): boolean => riskCeiling === undefined || - (scoreMap[name] !== undefined && scoreMap[name] >= riskCeiling); + (scoreMap[name] !== undefined && scoreMap[name] >= riskCeiling) const bestTargetProtocol = Object.entries(targets) .filter(([name]) => name !== currentProtocol) .filter(([name]) => passesCeiling(name)) - .sort(([, a], [, b]) => b - a); + .sort(([, a], [, b]) => b - a) if (bestTargetProtocol.length === 0) { // Distinguish "ceiling excluded everything" from "nothing else configured" // so the user's stated risk tolerance is surfaced, never silently dropped. if (riskCeiling !== undefined) { - const otherConfigured = Object.keys(targets).filter((name) => name !== currentProtocol); + const otherConfigured = Object.keys(targets).filter( + (name) => name !== currentProtocol + ) if (otherConfigured.length > 0) { - logger.info('TargetAllocationStrategy: no target protocols meet risk ceiling', { - riskCeiling, - candidates: otherConfigured, - }); + logger.info( + 'TargetAllocationStrategy: no target protocols meet risk ceiling', + { + riskCeiling, + candidates: otherConfigured, + } + ) return { shouldRebalance: false, targetProtocol: currentProtocol, reasoning: NO_ELIGIBLE_PROTOCOLS_REASON, details: { riskCeiling, eligibleCount: 0 }, - }; + } } } return { shouldRebalance: false, targetProtocol: currentProtocol, reasoning: `Only one protocol configured in targets — no rebalance target available`, - }; + } } - const [highestTargetProtocol, highestTarget] = bestTargetProtocol[0]; - const ratio = highestTarget > 0 ? currentTarget / highestTarget : 1; + const [highestTargetProtocol, highestTarget] = bestTargetProtocol[0] + const ratio = highestTarget > 0 ? currentTarget / highestTarget : 1 if (ratio < 1 - this.targetDeviationThreshold) { - const costs = estimateRebalanceCosts(totalAmount, thresholds.maxGasPercent); - - if (costs.totalCostPercent >= thresholds.maxGasPercent || totalAmount === '0') { + const costs = estimateRebalanceCosts( + totalAmount, + thresholds.maxGasPercent + ) + + if ( + costs.totalCostPercent >= thresholds.maxGasPercent || + totalAmount === '0' + ) { return { shouldRebalance: false, targetProtocol: currentProtocol, reasoning: `Rebalance from ${currentProtocol} to ${highestTargetProtocol} would exceed max gas cost`, - }; + } } logger.info('TargetAllocationStrategy: rebalance recommended', { @@ -232,7 +272,7 @@ export class TargetAllocationStrategy implements RebalanceStrategy { ratio: ratio.toFixed(2), gasCost: costs.gasFeePercent.toFixed(4), slippage: costs.slippagePercent.toFixed(4), - }); + }) return { shouldRebalance: true, @@ -248,7 +288,7 @@ export class TargetAllocationStrategy implements RebalanceStrategy { targets, totalCostPercent: costs.totalCostPercent, }, - }; + } } return { @@ -262,7 +302,7 @@ export class TargetAllocationStrategy implements RebalanceStrategy { highestTarget, ratio, }, - }; + } } } @@ -272,9 +312,12 @@ export class TargetAllocationStrategy implements RebalanceStrategy { * for a future date rather than a past one. Zero or negative means the target * date has already passed. */ -export function calculateYearsRemaining(targetDate: Date, from: Date = new Date()): number { - const msPerYear = 365.25 * 24 * 60 * 60 * 1000; - return (targetDate.getTime() - from.getTime()) / msPerYear; +export function calculateYearsRemaining( + targetDate: Date, + from: Date = new Date() +): number { + const msPerYear = 365.25 * 24 * 60 * 60 * 1000 + return (targetDate.getTime() - from.getTime()) / msPerYear } /** @@ -290,11 +333,13 @@ export function calculateYearsRemaining(targetDate: Date, from: Date = new Date( export function calculateRequiredApy( startingAmount: number, targetAmount: number, - yearsRemaining: number, + yearsRemaining: number ): number { - if (targetAmount <= startingAmount) return 0; - if (yearsRemaining <= 0) return Infinity; - return ((targetAmount - startingAmount) / startingAmount / yearsRemaining) * 100; + if (targetAmount <= startingAmount) return 0 + if (yearsRemaining <= 0) return Infinity + return ( + ((targetAmount - startingAmount) / startingAmount / yearsRemaining) * 100 + ) } /** @@ -307,17 +352,24 @@ export function calculateRequiredApy( * explicit "unreachable" decision instead of overriding the ceiling. */ export class GoalTrackingStrategy implements RebalanceStrategy { - readonly name: StrategyName = 'GOAL_TRACKING'; + readonly name: StrategyName = 'GOAL_TRACKING' async analyze(params: StrategyParams): Promise { - const { currentProtocol, currentApy, availableProtocols, goal, riskCeiling, protocolRiskScores } = params; + const { + currentProtocol, + currentApy, + availableProtocols, + goal, + riskCeiling, + protocolRiskScores, + } = params if (!goal) { return { shouldRebalance: false, targetProtocol: currentProtocol, reasoning: 'No active savings goal configured', - }; + } } if (goal.targetAmount <= goal.startingAmount) { @@ -326,10 +378,10 @@ export class GoalTrackingStrategy implements RebalanceStrategy { targetProtocol: currentProtocol, reasoning: 'Savings goal is already achieved', details: { goal }, - }; + } } - const yearsRemaining = calculateYearsRemaining(goal.targetDate); + const yearsRemaining = calculateYearsRemaining(goal.targetDate) if (yearsRemaining <= 0) { return { @@ -337,28 +389,37 @@ export class GoalTrackingStrategy implements RebalanceStrategy { targetProtocol: currentProtocol, reasoning: 'Savings goal target date has passed without being met', details: { goal }, - }; + } } - const requiredApy = calculateRequiredApy(goal.startingAmount, goal.targetAmount, yearsRemaining); + const requiredApy = calculateRequiredApy( + goal.startingAmount, + goal.targetAmount, + yearsRemaining + ) - const eligibleProtocols = applyRiskCeiling(availableProtocols, riskCeiling, protocolRiskScores); + const eligibleProtocols = applyRiskCeiling( + availableProtocols, + riskCeiling, + protocolRiskScores + ) if (riskCeiling !== undefined && eligibleProtocols.length === 0) { logger.info('GoalTrackingStrategy: no protocols meet risk ceiling', { riskCeiling, requiredApy: requiredApy.toFixed(2), - }); + }) return { shouldRebalance: false, targetProtocol: currentProtocol, reasoning: NO_ELIGIBLE_PROTOCOLS_REASON, details: { requiredApy, riskCeiling, eligibleCount: 0 }, - }; + } } - const candidateApys = eligibleProtocols.map((p) => p.apy); - const maxEligibleApy = candidateApys.length > 0 ? Math.max(...candidateApys) : currentApy; + const candidateApys = eligibleProtocols.map((p) => p.apy) + const maxEligibleApy = + candidateApys.length > 0 ? Math.max(...candidateApys) : currentApy if (requiredApy > maxEligibleApy) { // Required rate is unreachable within the user's risk tolerance. Surface @@ -367,8 +428,13 @@ export class GoalTrackingStrategy implements RebalanceStrategy { shouldRebalance: false, targetProtocol: currentProtocol, reasoning: `Target requires ${requiredApy.toFixed(2)}% APY, which exceeds the best available within your risk tolerance (${maxEligibleApy.toFixed(2)}%) — target not reachable within your risk tolerance`, - details: { requiredApy, maxEligibleApy, riskCeiling, unreachable: true }, - }; + details: { + requiredApy, + maxEligibleApy, + riskCeiling, + unreachable: true, + }, + } } if (currentApy >= requiredApy) { @@ -377,18 +443,18 @@ export class GoalTrackingStrategy implements RebalanceStrategy { targetProtocol: currentProtocol, reasoning: `On track — current ${currentApy.toFixed(2)}% APY meets the ${requiredApy.toFixed(2)}% required to reach your goal by ${goal.targetDate.toISOString().slice(0, 10)}`, details: { requiredApy, currentApy, onTrack: true }, - }; + } } // Behind schedule and reachable: delegate to MaxYieldStrategy (already // bounded by the same riskCeiling) to chase the best eligible yield. - const maxYield = new MaxYieldStrategy(); - const decision = await maxYield.analyze(params); + const maxYield = new MaxYieldStrategy() + const decision = await maxYield.analyze(params) return { ...decision, reasoning: `Behind schedule (need ${requiredApy.toFixed(2)}% APY to reach your goal) — ${decision.reasoning}`, details: { ...decision.details, requiredApy, goalDriven: true }, - }; + } } } diff --git a/src/agent/types.ts b/src/agent/types.ts index ad36314..e34acd4 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -3,101 +3,101 @@ */ export interface YieldProtocol { - name: string; - apy: number; - tvl?: number; - assetSymbol: string; - lastUpdated: Date; - isAvailable: boolean; - errorMessage?: string; + name: string + apy: number + tvl?: number + assetSymbol: string + lastUpdated: Date + isAvailable: boolean + errorMessage?: string } export interface ProtocolComparison { - current: YieldProtocol; - best: YieldProtocol; - improvement: number; // percentage points - shouldRebalance: boolean; + current: YieldProtocol + best: YieldProtocol + improvement: number // percentage points + shouldRebalance: boolean } export interface RebalanceDetails { - fromProtocol: string; - toProtocol: string; - amount: string; - estimatedGasfee?: string; - txHash?: string; - timestamp: Date; - improvedBy: number; // percentage points + fromProtocol: string + toProtocol: string + amount: string + estimatedGasfee?: string + txHash?: string + timestamp: Date + improvedBy: number // percentage points } export interface UserBalance { - userId: string; - walletAddress: string; - positionId: string; - protocolName: string; - amount: string; - currentValue: string; - apy: number; - snapshotAt: Date; + userId: string + walletAddress: string + positionId: string + protocolName: string + amount: string + currentValue: string + apy: number + snapshotAt: Date } export interface AgentStatus { - isRunning: boolean; - lastRebalanceAt?: Date; - currentProtocol?: string; - currentApy?: number; - nextScheduledCheck: Date; - lastError?: string; - healthStatus: 'healthy' | 'degraded' | 'error'; + isRunning: boolean + lastRebalanceAt?: Date + currentProtocol?: string + currentApy?: number + nextScheduledCheck: Date + lastError?: string + healthStatus: 'healthy' | 'degraded' | 'error' } export interface AgentJobResult { - jobName: string; - success: boolean; - duration: number; // milliseconds - timestamp: Date; - details?: Record; - error?: string; + jobName: string + success: boolean + duration: number // milliseconds + timestamp: Date + details?: Record + error?: string } export interface ProtocolRate { - protocolName: string; - assetSymbol: string; - supplyApy: number; - borrowApy?: number; - tvl?: number; - network: string; - fetchedAt: Date; + protocolName: string + assetSymbol: string + supplyApy: number + borrowApy?: number + tvl?: number + network: string + fetchedAt: Date } export interface RebalanceThresholds { - minimumImprovement: number; // 0.5% default - maxGasPercent: number; // 0.1% default + minimumImprovement: number // 0.5% default + maxGasPercent: number // 0.1% default } -export type StrategyName = 'MAX_YIELD' | 'TARGET_ALLOCATION' | 'GOAL_TRACKING'; +export type StrategyName = 'MAX_YIELD' | 'TARGET_ALLOCATION' | 'GOAL_TRACKING' export interface StrategyDecision { - shouldRebalance: boolean; - targetProtocol: string; - reasoning: string; - deviationTrigger?: string; - details?: Record; + shouldRebalance: boolean + targetProtocol: string + reasoning: string + deviationTrigger?: string + details?: Record } export interface StrategyParams { - currentProtocol: string; - totalAmount: string; - currentApy: number; - availableProtocols: YieldProtocol[]; - thresholds: RebalanceThresholds; - userStrategyPreferences: UserStrategyPreferences[]; + currentProtocol: string + totalAmount: string + currentApy: number + availableProtocols: YieldProtocol[] + thresholds: RebalanceThresholds + userStrategyPreferences: UserStrategyPreferences[] /** * Optional per-protocol risk scores (0-100, higher = lower risk), keyed by * protocol name. Supplied by the caller from ProtocolRiskScore. Only consulted * when a strategy is given a riskCeiling; absent scores are treated as * ineligible under a ceiling (fail-closed — see StrategyParams.riskCeiling). */ - protocolRiskScores?: Record; + protocolRiskScores?: Record /** * Optional minimum acceptable risk score. When set, candidate protocols are * filtered to those with score >= riskCeiling BEFORE any yield/allocation @@ -106,34 +106,34 @@ export interface StrategyParams { * surfaces an explicit "no eligible protocols" decision — it is never silently * ignored to keep the agent allocating. */ - riskCeiling?: number; + riskCeiling?: number /** * Optional active SavingsGoal (#281) driving GoalTrackingStrategy. Only * consulted by that strategy — absent for MaxYieldStrategy/TargetAllocationStrategy * callers, which are unaffected by this field's presence. */ goal?: { - targetAmount: number; - startingAmount: number; - targetDate: Date; - }; + targetAmount: number + startingAmount: number + targetDate: Date + } } export interface RebalanceStrategy { - readonly name: StrategyName; - analyze(params: StrategyParams): Promise; + readonly name: StrategyName + analyze(params: StrategyParams): Promise } export interface UserStrategyPreferences { - userId: string; - strategyName?: StrategyName | null; - targetAllocations?: Record; - riskTolerance?: number; + userId: string + strategyName?: StrategyName | null + targetAllocations?: Record + riskTolerance?: number /** * Optional minimum acceptable protocol risk score (0-100, higher = lower * risk). When set, the strategy engine only considers protocols scoring at or * above this value. Opt-in and backward compatible: unset means no risk * filtering, identical to prior behavior. */ - riskCeiling?: number; + riskCeiling?: number } diff --git a/src/app.ts b/src/app.ts index 86446d9..0c49c3f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,5 +1,6 @@ import express, { NextFunction, Request, Response } from 'express' import { setupCors, validateCorsConfig } from './middleware/corsandbody' +import { logger } from './utils/logger' import dotenv from 'dotenv' dotenv.config() @@ -31,7 +32,7 @@ app.use( app.use((req: Request, res: Response, next: NextFunction) => { const origin = req.get('origin') || 'no-origin' const timestamp = new Date().toISOString() - console.log(`[${timestamp}] ${req.method} ${req.path} (origin: ${origin})`) + logger.info(`[${timestamp}] ${req.method} ${req.path} (origin: ${origin})`) next() }) @@ -110,18 +111,18 @@ app.use((err: any, req: Request, res: Response, next: NextFunction) => { // Start server const server = app.listen(PORT, () => { - console.log(`✓ Server running on http://localhost:${PORT}`) - console.log( + logger.info(`✓ Server running on http://localhost:${PORT}`) + logger.info( `✓ CORS enabled for: ${process.env.CORS_ALLOWED_ORIGINS || 'development'}` ) - console.log(`✓ Environment: ${process.env.NODE_ENV || 'development'}`) + logger.info(`✓ Environment: ${process.env.NODE_ENV || 'development'}`) }) // Graceful shutdown process.on('SIGTERM', () => { - console.log('SIGTERM received, closing server...') + logger.info('SIGTERM received, closing server...') server.close(() => { - console.log('Server closed') + logger.info('Server closed') process.exit(0) }) }) diff --git a/src/config/index.ts b/src/config/index.ts index a0ad4dd..0deff50 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,4 +1,8 @@ -export { JwtAdapter } from "./jwt-adapter"; -export { config } from "./env"; -export { bootstrapSecrets, getSecretsProvider, createSecretsProvider } from "./secrets"; -export type { SecretsProvider, SecretKey } from "./secrets"; \ No newline at end of file +export { JwtAdapter } from './jwt-adapter' +export { config } from './env' +export { + bootstrapSecrets, + getSecretsProvider, + createSecretsProvider, +} from './secrets' +export type { SecretsProvider, SecretKey } from './secrets' diff --git a/src/config/protocolRiskMetadata.ts b/src/config/protocolRiskMetadata.ts index 4207a32..fc40a00 100644 --- a/src/config/protocolRiskMetadata.ts +++ b/src/config/protocolRiskMetadata.ts @@ -24,19 +24,20 @@ * UNAUDITED with unknown (0-day) age — the most conservative assumption. */ -export type AuditStatusValue = 'UNAUDITED' | 'SELF_REPORTED' | 'THIRD_PARTY_AUDITED'; +export type AuditStatusValue = + 'UNAUDITED' | 'SELF_REPORTED' | 'THIRD_PARTY_AUDITED' export interface ProtocolRiskMetadata { /** Must match ProtocolRate.protocolName / YieldProtocol.name exactly. */ - protocolName: string; - auditStatus: AuditStatusValue; + protocolName: string + auditStatus: AuditStatusValue /** * Protocol launch date (ISO-8601, UTC). protocolAgeDays is computed from this * relative to the scoring run, so it never needs manual bumping. */ - inceptionDate: string; + inceptionDate: string /** Optional public link/citation backing the auditStatus. For review only. */ - auditReference?: string; + auditReference?: string } /** @@ -53,43 +54,51 @@ export const PROTOCOL_RISK_METADATA: readonly ProtocolRiskMetadata[] = [ protocolName: 'Blend', auditStatus: 'THIRD_PARTY_AUDITED', inceptionDate: '2024-02-01', - auditReference: 'https://docs.blend.capital/ — verify latest audit report on review', + auditReference: + 'https://docs.blend.capital/ — verify latest audit report on review', }, { protocolName: 'Stellar DEX', auditStatus: 'THIRD_PARTY_AUDITED', inceptionDate: '2015-09-30', - auditReference: 'Stellar Core protocol; native DEX. Verify scope on review.', + auditReference: + 'Stellar Core protocol; native DEX. Verify scope on review.', }, { protocolName: 'Luma', auditStatus: 'SELF_REPORTED', inceptionDate: '2023-06-01', - auditReference: 'Self-reported; no third-party audit confirmed at time of curation.', + auditReference: + 'Self-reported; no third-party audit confirmed at time of curation.', }, -]; +] const METADATA_BY_NAME: ReadonlyMap = new Map( - PROTOCOL_RISK_METADATA.map((m) => [m.protocolName, m]), -); + PROTOCOL_RISK_METADATA.map((m) => [m.protocolName, m]) +) /** * The conservative default applied to any protocol seen in rate history but not * present in the curated table: unaudited, unknown age. */ -export const DEFAULT_PROTOCOL_METADATA: Omit = { +export const DEFAULT_PROTOCOL_METADATA: Omit< + ProtocolRiskMetadata, + 'protocolName' +> = { auditStatus: 'UNAUDITED', inceptionDate: '', // empty => age unknown => treated as 0 days (newest/riskiest) -}; +} /** * Look up curated metadata for a protocol, falling back to the conservative * default when the protocol is not curated. */ -export function getProtocolMetadata(protocolName: string): ProtocolRiskMetadata { - const found = METADATA_BY_NAME.get(protocolName); - if (found) return found; - return { protocolName, ...DEFAULT_PROTOCOL_METADATA }; +export function getProtocolMetadata( + protocolName: string +): ProtocolRiskMetadata { + const found = METADATA_BY_NAME.get(protocolName) + if (found) return found + return { protocolName, ...DEFAULT_PROTOCOL_METADATA } } /** @@ -97,12 +106,15 @@ export function getProtocolMetadata(protocolName: string): ProtocolRiskMetadata * `now`. Returns 0 when the inception date is missing or unparseable (unknown * age is treated as brand-new, i.e. maximally risky). */ -export function computeProtocolAgeDays(inceptionDate: string, now: Date): number { - if (!inceptionDate) return 0; - const inception = new Date(inceptionDate); - const ms = inception.getTime(); - if (Number.isNaN(ms)) return 0; - const diffMs = now.getTime() - ms; - if (diffMs <= 0) return 0; - return Math.floor(diffMs / (24 * 60 * 60 * 1000)); +export function computeProtocolAgeDays( + inceptionDate: string, + now: Date +): number { + if (!inceptionDate) return 0 + const inception = new Date(inceptionDate) + const ms = inception.getTime() + if (Number.isNaN(ms)) return 0 + const diffMs = now.getTime() - ms + if (diffMs <= 0) return 0 + return Math.floor(diffMs / (24 * 60 * 60 * 1000)) } diff --git a/src/config/readiness.ts b/src/config/readiness.ts index a8eaf90..36fb3d8 100644 --- a/src/config/readiness.ts +++ b/src/config/readiness.ts @@ -43,10 +43,10 @@ function validateNetworkConsistency(): void { } const patterns = expectedPatterns[network] - if (patterns && !patterns.some(p => rpcUrl.toLowerCase().includes(p))) { + if (patterns && !patterns.some((p) => rpcUrl.toLowerCase().includes(p))) { logger.warn( `⚠️ Network/RPC mismatch: STELLAR_NETWORK=${network} but RPC URL "${rpcUrl}" ` + - `does not appear to be a ${network} endpoint. Verify your configuration.` + `does not appear to be a ${network} endpoint. Verify your configuration.` ) } else { logger.info(`✓ Stellar network consistency validated: ${network}`) diff --git a/src/config/secrets.ts b/src/config/secrets.ts index 9a7f863..0d8282d 100644 --- a/src/config/secrets.ts +++ b/src/config/secrets.ts @@ -144,13 +144,15 @@ export function createSecretsProvider(): SecretsProvider { * Call this once at process startup, before importing any config module. */ export async function bootstrapSecrets(): Promise { - // Lazily create the singleton provider. - if (!_provider) _provider = createSecretsProvider() - // Only the SSM backend needs to pre-populate process.env. - // The env backend already reads from process.env, so this is a no-op there. + // The env backend already reads from process.env, so this is a no-op there — + // return before touching the singleton so a later backend switch (tests, + // re-exec) still constructs the right provider. if (process.env.SECRET_BACKEND !== 'aws-ssm') return + // Lazily create the singleton provider. + if (!_provider) _provider = createSecretsProvider() + const errors: string[] = [] await Promise.all( SECRET_KEYS.map(async (key) => { diff --git a/src/controllers/goal-controller.ts b/src/controllers/goal-controller.ts index 02ec76a..1fc6bf6 100644 --- a/src/controllers/goal-controller.ts +++ b/src/controllers/goal-controller.ts @@ -3,7 +3,12 @@ // report its progress. import { Request, Response } from 'express' import { logger } from '../utils/logger' -import { sendError, sendNotFound, sendUnauthorized, sendConflict } from '../utils/errors' +import { + sendError, + sendNotFound, + sendUnauthorized, + sendConflict, +} from '../utils/errors' import { mapGoalToResponse } from '../utils/api-formatters' import { formatGoalProgressReply } from '../whatsapp/formatters' import { @@ -24,7 +29,10 @@ import { * Create the caller's savings goal. Rejects if the caller already has an * ACTIVE goal (single active goal per user, for now). */ -export async function createGoalHandler(req: Request, res: Response): Promise { +export async function createGoalHandler( + req: Request, + res: Response +): Promise { const userId = req.userId if (!userId) { sendUnauthorized(res) @@ -54,7 +62,10 @@ export async function createGoalHandler(req: Request, res: Response): Promise { +export async function getGoalHandler( + req: Request, + res: Response +): Promise { const userId = String(req.params.userId) try { @@ -76,7 +87,10 @@ export async function getGoalHandler(req: Request, res: Response): Promise * Update target amount/date/riskCeiling. Keyed by goal id (not :userId), so * enforceUserAccess doesn't apply — ownership is checked explicitly here. */ -export async function updateGoalHandler(req: Request, res: Response): Promise { +export async function updateGoalHandler( + req: Request, + res: Response +): Promise { const authUserId = req.auth?.userId if (!authUserId) { sendUnauthorized(res) @@ -118,7 +132,10 @@ export async function updateGoalHandler(req: Request, res: Response): Promise { +export async function cancelGoalHandler( + req: Request, + res: Response +): Promise { const authUserId = req.auth?.userId if (!authUserId) { sendUnauthorized(res) @@ -157,7 +174,10 @@ export async function cancelGoalHandler(req: Request, res: Response): Promise { +export async function getGoalProgressHandler( + req: Request, + res: Response +): Promise { const authUserId = req.auth?.userId if (!authUserId) { sendUnauthorized(res) diff --git a/src/controllers/transaction-controller.ts b/src/controllers/transaction-controller.ts index d8682ec..3c4ff61 100644 --- a/src/controllers/transaction-controller.ts +++ b/src/controllers/transaction-controller.ts @@ -50,7 +50,8 @@ export async function processOnChainTransaction( status: onChainTransaction.status, }) - const transactionStatus = onChainTransaction.status === 'success' ? 'CONFIRMED' : 'FAILED' + const transactionStatus = + onChainTransaction.status === 'success' ? 'CONFIRMED' : 'FAILED' const existing = await db.transaction.findUnique({ where: { txHash: onChainTransaction.hash }, @@ -76,7 +77,8 @@ export async function processOnChainTransaction( }, }) - const formatter = type === 'DEPOSIT' ? formatDepositReply : formatWithdrawReply + const formatter = + type === 'DEPOSIT' ? formatDepositReply : formatWithdrawReply if (transactionStatus === 'CONFIRMED') { dispatchWebhookEvent('transaction.confirmed', { diff --git a/src/db/index.ts b/src/db/index.ts index 8ec26b3..3fa962b 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -51,11 +51,15 @@ export async function connectDb(): Promise { logger.info('[DB] Connected to database') } catch (error) { logger.error('[DB] Cannot connect to database — server will not start') - logger.error(`[DB] ${error instanceof Error ? error.message : String(error)}`) - logger.error('[DB] Check that DATABASE_URL is correct and the database is running') + logger.error( + `[DB] ${error instanceof Error ? error.message : String(error)}` + ) + logger.error( + '[DB] Check that DATABASE_URL is correct and the database is running' + ) await db.$disconnect() process.exit(1) } } -export default db \ No newline at end of file +export default db diff --git a/src/fiat/providers/moonpay.ts b/src/fiat/providers/moonpay.ts index 918412c..9685c81 100644 --- a/src/fiat/providers/moonpay.ts +++ b/src/fiat/providers/moonpay.ts @@ -46,7 +46,9 @@ function normalizeStatus(raw: string | undefined): NormalizedWebhookStatus { } /** Parse the `t=...,s=...` signature header into its parts. */ -function parseSignatureHeader(header: string | undefined): { timestamp: string; signature: string } | null { +function parseSignatureHeader( + header: string | undefined +): { timestamp: string; signature: string } | null { if (!header) return null const parts = header.split(',').map((p) => p.trim()) let timestamp = '' @@ -79,11 +81,19 @@ export class MoonPayProvider implements FiatRampProvider { private readonly baseUrl: string private readonly http: HttpClientAdapter - constructor(opts?: { apiKey?: string; secretKey?: string; webhookKey?: string; baseUrl?: string }) { + constructor(opts?: { + apiKey?: string + secretKey?: string + webhookKey?: string + baseUrl?: string + }) { this.apiKey = opts?.apiKey ?? process.env.MOONPAY_API_KEY ?? '' this.secretKey = opts?.secretKey ?? process.env.MOONPAY_SECRET_KEY ?? '' this.webhookKey = opts?.webhookKey ?? process.env.MOONPAY_WEBHOOK_KEY ?? '' - this.baseUrl = opts?.baseUrl ?? process.env.MOONPAY_API_BASE_URL ?? 'https://api.moonpay.com' + this.baseUrl = + opts?.baseUrl ?? + process.env.MOONPAY_API_BASE_URL ?? + 'https://api.moonpay.com' this.http = new HttpClientAdapter({ timeoutMs: config.httpClient.timeoutMs, maxRetries: config.httpClient.maxRetries, @@ -107,14 +117,19 @@ export class MoonPayProvider implements FiatRampProvider { `&${amountParam}=${encodeURIComponent(String(req.fiatAmount))}` const data = await this.http.execute(async () => { - const res = await fetch(url, { method: 'GET', headers: { Accept: 'application/json' } }) + const res = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) if (!res.ok) { throw new Error(`MoonPay quote failed: HTTP ${res.status}`) } return (await res.json()) as Record }, 'moonpay.getQuote') - const cryptoAmount = Number(data.quoteCurrencyAmount ?? data.cryptoAmount ?? 0) + const cryptoAmount = Number( + data.quoteCurrencyAmount ?? data.cryptoAmount ?? 0 + ) const feeAmount = Number(data.feeAmount ?? 0) || undefined const rate = Number(data.exchangeRate ?? data.rate ?? 0) || undefined @@ -169,17 +184,25 @@ export class MoonPayProvider implements FiatRampProvider { return { providerOrderId, - checkoutUrl: (data.redirectUrl as string) ?? (data.widgetRedirectUrl as string) ?? undefined, + checkoutUrl: + (data.redirectUrl as string) ?? + (data.widgetRedirectUrl as string) ?? + undefined, kycUrl: (data.kycRedirectUrl as string) ?? undefined, status: normalizeStatus(data.status as string | undefined), cryptoAmount: Number(data.quoteCurrencyAmount ?? 0) || undefined, } } - verifyWebhookSignature(rawBody: string, headers: Record): boolean { + verifyWebhookSignature( + rawBody: string, + headers: Record + ): boolean { if (!this.webhookKey) { // No configured secret means we cannot verify — reject rather than trust. - logger.error('[MoonPay] MOONPAY_WEBHOOK_KEY not configured — rejecting webhook') + logger.error( + '[MoonPay] MOONPAY_WEBHOOK_KEY not configured — rejecting webhook' + ) return false } @@ -191,7 +214,9 @@ export class MoonPayProvider implements FiatRampProvider { if (!parsed) return false const signedPayload = `${parsed.timestamp}.${rawBody}` - const expected = createHmac('sha256', this.webhookKey).update(signedPayload).digest('hex') + const expected = createHmac('sha256', this.webhookKey) + .update(signedPayload) + .digest('hex') return timingSafeEqualHex(expected, parsed.signature) } @@ -201,14 +226,21 @@ export class MoonPayProvider implements FiatRampProvider { // MoonPay wraps the resource under `data` with a top-level `type`. const data = (parsed.data ?? parsed) as Record - const providerOrderId = String(data.id ?? parsed.externalTransactionId ?? '') + const providerOrderId = String( + data.id ?? parsed.externalTransactionId ?? '' + ) const status = normalizeStatus(data.status as string | undefined) return { providerOrderId, - status: data.kycRedirectUrl && status !== 'SETTLED' ? 'KYC_REQUIRED' : status, - txHash: (data.cryptoTransactionId as string) ?? (data.txHash as string) ?? undefined, - cryptoAmount: Number(data.quoteCurrencyAmount ?? data.cryptoAmount ?? 0) || undefined, + status: + data.kycRedirectUrl && status !== 'SETTLED' ? 'KYC_REQUIRED' : status, + txHash: + (data.cryptoTransactionId as string) ?? + (data.txHash as string) ?? + undefined, + cryptoAmount: + Number(data.quoteCurrencyAmount ?? data.cryptoAmount ?? 0) || undefined, kycUrl: (data.kycRedirectUrl as string) ?? undefined, reason: (data.failureReason as string) ?? undefined, } diff --git a/src/fiat/service.ts b/src/fiat/service.ts index 79c77a3..75230b9 100644 --- a/src/fiat/service.ts +++ b/src/fiat/service.ts @@ -34,7 +34,7 @@ import type { NormalizedWebhookStatus, ParsedWebhook } from './types' /** How long a PENDING/PROCESSING order may sit before the age-out job fails it. */ export const STALE_ORDER_MAX_AGE_MS = Number( - process.env.FIAT_STALE_ORDER_MAX_AGE_MS || 24 * 60 * 60 * 1000, + process.env.FIAT_STALE_ORDER_MAX_AGE_MS || 24 * 60 * 60 * 1000 ) type Db = typeof db @@ -61,7 +61,7 @@ export interface CreateOrderContext { export async function createFiatOrder( input: CreateFiatOrderInput, ctx: CreateOrderContext, - database: Db = db, + database: Db = db ) { const provider = getDefaultProvider() @@ -123,7 +123,7 @@ export interface ProcessWebhookResult { export async function processProviderWebhook( providerName: string, parsed: ParsedWebhook, - database: Db = db, + database: Db = db ): Promise { if (!parsed.providerOrderId) { return { handled: false, reason: 'missing providerOrderId' } @@ -150,7 +150,12 @@ export async function processProviderWebhook( // Terminal states are immutable — drop duplicate/late deliveries. if (isTerminal(order.status)) { - return { handled: true, reason: 'already terminal', orderId: order.id, status: order.status } + return { + handled: true, + reason: 'already terminal', + orderId: order.id, + status: order.status, + } } const data: Record = { updatedAt: new Date() } @@ -207,12 +212,14 @@ export async function processProviderWebhook( // If the provider handed us a tx hash, try an immediate reconciliation pass // for this single order so settlement isn't delayed to the next sweep. if (parsed.txHash && updated.status === 'PROCESSING') { - await reconcileSingleOrder(updated.id, parsed.txHash, database).catch((err) => { - logger.error('[Fiat] Inline reconciliation failed', { - orderId: updated.id, - error: err instanceof Error ? err.message : String(err), - }) - }) + await reconcileSingleOrder(updated.id, parsed.txHash, database).catch( + (err) => { + logger.error('[Fiat] Inline reconciliation failed', { + orderId: updated.id, + error: err instanceof Error ? err.message : String(err), + }) + } + ) } return { handled: true, orderId: updated.id, status: updated.status } @@ -228,12 +235,16 @@ export async function processProviderWebhook( export async function reconcileSingleOrder( orderId: string, txHash: string, - database: Db = db, + database: Db = db ): Promise { - const order = await (database as any).fiatOrder.findUnique({ where: { id: orderId } }) + const order = await (database as any).fiatOrder.findUnique({ + where: { id: orderId }, + }) if (!order || isTerminal(order.status)) return false - const tx = await (database as any).transaction.findUnique({ where: { txHash } }) + const tx = await (database as any).transaction.findUnique({ + where: { txHash }, + }) if (!tx || tx.status !== 'CONFIRMED') return false if (tx.userId !== order.userId) { // Hash belongs to a different user — never cross-link funds. @@ -305,7 +316,11 @@ export async function reconcileFiatOrders(database: Db = db): Promise<{ }) if (candidate) { - const ok = await reconcileSingleOrder(order.id, candidate.txHash, database).catch(() => false) + const ok = await reconcileSingleOrder( + order.id, + candidate.txHash, + database + ).catch(() => false) if (ok) settled++ continue } @@ -330,7 +345,7 @@ export async function reconcileFiatOrders(database: Db = db): Promise<{ userId: order.userId, }, }, - `fiat:stuck:${order.id}`, + `fiat:stuck:${order.id}` ) .catch(() => {}) } @@ -344,7 +359,9 @@ export async function reconcileFiatOrders(database: Db = db): Promise<{ * so they don't linger forever. PROCESSING orders are left to reconciliation + * alerting, because funds may still be in flight. */ -export async function ageOutStaleFiatOrders(database: Db = db): Promise<{ failed: number }> { +export async function ageOutStaleFiatOrders( + database: Db = db +): Promise<{ failed: number }> { const cutoff = new Date(Date.now() - STALE_ORDER_MAX_AGE_MS) const stale = await (database as any).fiatOrder.findMany({ diff --git a/src/fiat/types.ts b/src/fiat/types.ts index 0254b67..05dc509 100644 --- a/src/fiat/types.ts +++ b/src/fiat/types.ts @@ -65,12 +65,7 @@ export interface CreateOrderResult { * the system never depends on a provider's raw status strings. */ export type NormalizedWebhookStatus = - | 'PENDING' - | 'PROCESSING' - | 'SETTLED' - | 'FAILED' - | 'REFUNDED' - | 'KYC_REQUIRED' + 'PENDING' | 'PROCESSING' | 'SETTLED' | 'FAILED' | 'REFUNDED' | 'KYC_REQUIRED' export interface ParsedWebhook { providerOrderId: string @@ -105,7 +100,10 @@ export interface FiatRampProvider { * @param rawBody The exact raw request body bytes as received. * @param headers Incoming request headers (lower-cased keys). */ - verifyWebhookSignature(rawBody: string, headers: Record): boolean + verifyWebhookSignature( + rawBody: string, + headers: Record + ): boolean /** Parse a verified webhook body into the normalized shape. */ parseWebhookPayload(rawBody: string): ParsedWebhook diff --git a/src/goals/service.ts b/src/goals/service.ts index 0b64814..9f3c6bb 100644 --- a/src/goals/service.ts +++ b/src/goals/service.ts @@ -8,46 +8,50 @@ * decisions and this endpoint's "on track" reporting never disagree. * Compounding-aware modeling is tracked separately in #225. */ -import { Prisma } from '@prisma/client'; -import db from '../db'; -import { logger } from '../utils/logger'; -import { logAgentAction } from '../agent/router'; -import { scanAllProtocols } from '../agent/scanner'; -import { applyRiskCeiling, calculateRequiredApy, calculateYearsRemaining } from '../agent/strategies'; - -type Db = typeof db | Prisma.TransactionClient; +import { Prisma } from '@prisma/client' +import db from '../db' +import { logger } from '../utils/logger' +import { logAgentAction } from '../agent/router' +import { scanAllProtocols } from '../agent/scanner' +import { + applyRiskCeiling, + calculateRequiredApy, + calculateYearsRemaining, +} from '../agent/strategies' + +type Db = typeof db | Prisma.TransactionClient export class GoalConflictError extends Error {} export class GoalNotFoundError extends Error {} export class GoalValidationError extends Error {} export interface CreateGoalInput { - targetAmount: number; - targetDate: Date; - startingAmount?: number; - positionId?: string; - riskCeiling?: number; + targetAmount: number + targetDate: Date + startingAmount?: number + positionId?: string + riskCeiling?: number } export interface UpdateGoalInput { - targetAmount?: number; - targetDate?: Date; - riskCeiling?: number; + targetAmount?: number + targetDate?: Date + riskCeiling?: number } export interface GoalProgress { - goalId: string; - status: string; - targetAmount: number; - startingAmount: number; - currentAmount: number; - targetDate: string; - requiredApy: number; - actualApy: number; - onTrack: boolean; - reachable: boolean; - projectedCompletionDate: string | null; - note?: string; + goalId: string + status: string + targetAmount: number + startingAmount: number + currentAmount: number + targetDate: string + requiredApy: number + actualApy: number + onTrack: boolean + reachable: boolean + projectedCompletionDate: string | null + note?: string } /** @@ -58,18 +62,23 @@ export interface GoalProgress { async function resolveCurrentAmount( userId: string, positionId: string | null | undefined, - database: Db, + database: Db ): Promise { if (positionId) { - const position = await (database as any).position.findUnique({ where: { id: positionId } }); - if (!position || position.userId !== userId) return 0; - return Number(position.currentValue); + const position = await (database as any).position.findUnique({ + where: { id: positionId }, + }) + if (!position || position.userId !== userId) return 0 + return Number(position.currentValue) } const positions = await (database as any).position.findMany({ where: { userId, status: 'ACTIVE' }, - }); - return positions.reduce((sum: number, p: any) => sum + Number(p.currentValue), 0); + }) + return positions.reduce( + (sum: number, p: any) => sum + Number(p.currentValue), + 0 + ) } /** @@ -85,19 +94,22 @@ async function resolveCurrentAmount( export async function createGoal( userId: string, input: CreateGoalInput, - database: Db = db, + database: Db = db ): Promise { const existingActive = await (database as any).savingsGoal.findFirst({ where: { userId, status: 'ACTIVE' }, - }); + }) if (existingActive) { - throw new GoalConflictError('An active savings goal already exists for this user'); + throw new GoalConflictError( + 'An active savings goal already exists for this user' + ) } const startingAmount = - input.startingAmount ?? (await resolveCurrentAmount(userId, input.positionId, database)); + input.startingAmount ?? + (await resolveCurrentAmount(userId, input.positionId, database)) - const status = input.targetAmount <= startingAmount ? 'ACHIEVED' : 'ACTIVE'; + const status = input.targetAmount <= startingAmount ? 'ACHIEVED' : 'ACTIVE' const goal = await (database as any).savingsGoal.create({ data: { @@ -109,11 +121,11 @@ export async function createGoal( riskCeiling: input.riskCeiling ?? null, status, }, - }); + }) - logger.info('Savings goal created', { userId, goalId: goal.id, status }); + logger.info('Savings goal created', { userId, goalId: goal.id, status }) - return goal; + return goal } /** @@ -121,20 +133,26 @@ export async function createGoal( * most recently created goal (so a just-cancelled/achieved goal is still * visible), otherwise null. */ -export async function getGoalForUser(userId: string, database: Db = db): Promise { +export async function getGoalForUser( + userId: string, + database: Db = db +): Promise { const active = await (database as any).savingsGoal.findFirst({ where: { userId, status: 'ACTIVE' }, - }); - if (active) return active; + }) + if (active) return active return (database as any).savingsGoal.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' }, - }); + }) } -export async function getGoalById(id: string, database: Db = db): Promise { - return (database as any).savingsGoal.findUnique({ where: { id } }); +export async function getGoalById( + id: string, + database: Db = db +): Promise { + return (database as any).savingsGoal.findUnique({ where: { id } }) } /** @@ -148,39 +166,49 @@ export async function getGoalById(id: string, database: Db = db): Promise { - const goal = await getGoalById(id, database); +export async function updateGoal( + id: string, + updates: UpdateGoalInput, + database: Db = db +): Promise { + const goal = await getGoalById(id, database) if (!goal) { - throw new GoalNotFoundError('Savings goal not found'); + throw new GoalNotFoundError('Savings goal not found') } if (goal.status !== 'ACTIVE') { - throw new GoalValidationError('Only an ACTIVE goal can be updated'); + throw new GoalValidationError('Only an ACTIVE goal can be updated') } return (database as any).savingsGoal.update({ where: { id }, data: { - ...(updates.targetAmount !== undefined ? { targetAmount: updates.targetAmount } : {}), - ...(updates.targetDate !== undefined ? { targetDate: updates.targetDate } : {}), - ...(updates.riskCeiling !== undefined ? { riskCeiling: updates.riskCeiling } : {}), + ...(updates.targetAmount !== undefined + ? { targetAmount: updates.targetAmount } + : {}), + ...(updates.targetDate !== undefined + ? { targetDate: updates.targetDate } + : {}), + ...(updates.riskCeiling !== undefined + ? { riskCeiling: updates.riskCeiling } + : {}), }, - }); + }) } /** Soft-cancel: sets status = CANCELLED, never hard-deletes. */ export async function cancelGoal(id: string, database: Db = db): Promise { - const goal = await getGoalById(id, database); + const goal = await getGoalById(id, database) if (!goal) { - throw new GoalNotFoundError('Savings goal not found'); + throw new GoalNotFoundError('Savings goal not found') } if (goal.status !== 'ACTIVE') { - return goal; + return goal } return (database as any).savingsGoal.update({ where: { id }, data: { status: 'CANCELLED' }, - }); + }) } /** @@ -190,14 +218,15 @@ export async function cancelGoal(id: string, database: Db = db): Promise { * getting". */ async function resolveActualApy(userId: string, database: Db): Promise { - const fromDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const fromDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) const snapshots = await (database as any).yieldSnapshot.findMany({ where: { position: { is: { userId } }, snapshotAt: { gte: fromDate } }, - }); - if (snapshots.length === 0) return 0; + }) + if (snapshots.length === 0) return 0 return ( - snapshots.reduce((sum: number, s: any) => sum + Number(s.apy), 0) / snapshots.length - ); + snapshots.reduce((sum: number, s: any) => sum + Number(s.apy), 0) / + snapshots.length + ) } /** @@ -208,27 +237,33 @@ async function resolveActualApy(userId: string, database: Db): Promise { */ async function resolveReachability( requiredApy: number, - riskCeiling: number | null, + riskCeiling: number | null ): Promise<{ reachable: boolean; maxEligibleApy: number }> { - if (requiredApy <= 0) return { reachable: true, maxEligibleApy: requiredApy }; + if (requiredApy <= 0) return { reachable: true, maxEligibleApy: requiredApy } - const allProtocols = await scanAllProtocols(); + const allProtocols = await scanAllProtocols() if (allProtocols.length === 0) { - return { reachable: false, maxEligibleApy: 0 }; + return { reachable: false, maxEligibleApy: 0 } } - let eligible = allProtocols; + let eligible = allProtocols if (riskCeiling !== null && riskCeiling !== undefined) { - const scores: Record = {}; - const riskRows = await db.protocolRiskScore.findMany({ select: { protocolName: true, score: true } }); - for (const row of riskRows as Array<{ protocolName: string; score: number }>) { - scores[row.protocolName] = row.score; + const scores: Record = {} + const riskRows = await db.protocolRiskScore.findMany({ + select: { protocolName: true, score: true }, + }) + for (const row of riskRows as Array<{ + protocolName: string + score: number + }>) { + scores[row.protocolName] = row.score } - eligible = applyRiskCeiling(allProtocols, riskCeiling, scores); + eligible = applyRiskCeiling(allProtocols, riskCeiling, scores) } - const maxEligibleApy = eligible.length > 0 ? Math.max(...eligible.map((p) => p.apy)) : 0; - return { reachable: requiredApy <= maxEligibleApy, maxEligibleApy }; + const maxEligibleApy = + eligible.length > 0 ? Math.max(...eligible.map((p) => p.apy)) : 0 + return { reachable: requiredApy <= maxEligibleApy, maxEligibleApy } } /** @@ -244,22 +279,29 @@ async function resolveReachability( * linked position has disappeared or is no longer ACTIVE, the goal is * cancelled on next read rather than left dangling as ACTIVE. */ -export async function computeGoalProgress(goalId: string, database: Db = db): Promise { - const goal = await getGoalById(goalId, database); +export async function computeGoalProgress( + goalId: string, + database: Db = db +): Promise { + const goal = await getGoalById(goalId, database) if (!goal) { - throw new GoalNotFoundError('Savings goal not found'); + throw new GoalNotFoundError('Savings goal not found') } - const targetAmount = Number(goal.targetAmount); - const startingAmount = Number(goal.startingAmount); + const targetAmount = Number(goal.targetAmount) + const startingAmount = Number(goal.startingAmount) const requiredApy = calculateRequiredApy( startingAmount, targetAmount, - calculateYearsRemaining(goal.targetDate), - ); + calculateYearsRemaining(goal.targetDate) + ) if (goal.status !== 'ACTIVE') { - const currentAmount = await resolveCurrentAmount(goal.userId, goal.positionId, database); + const currentAmount = await resolveCurrentAmount( + goal.userId, + goal.positionId, + database + ) return { goalId: goal.id, status: goal.status, @@ -272,37 +314,52 @@ export async function computeGoalProgress(goalId: string, database: Db = db): Pr onTrack: goal.status === 'ACHIEVED', reachable: goal.status === 'ACHIEVED', projectedCompletionDate: null, - }; + } } if (goal.positionId) { - const position = await (database as any).position.findUnique({ where: { id: goal.positionId } }); + const position = await (database as any).position.findUnique({ + where: { id: goal.positionId }, + }) if (!position || position.status !== 'ACTIVE') { - await (database as any).savingsGoal.update({ where: { id: goal.id }, data: { status: 'CANCELLED' } }); + await (database as any).savingsGoal.update({ + where: { id: goal.id }, + data: { status: 'CANCELLED' }, + }) await logAgentAction( 'GOAL_PROGRESS', 'SKIPPED', { reasoning: 'Linked position is no longer active — goal cancelled' }, goal.userId, - goal.positionId, - ); - return computeGoalProgress(goalId, database); + goal.positionId + ) + return computeGoalProgress(goalId, database) } } - const currentAmount = await resolveCurrentAmount(goal.userId, goal.positionId, database); - const actualApy = await resolveActualApy(goal.userId, database); - const yearsRemaining = calculateYearsRemaining(goal.targetDate); + const currentAmount = await resolveCurrentAmount( + goal.userId, + goal.positionId, + database + ) + const actualApy = await resolveActualApy(goal.userId, database) + const yearsRemaining = calculateYearsRemaining(goal.targetDate) if (currentAmount >= targetAmount) { - await (database as any).savingsGoal.update({ where: { id: goal.id }, data: { status: 'ACHIEVED' } }); + await (database as any).savingsGoal.update({ + where: { id: goal.id }, + data: { status: 'ACHIEVED' }, + }) await logAgentAction( 'GOAL_PROGRESS', 'SUCCESS', - { reasoning: 'Savings goal achieved', outputData: { currentAmount, targetAmount } }, + { + reasoning: 'Savings goal achieved', + outputData: { currentAmount, targetAmount }, + }, goal.userId, - goal.positionId ?? undefined, - ); + goal.positionId ?? undefined + ) return { goalId: goal.id, status: 'ACHIEVED', @@ -315,18 +372,24 @@ export async function computeGoalProgress(goalId: string, database: Db = db): Pr onTrack: true, reachable: true, projectedCompletionDate: new Date().toISOString(), - }; + } } if (yearsRemaining <= 0) { - await (database as any).savingsGoal.update({ where: { id: goal.id }, data: { status: 'MISSED' } }); + await (database as any).savingsGoal.update({ + where: { id: goal.id }, + data: { status: 'MISSED' }, + }) await logAgentAction( 'GOAL_PROGRESS', 'FAILED', - { reasoning: 'Savings goal target date passed without being met', outputData: { currentAmount, targetAmount } }, + { + reasoning: 'Savings goal target date passed without being met', + outputData: { currentAmount, targetAmount }, + }, goal.userId, - goal.positionId ?? undefined, - ); + goal.positionId ?? undefined + ) return { goalId: goal.id, status: 'MISSED', @@ -339,18 +402,21 @@ export async function computeGoalProgress(goalId: string, database: Db = db): Pr onTrack: false, reachable: false, projectedCompletionDate: null, - }; + } } - const { reachable } = await resolveReachability(requiredApy, goal.riskCeiling); - const onTrack = actualApy >= requiredApy; + const { reachable } = await resolveReachability(requiredApy, goal.riskCeiling) + const onTrack = actualApy >= requiredApy - let projectedCompletionDate: string | null = null; + let projectedCompletionDate: string | null = null if (actualApy > 0 && currentAmount > 0 && currentAmount < targetAmount) { - const yearsToComplete = (targetAmount - currentAmount) / currentAmount / (actualApy / 100); - const projected = new Date(); - projected.setDate(projected.getDate() + Math.round(yearsToComplete * 365.25)); - projectedCompletionDate = projected.toISOString(); + const yearsToComplete = + (targetAmount - currentAmount) / currentAmount / (actualApy / 100) + const projected = new Date() + projected.setDate( + projected.getDate() + Math.round(yearsToComplete * 365.25) + ) + projectedCompletionDate = projected.toISOString() } await logAgentAction( @@ -358,13 +424,22 @@ export async function computeGoalProgress(goalId: string, database: Db = db): Pr 'SUCCESS', { reasoning: reachable - ? (onTrack ? 'On track toward savings goal' : 'Behind schedule but still reachable within risk tolerance') + ? onTrack + ? 'On track toward savings goal' + : 'Behind schedule but still reachable within risk tolerance' : 'Target not reachable within your risk tolerance', - outputData: { currentAmount, targetAmount, requiredApy, actualApy, reachable, onTrack }, + outputData: { + currentAmount, + targetAmount, + requiredApy, + actualApy, + reachable, + onTrack, + }, }, goal.userId, - goal.positionId ?? undefined, - ); + goal.positionId ?? undefined + ) return { goalId: goal.id, @@ -378,6 +453,8 @@ export async function computeGoalProgress(goalId: string, database: Db = db): Pr onTrack, reachable, projectedCompletionDate, - note: reachable ? undefined : 'Target not reachable within your risk tolerance', - }; + note: reachable + ? undefined + : 'Target not reachable within your risk tolerance', + } } diff --git a/src/index.ts b/src/index.ts index f927e35..c916d37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -152,6 +152,14 @@ app.use(trustedIpBypass) app.use(rateLimiter) app.use(requestTimeoutMiddleware) +// Advertise the served API version on every response — must be registered +// before the first route (including the health probes below). +const API_VERSION = '1' +app.use((_req: Request, res: Response, next) => { + res.setHeader('X-API-Version', API_VERSION) + next() +}) + // ── Readiness / liveness probes ─────────────────────────────────────────────── app.get('/health/live', (_req, res) => { @@ -189,19 +197,11 @@ app.get('/health/ready', (_req, res) => { // existing clients keep working; they emit RFC 8594 Deprecation/Sunset headers // announcing the removal date. See docs/api-versioning.md for the policy. -const API_VERSION = '1' - // Unversioned routes are supported for at least 6 months from this release. const UNVERSIONED_SUNSET = new Date( Date.now() + 182 * 24 * 60 * 60 * 1000 ).toUTCString() -// Advertise the served API version on every response. -app.use((_req: Request, res: Response, next) => { - res.setHeader('X-API-Version', API_VERSION) - next() -}) - // ── OpenAPI / Swagger UI ────────────────────────────────────────────────────── let swaggerSpec: Record | null = null @@ -278,17 +278,6 @@ const apiRoutes: ApiRoute[] = [ // ── Application routes ──────────────────────────────────────────────────────── app.use('/health', healthRouter) -app.use('/api/agent', internalRateLimiter, agentRouter) -app.use('/api/auth', authRateLimiter, authRouter) -app.use('/api/whatsapp', webhookRateLimiter, whatsappRouter) -app.use('/api/portfolio', portfolioRouter) -app.use('/api/transactions', transactionsRouter) -app.use('/api/protocols', protocolsRouter) -app.use('/api/deposit', depositRouter) -app.use('/api/withdraw', withdrawRouter) -app.use('/api/vault', vaultRouter) -app.use('/api/analytics', analyticsRouter) -app.use('/api/stellar', stellarRouter) app.use('/api/webhooks', webhooksRouter) app.use('/metrics', metricsRouter) diff --git a/src/jobs/dataRetention.ts b/src/jobs/dataRetention.ts index b4482e7..fd1be1f 100644 --- a/src/jobs/dataRetention.ts +++ b/src/jobs/dataRetention.ts @@ -1,97 +1,102 @@ -import db from '../db'; -import { logger, logBackgroundJob } from '../utils/logger'; -import { generateCorrelationId, runWithCorrelationIdAsync } from '../utils/correlation'; -import { config } from '../config/env'; -import { recordBackgroundJob, recordRetentionDeletes } from '../utils/metrics'; -import { recordJobSuccess, recordJobFailure } from '../utils/job-metrics'; +import db from '../db' +import { logger, logBackgroundJob } from '../utils/logger' +import { + generateCorrelationId, + runWithCorrelationIdAsync, +} from '../utils/correlation' +import { config } from '../config/env' +import { recordBackgroundJob, recordRetentionDeletes } from '../utils/metrics' +import { recordJobSuccess, recordJobFailure } from '../utils/job-metrics' function cutoffDate(retentionDays: number): Date { - const d = new Date(); - d.setDate(d.getDate() - retentionDays); - return d; + const d = new Date() + d.setDate(d.getDate() - retentionDays) + return d } /** * Delete expired auth_nonces (expiresAt < now). */ export async function cleanupAuthNonces(): Promise { - const correlationId = generateCorrelationId(); + const correlationId = generateCorrelationId() return runWithCorrelationIdAsync(correlationId, async () => { - const start = Date.now(); - const jobName = 'retention_auth_nonces'; + const start = Date.now() + const jobName = 'retention_auth_nonces' try { const result = await db.authNonce.deleteMany({ where: { expiresAt: { lt: new Date() } }, - }); - const durationMs = Date.now() - start; - const duration = durationMs / 1000; + }) + const durationMs = Date.now() - start + const duration = durationMs / 1000 logBackgroundJob(jobName, 'success', duration, correlationId, { rowsDeleted: result.count, - }); + }) if (result.count > 0) { - recordRetentionDeletes('auth_nonces', result.count); + recordRetentionDeletes('auth_nonces', result.count) } - recordBackgroundJob(jobName, 'success', duration); - recordJobSuccess(jobName, durationMs); + recordBackgroundJob(jobName, 'success', duration) + recordJobSuccess(jobName, durationMs) } catch (error) { - const durationMs = Date.now() - start; - const duration = durationMs / 1000; - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const durationMs = Date.now() - start + const duration = durationMs / 1000 + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' logBackgroundJob(jobName, 'failed', duration, correlationId, { error: errorMessage, - }); + }) - recordBackgroundJob(jobName, 'failed', duration); - recordJobFailure(jobName, durationMs); + recordBackgroundJob(jobName, 'failed', duration) + recordJobFailure(jobName, durationMs) } - }); + }) } /** * Prune processed_events older than RETENTION_PROCESSED_EVENTS_DAYS (default 90). */ export async function cleanupProcessedEvents(): Promise { - const correlationId = generateCorrelationId(); + const correlationId = generateCorrelationId() return runWithCorrelationIdAsync(correlationId, async () => { - const start = Date.now(); - const jobName = 'retention_processed_events'; + const start = Date.now() + const jobName = 'retention_processed_events' try { - const cutoff = cutoffDate(config.retention.processedEventsDays); + const cutoff = cutoffDate(config.retention.processedEventsDays) const result = await db.processedEvent.deleteMany({ where: { processedAt: { lt: cutoff } }, - }); - const durationMs = Date.now() - start; - const duration = durationMs / 1000; + }) + const durationMs = Date.now() - start + const duration = durationMs / 1000 logBackgroundJob(jobName, 'success', duration, correlationId, { rowsDeleted: result.count, retentionDays: config.retention.processedEventsDays, - }); + }) if (result.count > 0) { - recordRetentionDeletes('processed_events', result.count); + recordRetentionDeletes('processed_events', result.count) } - recordBackgroundJob(jobName, 'success', duration); - recordJobSuccess(jobName, durationMs); + recordBackgroundJob(jobName, 'success', duration) + recordJobSuccess(jobName, durationMs) } catch (error) { - const durationMs = Date.now() - start; - const duration = durationMs / 1000; - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const durationMs = Date.now() - start + const duration = durationMs / 1000 + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' logBackgroundJob(jobName, 'failed', duration, correlationId, { error: errorMessage, retentionDays: config.retention.processedEventsDays, - }); + }) - recordBackgroundJob(jobName, 'failed', duration); - recordJobFailure(jobName, durationMs); + recordBackgroundJob(jobName, 'failed', duration) + recordJobFailure(jobName, durationMs) } - }); + }) } /** @@ -99,121 +104,126 @@ export async function cleanupProcessedEvents(): Promise { * PENDING and RETRIED records are left untouched so they remain actionable. */ export async function cleanupDeadLetterEvents(): Promise { - const correlationId = generateCorrelationId(); + const correlationId = generateCorrelationId() return runWithCorrelationIdAsync(correlationId, async () => { - const start = Date.now(); - const jobName = 'retention_dead_letter_events'; + const start = Date.now() + const jobName = 'retention_dead_letter_events' try { - const cutoff = cutoffDate(config.retention.deadLetterEventsDays); + const cutoff = cutoffDate(config.retention.deadLetterEventsDays) const result = await db.deadLetterEvent.deleteMany({ where: { status: 'RESOLVED', createdAt: { lt: cutoff }, }, - }); - const durationMs = Date.now() - start; - const duration = durationMs / 1000; + }) + const durationMs = Date.now() - start + const duration = durationMs / 1000 logBackgroundJob(jobName, 'success', duration, correlationId, { rowsDeleted: result.count, eventStatus: 'RESOLVED', retentionDays: config.retention.deadLetterEventsDays, - }); + }) if (result.count > 0) { - recordRetentionDeletes('dead_letter_events', result.count); + recordRetentionDeletes('dead_letter_events', result.count) } - recordBackgroundJob(jobName, 'success', duration); - recordJobSuccess(jobName, durationMs); + recordBackgroundJob(jobName, 'success', duration) + recordJobSuccess(jobName, durationMs) } catch (error) { - const durationMs = Date.now() - start; - const duration = durationMs / 1000; - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const durationMs = Date.now() - start + const duration = durationMs / 1000 + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' logBackgroundJob(jobName, 'failed', duration, correlationId, { error: errorMessage, eventStatus: 'RESOLVED', retentionDays: config.retention.deadLetterEventsDays, - }); + }) - recordBackgroundJob(jobName, 'failed', duration); - recordJobFailure(jobName, durationMs); + recordBackgroundJob(jobName, 'failed', duration) + recordJobFailure(jobName, durationMs) } - }); + }) } /** * Prune agent_logs older than RETENTION_AGENT_LOGS_DAYS (default 60). */ export async function cleanupAgentLogs(): Promise { - const correlationId = generateCorrelationId(); + const correlationId = generateCorrelationId() return runWithCorrelationIdAsync(correlationId, async () => { - const start = Date.now(); - const jobName = 'retention_agent_logs'; + const start = Date.now() + const jobName = 'retention_agent_logs' try { - const cutoff = cutoffDate(config.retention.agentLogsDays); + const cutoff = cutoffDate(config.retention.agentLogsDays) const result = await db.agentLog.deleteMany({ where: { createdAt: { lt: cutoff } }, - }); - const durationMs = Date.now() - start; - const duration = durationMs / 1000; + }) + const durationMs = Date.now() - start + const duration = durationMs / 1000 logBackgroundJob(jobName, 'success', duration, correlationId, { rowsDeleted: result.count, retentionDays: config.retention.agentLogsDays, - }); + }) if (result.count > 0) { - recordRetentionDeletes('agent_logs', result.count); + recordRetentionDeletes('agent_logs', result.count) } - recordBackgroundJob(jobName, 'success', duration); - recordJobSuccess(jobName, durationMs); + recordBackgroundJob(jobName, 'success', duration) + recordJobSuccess(jobName, durationMs) } catch (error) { - const durationMs = Date.now() - start; - const duration = durationMs / 1000; - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const durationMs = Date.now() - start + const duration = durationMs / 1000 + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' logBackgroundJob(jobName, 'failed', duration, correlationId, { error: errorMessage, retentionDays: config.retention.agentLogsDays, - }); + }) - recordBackgroundJob(jobName, 'failed', duration); - recordJobFailure(jobName, durationMs); + recordBackgroundJob(jobName, 'failed', duration) + recordJobFailure(jobName, durationMs) } - }); + }) } /** * Run all retention jobs sequentially. */ export async function runAllRetentionJobs(): Promise { - const correlationId = generateCorrelationId(); + const correlationId = generateCorrelationId() await runWithCorrelationIdAsync(correlationId, async () => { - const startTime = Date.now(); - const jobName = 'retention_all_jobs'; + const startTime = Date.now() + const jobName = 'retention_all_jobs' - logger.info(`[${jobName}] Starting all retention cleanup jobs`, { correlationId }); + logger.info(`[${jobName}] Starting all retention cleanup jobs`, { + correlationId, + }) try { - await cleanupAuthNonces(); - await cleanupProcessedEvents(); - await cleanupDeadLetterEvents(); - await cleanupAgentLogs(); + await cleanupAuthNonces() + await cleanupProcessedEvents() + await cleanupDeadLetterEvents() + await cleanupAgentLogs() - const duration = (Date.now() - startTime) / 1000; - logBackgroundJob(jobName, 'success', duration, correlationId); + const duration = (Date.now() - startTime) / 1000 + logBackgroundJob(jobName, 'success', duration, correlationId) } catch (error) { - const duration = (Date.now() - startTime) / 1000; - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const duration = (Date.now() - startTime) / 1000 + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' logBackgroundJob(jobName, 'failed', duration, correlationId, { error: errorMessage, - }); + }) } - }); + }) } /** @@ -223,13 +233,13 @@ export async function runAllRetentionJobs(): Promise { * @returns NodeJS.Timeout handle — pass to clearInterval() on shutdown. */ export function scheduleDataRetention(): NodeJS.Timeout { - runAllRetentionJobs(); - const handle = setInterval(runAllRetentionJobs, config.retention.intervalMs); + runAllRetentionJobs() + const handle = setInterval(runAllRetentionJobs, config.retention.intervalMs) logger.info( `[DataRetention] Retention jobs scheduled every ${config.retention.intervalMs / 3600000}h` + - ` (processed_events=${config.retention.processedEventsDays}d,` + - ` dlq=${config.retention.deadLetterEventsDays}d,` + - ` agent_logs=${config.retention.agentLogsDays}d)`, - ); - return handle; + ` (processed_events=${config.retention.processedEventsDays}d,` + + ` dlq=${config.retention.deadLetterEventsDays}d,` + + ` agent_logs=${config.retention.agentLogsDays}d)` + ) + return handle } diff --git a/src/jobs/fiatReconciliation.ts b/src/jobs/fiatReconciliation.ts index b99395a..307a15b 100644 --- a/src/jobs/fiatReconciliation.ts +++ b/src/jobs/fiatReconciliation.ts @@ -1,12 +1,15 @@ import { logger, logBackgroundJob } from '../utils/logger' -import { generateCorrelationId, runWithCorrelationIdAsync } from '../utils/correlation' +import { + generateCorrelationId, + runWithCorrelationIdAsync, +} from '../utils/correlation' import { recordBackgroundJob } from '../utils/metrics' import { recordJobSuccess, recordJobFailure } from '../utils/job-metrics' import { reconcileFiatOrders, ageOutStaleFiatOrders } from '../fiat/service' /** Interval between reconciliation sweeps (default: 5 minutes). */ const FIAT_RECONCILE_INTERVAL_MS = Number( - process.env.FIAT_RECONCILE_INTERVAL_MS || 5 * 60 * 1000, + process.env.FIAT_RECONCILE_INTERVAL_MS || 5 * 60 * 1000 ) /** @@ -37,7 +40,8 @@ export async function runFiatReconciliation(): Promise { } catch (error) { const durationMs = Date.now() - startTime const duration = durationMs / 1000 - const errorMessage = error instanceof Error ? error.message : 'Unknown error' + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' logBackgroundJob(jobName, 'failed', duration, correlationId, { error: errorMessage, diff --git a/src/jobs/poolMetrics.ts b/src/jobs/poolMetrics.ts index c5b3c37..5a02847 100644 --- a/src/jobs/poolMetrics.ts +++ b/src/jobs/poolMetrics.ts @@ -30,14 +30,20 @@ interface PrismaMetric { interface PrismaMetricsJson { counters: PrismaMetric[] gauges: PrismaMetric[] - histograms: PrismaMetric<{ buckets: [number, number][]; sum: number; count: number }>[] + histograms: PrismaMetric<{ + buckets: [number, number][] + sum: number + count: number + }>[] } type MetricsCapableClient = { $metrics?: { json: () => Promise } } -function hasMetricsApi(client: unknown): client is Required { +function hasMetricsApi( + client: unknown +): client is Required { const candidate = client as MetricsCapableClient return typeof candidate.$metrics?.json === 'function' } @@ -63,7 +69,9 @@ export async function collectPoolMetrics(): Promise { dbPoolActive.set(gauge('prisma_pool_connections_busy')) dbPoolIdle.set(gauge('prisma_pool_connections_idle')) dbPoolWaitCount.set(gauge('prisma_client_queries_wait')) - dbPoolWaitDurationMs.set(histogramSum('prisma_client_queries_wait_histogram_ms')) + dbPoolWaitDurationMs.set( + histogramSum('prisma_client_queries_wait_histogram_ms') + ) } catch (error) { logger.warn('[PoolMetrics] Failed to collect Prisma pool metrics', { error: error instanceof Error ? error.message : String(error), @@ -89,6 +97,8 @@ export function schedulePoolMetrics(): NodeJS.Timeout { // Don't keep the event loop alive solely for metrics polling handle.unref?.() - logger.info(`[PoolMetrics] Prisma pool metrics polling scheduled (every ${intervalMs}ms)`) + logger.info( + `[PoolMetrics] Prisma pool metrics polling scheduled (every ${intervalMs}ms)` + ) return handle } diff --git a/src/jobs/protocolRiskScoring.ts b/src/jobs/protocolRiskScoring.ts index 9cc4919..981ee6d 100644 --- a/src/jobs/protocolRiskScoring.ts +++ b/src/jobs/protocolRiskScoring.ts @@ -1,10 +1,13 @@ -import db from '../db'; -import { logger, logBackgroundJob } from '../utils/logger'; -import { generateCorrelationId, runWithCorrelationIdAsync } from '../utils/correlation'; -import { config } from '../config/env'; -import { recordJobSuccess, recordJobFailure } from '../utils/job-metrics'; -import { computeRiskScore, RateSample } from '../agent/riskScoring'; -import { PROTOCOL_RISK_METADATA } from '../config/protocolRiskMetadata'; +import db from '../db' +import { logger, logBackgroundJob } from '../utils/logger' +import { + generateCorrelationId, + runWithCorrelationIdAsync, +} from '../utils/correlation' +import { config } from '../config/env' +import { recordJobSuccess, recordJobFailure } from '../utils/job-metrics' +import { computeRiskScore, RateSample } from '../agent/riskScoring' +import { PROTOCOL_RISK_METADATA } from '../config/protocolRiskMetadata' /** * Protocol risk scoring job. @@ -19,41 +22,43 @@ import { PROTOCOL_RISK_METADATA } from '../config/protocolRiskMetadata'; * protocol still gets a (conservative) score, and a scanned-but-uncurated one is * scored with the conservative UNAUDITED/unknown-age default. */ -export async function computeProtocolRiskScores(now: Date = new Date()): Promise { - const correlationId = generateCorrelationId(); +export async function computeProtocolRiskScores( + now: Date = new Date() +): Promise { + const correlationId = generateCorrelationId() return runWithCorrelationIdAsync(correlationId, async () => { - const start = Date.now(); - const jobName = 'protocol_risk_scoring'; + const start = Date.now() + const jobName = 'protocol_risk_scoring' try { // Distinct protocol names from rate history… const rateProtocols = await db.protocolRate.findMany({ distinct: ['protocolName'], select: { protocolName: true }, - }); + }) const protocolNames = new Set([ ...rateProtocols.map((r: { protocolName: string }) => r.protocolName), ...PROTOCOL_RISK_METADATA.map((m) => m.protocolName), - ]); + ]) - let scored = 0; + let scored = 0 for (const protocolName of protocolNames) { const rates = await db.protocolRate.findMany({ where: { protocolName }, orderBy: { fetchedAt: 'asc' }, select: { supplyApy: true, tvl: true, fetchedAt: true }, - }); + }) const samples: RateSample[] = rates.map( (r: { supplyApy: unknown; tvl: unknown; fetchedAt: Date }) => ({ supplyApy: Number(r.supplyApy), tvl: r.tvl === null || r.tvl === undefined ? null : Number(r.tvl), fetchedAt: r.fetchedAt, - }), - ); + }) + ) - const result = computeRiskScore(protocolName, samples, now); + const result = computeRiskScore(protocolName, samples, now) await db.protocolRiskScore.upsert({ where: { protocolName }, @@ -78,25 +83,26 @@ export async function computeProtocolRiskScores(now: Date = new Date()): Promise sampleCount: result.sampleCount, computedAt: now, }, - }); - scored++; + }) + scored++ } - const durationMs = Date.now() - start; + const durationMs = Date.now() - start logBackgroundJob(jobName, 'success', durationMs / 1000, correlationId, { protocolsScored: scored, - }); - recordJobSuccess(jobName, durationMs); + }) + recordJobSuccess(jobName, durationMs) } catch (error) { - const durationMs = Date.now() - start; - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const durationMs = Date.now() - start + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' logBackgroundJob(jobName, 'failed', durationMs / 1000, correlationId, { error: errorMessage, - }); - recordJobFailure(jobName, durationMs); + }) + recordJobFailure(jobName, durationMs) } - }); + }) } /** @@ -106,17 +112,17 @@ export async function computeProtocolRiskScores(now: Date = new Date()): Promise * @returns NodeJS.Timeout handle — pass to clearInterval() on shutdown. */ export function scheduleProtocolRiskScoring(): NodeJS.Timeout { - void computeProtocolRiskScores(); + void computeProtocolRiskScores() - const intervalMs = config.protocolRisk.intervalMs; + const intervalMs = config.protocolRisk.intervalMs const handle = setInterval(() => { - void computeProtocolRiskScores(); - }, intervalMs); + void computeProtocolRiskScores() + }, intervalMs) - handle.unref?.(); + handle.unref?.() logger.info( - `[ProtocolRiskScoring] Risk scoring scheduled every ${intervalMs / 3600000}h`, - ); - return handle; + `[ProtocolRiskScoring] Risk scoring scheduled every ${intervalMs / 3600000}h` + ) + return handle } diff --git a/src/jobs/sessionCleanup.ts b/src/jobs/sessionCleanup.ts index e25917e..6626ef7 100644 --- a/src/jobs/sessionCleanup.ts +++ b/src/jobs/sessionCleanup.ts @@ -1,46 +1,50 @@ -import db from '../db'; -import { logger, logBackgroundJob } from '../utils/logger'; -import { generateCorrelationId, runWithCorrelationIdAsync } from '../utils/correlation'; -import { config } from '../config/env'; -import { recordBackgroundJob } from '../utils/metrics'; -import { recordJobSuccess, recordJobFailure } from '../utils/job-metrics'; +import db from '../db' +import { logger, logBackgroundJob } from '../utils/logger' +import { + generateCorrelationId, + runWithCorrelationIdAsync, +} from '../utils/correlation' +import { config } from '../config/env' +import { recordBackgroundJob } from '../utils/metrics' +import { recordJobSuccess, recordJobFailure } from '../utils/job-metrics' /** * Delete all sessions whose expiration timestamp is in the past. * Safe to call multiple times — it is idempotent. */ export async function cleanupExpiredSessions(): Promise { - const correlationId = generateCorrelationId(); + const correlationId = generateCorrelationId() return runWithCorrelationIdAsync(correlationId, async () => { - const startTime = Date.now(); - const jobName = 'session_cleanup'; + const startTime = Date.now() + const jobName = 'session_cleanup' try { const result = await db.session.deleteMany({ where: { expiresAt: { lt: new Date() } }, - }); - const durationMs = Date.now() - startTime; - const duration = durationMs / 1000; + }) + const durationMs = Date.now() - startTime + const duration = durationMs / 1000 logBackgroundJob(jobName, 'success', duration, correlationId, { rowsDeleted: result.count, - }); + }) - recordBackgroundJob(jobName, 'success', duration); - recordJobSuccess(jobName, durationMs); + recordBackgroundJob(jobName, 'success', duration) + recordJobSuccess(jobName, durationMs) } catch (error) { - const durationMs = Date.now() - startTime; - const duration = durationMs / 1000; - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const durationMs = Date.now() - startTime + const duration = durationMs / 1000 + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' logBackgroundJob(jobName, 'failed', duration, correlationId, { error: errorMessage, - }); + }) - recordBackgroundJob(jobName, 'failed', duration); - recordJobFailure(jobName, durationMs); + recordBackgroundJob(jobName, 'failed', duration) + recordJobFailure(jobName, durationMs) } - }); + }) } /** @@ -52,11 +56,11 @@ export async function cleanupExpiredSessions(): Promise { */ export function scheduleSessionCleanup(): NodeJS.Timeout { // Run once at startup - cleanupExpiredSessions(); + cleanupExpiredSessions() // Then run every 24 hours - const handle = setInterval(cleanupExpiredSessions, config.jwt.interval_ms); + const handle = setInterval(cleanupExpiredSessions, config.jwt.interval_ms) - logger.info('[SessionCleanup] Daily cleanup scheduled'); - return handle; + logger.info('[SessionCleanup] Daily cleanup scheduled') + return handle } diff --git a/src/middleware/authGuard.ts b/src/middleware/authGuard.ts index eddf283..7107cb9 100644 --- a/src/middleware/authGuard.ts +++ b/src/middleware/authGuard.ts @@ -1,7 +1,7 @@ /** * Internal endpoints authentication guard * Protects /metrics and /api/agent/status endpoints - * + * * Allows access via: * 1. X-Internal-Token header matching INTERNAL_SERVICE_TOKEN * 2. IP allowlist from INTERNAL_IP_WHITELIST @@ -17,7 +17,12 @@ import { logger } from '../utils/logger' function parseIpWhitelist(): Set { const whitelist = process.env.INTERNAL_IP_WHITELIST || '' if (!whitelist.trim()) return new Set() - return new Set(whitelist.split(',').map(ip => ip.trim()).filter(Boolean)) + return new Set( + whitelist + .split(',') + .map((ip) => ip.trim()) + .filter(Boolean) + ) } /** @@ -31,25 +36,39 @@ function getClientIp(req: Request): string { /** * Middleware to protect internal endpoints */ -export function internalAuthGuard(req: Request, res: Response, next: NextFunction): void { +export function internalAuthGuard( + req: Request, + res: Response, + next: NextFunction +): void { const clientIp = getClientIp(req) const internalToken = req.headers['x-internal-token'] as string | undefined - const adminToken = req.headers['authorization']?.replace('Bearer ', '') as string | undefined + const adminToken = req.headers['authorization']?.replace('Bearer ', '') as + string | undefined // Check 1: X-Internal-Token header if (internalToken && process.env.INTERNAL_SERVICE_TOKEN) { if (internalToken === process.env.INTERNAL_SERVICE_TOKEN) { - logger.info('[AuthGuard] Internal token accepted', { clientIp, endpoint: req.path }) + logger.info('[AuthGuard] Internal token accepted', { + clientIp, + endpoint: req.path, + }) next() return } - logger.warn('[AuthGuard] Invalid internal token attempted', { clientIp, endpoint: req.path }) + logger.warn('[AuthGuard] Invalid internal token attempted', { + clientIp, + endpoint: req.path, + }) } // Check 2: IP allowlist const ipWhitelist = parseIpWhitelist() if (ipWhitelist.size > 0 && ipWhitelist.has(clientIp)) { - logger.info('[AuthGuard] IP allowlist match', { clientIp, endpoint: req.path }) + logger.info('[AuthGuard] IP allowlist match', { + clientIp, + endpoint: req.path, + }) next() return } @@ -57,11 +76,17 @@ export function internalAuthGuard(req: Request, res: Response, next: NextFunctio // Check 3: Admin token if (adminToken && process.env.ADMIN_API_TOKEN) { if (adminToken === process.env.ADMIN_API_TOKEN) { - logger.info('[AuthGuard] Admin token accepted for internal endpoint', { clientIp, endpoint: req.path }) + logger.info('[AuthGuard] Admin token accepted for internal endpoint', { + clientIp, + endpoint: req.path, + }) next() return } - logger.warn('[AuthGuard] Invalid admin token attempted on internal endpoint', { clientIp, endpoint: req.path }) + logger.warn( + '[AuthGuard] Invalid admin token attempted on internal endpoint', + { clientIp, endpoint: req.path } + ) } // Reject: no valid auth method @@ -85,15 +110,23 @@ export function internalAuthGuard(req: Request, res: Response, next: NextFunctio * Variant that returns 404 instead of 403 (full info hiding) * Use for endpoints you want to pretend don't exist */ -export function internalAuthGuardStrict(req: Request, res: Response, next: NextFunction): void { +export function internalAuthGuardStrict( + req: Request, + res: Response, + next: NextFunction +): void { const clientIp = getClientIp(req) const internalToken = req.headers['x-internal-token'] as string | undefined - const adminToken = req.headers['authorization']?.replace('Bearer ', '') as string | undefined + const adminToken = req.headers['authorization']?.replace('Bearer ', '') as + string | undefined // Check 1: X-Internal-Token header if (internalToken && process.env.INTERNAL_SERVICE_TOKEN) { if (internalToken === process.env.INTERNAL_SERVICE_TOKEN) { - logger.info('[AuthGuard-Strict] Internal token accepted', { clientIp, endpoint: req.path }) + logger.info('[AuthGuard-Strict] Internal token accepted', { + clientIp, + endpoint: req.path, + }) next() return } @@ -102,7 +135,10 @@ export function internalAuthGuardStrict(req: Request, res: Response, next: NextF // Check 2: IP allowlist const ipWhitelist = parseIpWhitelist() if (ipWhitelist.size > 0 && ipWhitelist.has(clientIp)) { - logger.info('[AuthGuard-Strict] IP allowlist match', { clientIp, endpoint: req.path }) + logger.info('[AuthGuard-Strict] IP allowlist match', { + clientIp, + endpoint: req.path, + }) next() return } @@ -110,7 +146,10 @@ export function internalAuthGuardStrict(req: Request, res: Response, next: NextF // Check 3: Admin token if (adminToken && process.env.ADMIN_API_TOKEN) { if (adminToken === process.env.ADMIN_API_TOKEN) { - logger.info('[AuthGuard-Strict] Admin token accepted', { clientIp, endpoint: req.path }) + logger.info('[AuthGuard-Strict] Admin token accepted', { + clientIp, + endpoint: req.path, + }) next() return } diff --git a/src/middleware/authenticate.ts b/src/middleware/authenticate.ts index c99f1b4..ea19343 100644 --- a/src/middleware/authenticate.ts +++ b/src/middleware/authenticate.ts @@ -1,11 +1,10 @@ -import { NextFunction, Request, Response } from 'express'; -import { JwtAdapter } from '../config'; -import db from '../db'; -import { logger } from '../utils/logger'; +import { NextFunction, Request, Response } from 'express' +import { JwtAdapter } from '../config' +import db from '../db' +import { logger } from '../utils/logger' // ─── Types ──────────────────────────────────────────────────────────────────── - // ─── Constants ──────────────────────────────────────────────────────────────── const AUTH_ERRORS = { @@ -16,19 +15,19 @@ const AUTH_ERRORS = { SESSION_EXPIRED: 'Session expired', USER_INACTIVE: 'User account is inactive', INTERNAL_ERROR: 'Internal server error', -} as const; +} as const // ─── Helpers ────────────────────────────────────────────────────────────────── function extractBearerToken(authHeader: string | undefined): string | null { - if (!authHeader) return null; - if (!authHeader.startsWith('Bearer ')) return null; - const token = authHeader.slice(7).trim(); // 'Bearer '.length === 7 - return token.length > 0 ? token : null; + if (!authHeader) return null + if (!authHeader.startsWith('Bearer ')) return null + const token = authHeader.slice(7).trim() // 'Bearer '.length === 7 + return token.length > 0 ? token : null } function isExpired(date: Date): boolean { - return date < new Date(); + return date < new Date() } // ─── Middleware ─────────────────────────────────────────────────────────────── @@ -49,76 +48,78 @@ function isExpired(date: Date): boolean { export async function requireAuth( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): Promise { - const authHeader = req.header('Authorization'); + const authHeader = req.header('Authorization') // 1. Header presence if (!authHeader) { - res.status(401).json({ error: AUTH_ERRORS.UNAUTHORIZED }); - return; + res.status(401).json({ error: AUTH_ERRORS.UNAUTHORIZED }) + return } // 2. Bearer format if (!authHeader.startsWith('Bearer ')) { - res.status(401).json({ error: AUTH_ERRORS.INVALID_BEARER }); - return; + res.status(401).json({ error: AUTH_ERRORS.INVALID_BEARER }) + return } - const token = extractBearerToken(authHeader); + const token = extractBearerToken(authHeader) if (!token) { - res.status(401).json({ error: AUTH_ERRORS.INVALID_TOKEN }); - return; + res.status(401).json({ error: AUTH_ERRORS.INVALID_TOKEN }) + return } try { // 3. JWT signature verification - const payload = await JwtAdapter.validateToken<{ id: string }>(token); + const payload = await JwtAdapter.validateToken<{ id: string }>(token) if (!payload) { - res.status(401).json({ error: AUTH_ERRORS.INVALID_TOKEN }); - return; + res.status(401).json({ error: AUTH_ERRORS.INVALID_TOKEN }) + return } // 4. Live session lookup const session = await db.session.findUnique({ where: { token }, include: { user: { select: { id: true, isActive: true } } }, - }); + }) if (!session) { - res.status(401).json({ error: AUTH_ERRORS.SESSION_NOT_FOUND }); - return; + res.status(401).json({ error: AUTH_ERRORS.SESSION_NOT_FOUND }) + return } // 5. Expiry check — delete stale row in the background, don't await if (isExpired(session.expiresAt)) { - db.session.delete({ where: { token } }).catch((err) => - logger.error('[Auth] Failed to delete expired session:', err), - ); - res.status(401).json({ error: AUTH_ERRORS.SESSION_EXPIRED }); - return; + db.session + .delete({ where: { token } }) + .catch((err) => + logger.error('[Auth] Failed to delete expired session:', err) + ) + res.status(401).json({ error: AUTH_ERRORS.SESSION_EXPIRED }) + return } // 6. Active user check if (!session.user.isActive) { - res.status(401).json({ error: AUTH_ERRORS.USER_INACTIVE }); - return; + res.status(401).json({ error: AUTH_ERRORS.USER_INACTIVE }) + return } // 7. Attach identity to request - req.userId = session.user.id; - req.stellarPubKey = session.walletAddress; + req.userId = session.user.id + req.stellarPubKey = session.walletAddress req.auth = { - userId: session.userId, - sessionId: session.id, + userId: session.userId, + sessionId: session.id, walletAddress: session.walletAddress, - network: session.network, - }; + network: session.network, + } - next(); + next() } catch (error) { - logger.error('[Auth] Middleware error:', error); - res.status(500).json({ error: AUTH_ERRORS.INTERNAL_ERROR }); + logger.error('[Auth] Middleware error:', error) + res.status(500).json({ error: AUTH_ERRORS.INTERNAL_ERROR }) } } @@ -132,21 +133,21 @@ export async function requireAuth( export function enforceUserAccess( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): void { if (!req.auth) { - res.status(401).json({ error: AUTH_ERRORS.UNAUTHORIZED }); - return; + res.status(401).json({ error: AUTH_ERRORS.UNAUTHORIZED }) + return } - const targetUserId = req.params.userId ?? req.body?.userId; + const targetUserId = req.params.userId ?? req.body?.userId if (targetUserId && req.auth.userId !== targetUserId) { - res.status(401).json({ error: AUTH_ERRORS.UNAUTHORIZED }); - return; + res.status(401).json({ error: AUTH_ERRORS.UNAUTHORIZED }) + return } - next(); + next() } /** @@ -161,5 +162,5 @@ export function enforceUserAccess( */ export class AuthMiddleware { /** @deprecated Use `requireAuth` directly */ - static readonly validateJwt = requireAuth; -} \ No newline at end of file + static readonly validateJwt = requireAuth +} diff --git a/src/middleware/correlationId.ts b/src/middleware/correlationId.ts index 9d1c739..4dbd0bf 100644 --- a/src/middleware/correlationId.ts +++ b/src/middleware/correlationId.ts @@ -1,5 +1,8 @@ import { Request, Response, NextFunction } from 'express' -import { resolveCorrelationId, runWithCorrelationId } from '../utils/correlation' +import { + resolveCorrelationId, + runWithCorrelationId, +} from '../utils/correlation' export const REQUEST_ID_HEADER = 'X-Request-ID' @@ -12,7 +15,9 @@ export function correlationIdMiddleware( res: Response, next: NextFunction ): void { - const correlationId = resolveCorrelationId(req.headers as Record) + const correlationId = resolveCorrelationId( + req.headers as Record + ) req.correlationId = correlationId res.locals.correlationId = correlationId diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index 4ea2256..365c2ff 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -57,9 +57,15 @@ export function errorHandler( } if (isClientError(statusCode)) { - logger.warn(`[ErrorHandler] Client error ${statusCode}: ${err.message}`, logMeta) + logger.warn( + `[ErrorHandler] Client error ${statusCode}: ${err.message}`, + logMeta + ) } else { - logger.error(`[ErrorHandler] Server error ${statusCode}: ${err.message}`, logMeta) + logger.error( + `[ErrorHandler] Server error ${statusCode}: ${err.message}`, + logMeta + ) } // ── OpenTelemetry — mark the active span as failed ──────────────────────── @@ -98,13 +104,14 @@ export function errorHandler( if (!isClientError(statusCode)) { // Attach request context so the Sentry issue shows who was affected Sentry.withScope((scope) => { - const user = (req as Request & { user?: { id: string; phone?: string } }).user + const user = (req as Request & { user?: { id: string; phone?: string } }) + .user if (user?.id) { scope.setUser({ id: user.id, phone: user.phone }) } - scope.setTag('correlation_id', requestId ?? 'unknown') + scope.setTag('correlation_id', requestId ?? 'unknown') scope.setTag('http.method', req.method) scope.setTag('http.route', req.route?.path ?? req.path) scope.setTag('status_code', String(statusCode)) @@ -131,4 +138,4 @@ export function errorHandler( ) res.status(statusCode).json(errorResponse) -} \ No newline at end of file +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts index e393e91..a1b45d7 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -1,7 +1,14 @@ -export { logger } from '../utils/logger'; -export { errorHandler } from './errorHandler'; -export { rateLimiter } from './rateLimiter'; -export { requestTimeoutMiddleware, resolveRequestTimeout } from './requestTimeout'; -export { configureTrustProxy, securityHeaders, permissionsPolicy } from './security'; -export { requireAuth, enforceUserAccess, AuthMiddleware } from './authenticate'; -export type { } from './authenticate'; // re-export augmented Request types +export { logger } from '../utils/logger' +export { errorHandler } from './errorHandler' +export { rateLimiter } from './rateLimiter' +export { + requestTimeoutMiddleware, + resolveRequestTimeout, +} from './requestTimeout' +export { + configureTrustProxy, + securityHeaders, + permissionsPolicy, +} from './security' +export { requireAuth, enforceUserAccess, AuthMiddleware } from './authenticate' +export type {} from './authenticate' // re-export augmented Request types diff --git a/src/middleware/logger.ts b/src/middleware/logger.ts index a4159b5..0a70ac4 100644 --- a/src/middleware/logger.ts +++ b/src/middleware/logger.ts @@ -50,4 +50,4 @@ export function requestLogger(req: Request, res: Response, next: NextFunction) { }) next() -} \ No newline at end of file +} diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index e53cd2c..45f7ef7 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -15,12 +15,17 @@ import { logger } from '../utils/logger' * TRUSTED_IPS — comma-separated IPv4/IPv6 addresses * INTERNAL_SERVICE_TOKEN — opaque token sent in the X-Internal-Token header */ -export function trustedIpBypass(req: Request, res: Response, next: NextFunction): void { +export function trustedIpBypass( + req: Request, + res: Response, + next: NextFunction +): void { const ip = req.ip ?? '' const token = req.headers['x-internal-token'] const ipTrusted = - config.security.trustedIps.length > 0 && config.security.trustedIps.includes(ip) + config.security.trustedIps.length > 0 && + config.security.trustedIps.includes(ip) const tokenTrusted = config.security.internalServiceToken.length > 0 && token === config.security.internalServiceToken @@ -116,7 +121,9 @@ export function buildRateLimiter( standardHeaders: true, legacyHeaders: false, skip: opts.skip, - message: { error: opts.message ?? 'Too many requests. Please try again later.' }, + message: { + error: opts.message ?? 'Too many requests. Please try again later.', + }, handler: (req: any, res: any) => handleRateLimitExceeded(req, res, { limiterType: opts.limiterType, diff --git a/src/middleware/security.ts b/src/middleware/security.ts index d1210ff..195f5a8 100644 --- a/src/middleware/security.ts +++ b/src/middleware/security.ts @@ -1,4 +1,9 @@ -import { type Express, type Request, type Response, type NextFunction } from 'express' +import { + type Express, + type Request, + type Response, + type NextFunction, +} from 'express' import helmet from 'helmet' import { config } from '../config/env' @@ -69,7 +74,7 @@ export function permissionsPolicy() { return (_req: Request, res: Response, next: NextFunction): void => { res.setHeader( 'Permissions-Policy', - 'geolocation=(), camera=(), microphone=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()', + 'geolocation=(), camera=(), microphone=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()' ) next() } diff --git a/src/middleware/validate.ts b/src/middleware/validate.ts index cdcbc9e..4a51c11 100644 --- a/src/middleware/validate.ts +++ b/src/middleware/validate.ts @@ -1,25 +1,25 @@ -import { Request, Response, NextFunction } from 'express'; -import { ZodSchema, ZodError, ZodTypeAny } from 'zod'; -import { logger } from '../utils/logger'; +import { Request, Response, NextFunction } from 'express' +import { ZodSchema, ZodError, ZodTypeAny } from 'zod' +import { logger } from '../utils/logger' export interface ValidationSchemas { - body?: ZodTypeAny; - query?: ZodTypeAny; - params?: ZodTypeAny; - errorMessage?: string; + body?: ZodTypeAny + query?: ZodTypeAny + params?: ZodTypeAny + errorMessage?: string } -type SchemasOrSchema = ValidationSchemas | ZodSchema | ZodTypeAny; +type SchemasOrSchema = ValidationSchemas | ZodSchema | ZodTypeAny function isZodSchema(val: any): val is ZodSchema | ZodTypeAny { - return val && typeof val.safeParseAsync === 'function'; + return val && typeof val.safeParseAsync === 'function' } function formatZodErrors(err: ZodError) { - return err.issues.map(e => ({ + return err.issues.map((e) => ({ path: e.path.join('.'), message: e.message.includes('received undefined') ? 'Required' : e.message, - })); + })) } /** @@ -31,36 +31,69 @@ export const validate = (schemasOrSchema: SchemasOrSchema) => { return async (req: Request, res: Response, next: NextFunction) => { try { if (isZodSchema(schemasOrSchema)) { - const parsed = await schemasOrSchema.safeParseAsync({ body: req.body, query: req.query, params: req.params }); + const parsed = await schemasOrSchema.safeParseAsync({ + body: req.body, + query: req.query, + params: req.params, + }) if (!parsed.success) { - return res.status(400).json({ error: 'Validation failed', details: formatZodErrors(parsed.error) }); + return res.status(400).json({ + error: 'Validation failed', + details: formatZodErrors(parsed.error), + }) } // Merge parsed results back into req if present - const data: any = parsed.data || {}; - if (data.body !== undefined) req.body = data.body; - if (data.query !== undefined) Object.defineProperty(req, 'query', { value: data.query, writable: true, configurable: true }); - if (data.params !== undefined) Object.defineProperty(req, 'params', { value: data.params, writable: true, configurable: true }); + const data: any = parsed.data || {} + if (data.body !== undefined) req.body = data.body + if (data.query !== undefined) + Object.defineProperty(req, 'query', { + value: data.query, + writable: true, + configurable: true, + }) + if (data.params !== undefined) + Object.defineProperty(req, 'params', { + value: data.params, + writable: true, + configurable: true, + }) - return next(); + return next() } - const schemas = schemasOrSchema as ValidationSchemas; - if (schemas.body) req.body = schemas.body.parse(req.body); - if (schemas.query) Object.defineProperty(req, 'query', { value: schemas.query.parse(req.query) as typeof req.query, writable: true, configurable: true }); - if (schemas.params) Object.defineProperty(req, 'params', { value: schemas.params.parse(req.params) as typeof req.params, writable: true, configurable: true }); + const schemas = schemasOrSchema as ValidationSchemas + if (schemas.body) req.body = schemas.body.parse(req.body) + if (schemas.query) + Object.defineProperty(req, 'query', { + value: schemas.query.parse(req.query) as typeof req.query, + writable: true, + configurable: true, + }) + if (schemas.params) + Object.defineProperty(req, 'params', { + value: schemas.params.parse(req.params) as typeof req.params, + writable: true, + configurable: true, + }) - return next(); + return next() } catch (error) { if (error instanceof ZodError) { - const details = formatZodErrors(error); - logger.warn(`[Validation] Request validation failed: ${JSON.stringify(details)}`); - const msg = (schemasOrSchema as ValidationSchemas).errorMessage ?? 'Validation failed'; - return res.status(400).json({ error: msg, details }); + const details = formatZodErrors(error) + logger.warn( + `[Validation] Request validation failed: ${JSON.stringify(details)}` + ) + const msg = + (schemasOrSchema as ValidationSchemas).errorMessage ?? + 'Validation failed' + return res.status(400).json({ error: msg, details }) } - logger.error('[Validation] Unexpected error:', error); - return res.status(500).json({ error: 'Internal server error during validation' }); + logger.error('[Validation] Unexpected error:', error) + return res + .status(500) + .json({ error: 'Internal server error during validation' }) } - }; -}; + } +} diff --git a/src/nlp/parser.ts b/src/nlp/parser.ts index 173d9dd..2413a88 100644 --- a/src/nlp/parser.ts +++ b/src/nlp/parser.ts @@ -3,7 +3,14 @@ import { HttpClientAdapter } from '../utils/http-client' import { config } from '../config' export interface Intent { - action: 'deposit' | 'withdraw' | 'balance' | 'earnings' | 'goal' | 'help' | 'unknown' + action: + | 'deposit' + | 'withdraw' + | 'balance' + | 'earnings' + | 'goal' + | 'help' + | 'unknown' amount?: number currency?: string all?: boolean @@ -99,9 +106,14 @@ Return ONLY a JSON object representing the intent, matching this TypeScript inte if (jsonStr) { const parsed = JSON.parse(jsonStr) if ( - ['deposit', 'withdraw', 'balance', 'earnings', 'goal', 'help'].includes( - parsed.action - ) + [ + 'deposit', + 'withdraw', + 'balance', + 'earnings', + 'goal', + 'help', + ].includes(parsed.action) ) { return parsed as Intent } diff --git a/src/nlp/responses.ts b/src/nlp/responses.ts index bc91405..2ba42dc 100644 --- a/src/nlp/responses.ts +++ b/src/nlp/responses.ts @@ -1,10 +1,12 @@ export const responses = { - deposit: (amount: number | string, currency?: string) => `You want to deposit ${amount}${currency ? ' ' + currency : ''}.`, + deposit: (amount: number | string, currency?: string) => + `You want to deposit ${amount}${currency ? ' ' + currency : ''}.`, withdraw: (amount?: number | string, currency?: string, all?: boolean) => { - if (all) return "You want to withdraw everything."; - return `You want to withdraw ${amount}${currency ? ' ' + currency : ''}.`; + if (all) return 'You want to withdraw everything.' + return `You want to withdraw ${amount}${currency ? ' ' + currency : ''}.` }, - balance: () => "Here is your current balance.", - help: () => "You can ask me to deposit, withdraw, or check your balance.", - unrecognized: () => "I'm sorry, I couldn't understand that command. Please try 'deposit 100', 'withdraw everything', or 'balance'." -}; + balance: () => 'Here is your current balance.', + help: () => 'You can ask me to deposit, withdraw, or check your balance.', + unrecognized: () => + "I'm sorry, I couldn't understand that command. Please try 'deposit 100', 'withdraw everything', or 'balance'.", +} diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 6384c39..c4d4cf9 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -666,12 +666,10 @@ router.post( } catch (error: any) { // Unique constraint violation — name already taken if (error?.code === 'P2002') { - return res - .status(409) - .json({ - success: false, - error: 'A key with that name already exists', - }) + return res.status(409).json({ + success: false, + error: 'A key with that name already exists', + }) } logger.error('[Admin] Failed to create admin key', { error: error instanceof Error ? error.message : 'Unknown error', diff --git a/src/routes/analytics.ts b/src/routes/analytics.ts index 4150003..1952096 100644 --- a/src/routes/analytics.ts +++ b/src/routes/analytics.ts @@ -21,10 +21,14 @@ router.get('/apy-history', requireAuth, async (req: Request, res: Response) => { const userId = req.auth!.userId const parsed = periodSchema.safeParse(req.query) if (!parsed.success) { - return res.status(400).json({ error: 'Validation error', details: parsed.error.flatten() }) + return res + .status(400) + .json({ error: 'Validation error', details: parsed.error.flatten() }) } - const fromDate = new Date(Date.now() - periodToDays(parsed.data.period) * 86400_000) + const fromDate = new Date( + Date.now() - periodToDays(parsed.data.period) * 86400_000 + ) const snapshots = await db.yieldSnapshot.findMany({ where: { position: { userId }, snapshotAt: { gte: fromDate } }, @@ -49,13 +53,20 @@ router.get('/user-yield', requireAuth, async (req: Request, res: Response) => { const userId = req.auth!.userId const parsed = periodSchema.safeParse(req.query) if (!parsed.success) { - return res.status(400).json({ error: 'Validation error', details: parsed.error.flatten() }) + return res + .status(400) + .json({ error: 'Validation error', details: parsed.error.flatten() }) } - const fromDate = new Date(Date.now() - periodToDays(parsed.data.period) * 86400_000) + const fromDate = new Date( + Date.now() - periodToDays(parsed.data.period) * 86400_000 + ) const [positions, snapshots] = await Promise.all([ - db.position.findMany({ where: { userId }, select: { yieldEarned: true, assetSymbol: true } }), + db.position.findMany({ + where: { userId }, + select: { yieldEarned: true, assetSymbol: true }, + }), db.yieldSnapshot.findMany({ where: { position: { userId }, snapshotAt: { gte: fromDate } }, orderBy: { snapshotAt: 'asc' }, @@ -63,8 +74,14 @@ router.get('/user-yield', requireAuth, async (req: Request, res: Response) => { }), ]) - const totalYield = positions.reduce((sum, p) => sum + Number(p.yieldEarned), 0) - const periodYield = snapshots.reduce((sum, s) => sum + Number(s.yieldAmount), 0) + const totalYield = positions.reduce( + (sum, p) => sum + Number(p.yieldEarned), + 0 + ) + const periodYield = snapshots.reduce( + (sum, s) => sum + Number(s.yieldAmount), + 0 + ) const averageApy = snapshots.length > 0 ? snapshots.reduce((sum, s) => sum + Number(s.apy), 0) / snapshots.length @@ -93,10 +110,14 @@ router.get('/user-yield', requireAuth, async (req: Request, res: Response) => { router.get('/protocol-performance', async (req: Request, res: Response) => { const parsed = periodSchema.safeParse(req.query) if (!parsed.success) { - return res.status(400).json({ error: 'Validation error', details: parsed.error.flatten() }) + return res + .status(400) + .json({ error: 'Validation error', details: parsed.error.flatten() }) } - const fromDate = new Date(Date.now() - periodToDays(parsed.data.period) * 86400_000) + const fromDate = new Date( + Date.now() - periodToDays(parsed.data.period) * 86400_000 + ) const rates = await db.protocolRate.findMany({ where: { fetchedAt: { gte: fromDate } }, @@ -112,12 +133,25 @@ router.get('/protocol-performance', async (req: Request, res: Response) => { }) // Group by protocol for graph-ready output - const byProtocol: Record = {} + const byProtocol: Record< + string, + { + protocol: string + asset: string + network: string + points: { date: string; apy: number; tvl: number | null }[] + } + > = {} for (const r of rates) { const key = `${r.protocolName}:${r.assetSymbol}:${r.network}` if (!byProtocol[key]) { - byProtocol[key] = { protocol: r.protocolName, asset: r.assetSymbol, network: r.network, points: [] } + byProtocol[key] = { + protocol: r.protocolName, + asset: r.assetSymbol, + network: r.network, + points: [], + } } byProtocol[key].points.push({ date: r.fetchedAt.toISOString().slice(0, 10), @@ -126,7 +160,9 @@ router.get('/protocol-performance', async (req: Request, res: Response) => { }) } - return res.status(200).json({ period: parsed.data.period, protocols: Object.values(byProtocol) }) + return res + .status(200) + .json({ period: parsed.data.period, protocols: Object.values(byProtocol) }) }) export default router diff --git a/src/routes/auth.ts b/src/routes/auth.ts index a2553a3..f9bde2f 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,27 +1,30 @@ -import { Router } from 'express'; -import { challenge, verify, logout } from '../controllers/auth-controller'; -import { requireAuth } from '../middleware/authenticate'; -import { validate } from '../middleware/validate'; -import { authChallengeSchema, authVerifySchema } from '../validators/auth-validators'; +import { Router } from 'express' +import { challenge, verify, logout } from '../controllers/auth-controller' +import { requireAuth } from '../middleware/authenticate' +import { validate } from '../middleware/validate' +import { + authChallengeSchema, + authVerifySchema, +} from '../validators/auth-validators' -const router = Router(); +const router = Router() /** * POST /api/auth/challenge * Returns a one-time nonce to be signed by the Stellar keypair. */ -router.post('/challenge', validate({ body: authChallengeSchema }), challenge); +router.post('/challenge', validate({ body: authChallengeSchema }), challenge) /** * POST /api/auth/verify * Verifies Stellar signature, creates/fetches user, issues JWT. */ -router.post('/verify', validate({ body: authVerifySchema }), verify); +router.post('/verify', validate({ body: authVerifySchema }), verify) /** * POST /api/auth/logout * Revokes the active session. Requires a valid Bearer token. */ -router.post('/logout', requireAuth, logout); +router.post('/logout', requireAuth, logout) -export default router; +export default router diff --git a/src/routes/fiat.ts b/src/routes/fiat.ts index f189a6f..b26fbe9 100644 --- a/src/routes/fiat.ts +++ b/src/routes/fiat.ts @@ -46,7 +46,7 @@ router.post( }) return sendError(res, 502, 'Failed to fetch quote from provider') } - }, + } ) // ── Create order ──────────────────────────────────────────────────────────── @@ -70,7 +70,7 @@ router.post( }) return sendError(res, 502, 'Failed to create order with provider') } - }, + } ) // ── Order history (caller-scoped) ───────────────────────────────────────────── @@ -131,7 +131,9 @@ router.post( } if (!provider.verifyWebhookSignature(rawBody, headers)) { - logger.warn('[Fiat] Webhook signature verification failed', { provider: providerName }) + logger.warn('[Fiat] Webhook signature verification failed', { + provider: providerName, + }) return sendError(res, 401, 'Invalid signature') } @@ -159,7 +161,7 @@ router.post( }) return sendError(res, 500, 'Webhook processing failed') } - }, + } ) export default router diff --git a/src/routes/goals.ts b/src/routes/goals.ts index 365fa1a..d2ddab0 100644 --- a/src/routes/goals.ts +++ b/src/routes/goals.ts @@ -14,7 +14,11 @@ import { Router } from 'express' import { requireAuth, enforceUserAccess } from '../middleware/authenticate' import { validate } from '../middleware/validate' import { userIdParamSchema } from '../validators/common-validators' -import { createGoalSchema, updateGoalSchema, goalIdParamSchema } from '../validators/goal-validators' +import { + createGoalSchema, + updateGoalSchema, + goalIdParamSchema, +} from '../validators/goal-validators' import { createGoalHandler, getGoalHandler, @@ -25,30 +29,40 @@ import { const router = Router() -router.post('/', requireAuth, validate({ body: createGoalSchema }), createGoalHandler) +router.post( + '/', + requireAuth, + validate({ body: createGoalSchema }), + createGoalHandler +) router.get( '/:userId', requireAuth, enforceUserAccess, validate({ params: userIdParamSchema }), - getGoalHandler, + getGoalHandler ) router.patch( '/:id', requireAuth, validate({ params: goalIdParamSchema, body: updateGoalSchema }), - updateGoalHandler, + updateGoalHandler ) -router.delete('/:id', requireAuth, validate({ params: goalIdParamSchema }), cancelGoalHandler) +router.delete( + '/:id', + requireAuth, + validate({ params: goalIdParamSchema }), + cancelGoalHandler +) router.get( '/:id/progress', requireAuth, validate({ params: goalIdParamSchema }), - getGoalProgressHandler, + getGoalProgressHandler ) export default router diff --git a/src/routes/health.ts b/src/routes/health.ts index 9c3c332..51d94e1 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -14,7 +14,7 @@ router.get('/', (req: Request, res: Response) => { status: 'ok', timestamp: new Date().toISOString(), version: pkg.version || '1.0.0', - environment: process.env.NODE_ENV || 'development' + environment: process.env.NODE_ENV || 'development', }) }) @@ -31,17 +31,26 @@ router.get('/ready', (req: Request, res: Response) => { }) }) -const withTimeout = (promise: Promise, timeoutMs: number, name: string): Promise => { +const withTimeout = ( + promise: Promise, + timeoutMs: number, + name: string +): Promise => { return Promise.race([ promise, new Promise((_, reject) => - setTimeout(() => reject(new Error(`${name} check timed out after ${timeoutMs}ms`)), timeoutMs) - ) + setTimeout( + () => reject(new Error(`${name} check timed out after ${timeoutMs}ms`)), + timeoutMs + ) + ), ]) } router.get('/deep', async (req: Request, res: Response) => { - const token = req.headers['x-internal-token'] || req.headers['authorization']?.replace('Bearer ', '') + const token = + req.headers['x-internal-token'] || + req.headers['authorization']?.replace('Bearer ', '') const expectedToken = process.env.INTERNAL_SERVICE_TOKEN if (!expectedToken || token !== expectedToken) { @@ -49,32 +58,57 @@ router.get('/deep', async (req: Request, res: Response) => { } const startDb = Date.now() - const checkDatabase = async (): Promise<{ status: 'healthy' | 'degraded' | 'unhealthy'; latencyMs: number; error?: string }> => { + const checkDatabase = async (): Promise<{ + status: 'healthy' | 'degraded' | 'unhealthy' + latencyMs: number + error?: string + }> => { try { await withTimeout(db.$queryRaw`SELECT 1`, 3000, 'database') return { status: 'healthy', latencyMs: Date.now() - startDb } } catch (error: any) { - return { status: 'unhealthy', latencyMs: Date.now() - startDb, error: error.message } + return { + status: 'unhealthy', + latencyMs: Date.now() - startDb, + error: error.message, + } } } const startStellar = Date.now() - const checkStellarRpc = async (): Promise<{ status: 'healthy' | 'degraded' | 'unhealthy'; latencyMs: number; ledger?: number; error?: string }> => { + const checkStellarRpc = async (): Promise<{ + status: 'healthy' | 'degraded' | 'unhealthy' + latencyMs: number + ledger?: number + error?: string + }> => { try { const client = getResilientClient() const latestLedger = await withTimeout( - client.execute(server => server.getLatestLedger(), 'health-check'), + client.execute((server) => server.getLatestLedger(), 'health-check'), 3000, 'stellarRpc' ) - return { status: 'healthy', latencyMs: Date.now() - startStellar, ledger: latestLedger.sequence } + return { + status: 'healthy', + latencyMs: Date.now() - startStellar, + ledger: latestLedger.sequence, + } } catch (error: any) { - return { status: 'unhealthy', latencyMs: Date.now() - startStellar, error: error.message } + return { + status: 'unhealthy', + latencyMs: Date.now() - startStellar, + error: error.message, + } } } const startTwilio = Date.now() - const checkTwilio = async (): Promise<{ status: 'healthy' | 'degraded' | 'unhealthy'; latencyMs: number; error?: string }> => { + const checkTwilio = async (): Promise<{ + status: 'healthy' | 'degraded' | 'unhealthy' + latencyMs: number + error?: string + }> => { try { const sid = config.whatsapp.twilioSid const twilioToken = config.whatsapp.twilioToken @@ -82,14 +116,14 @@ router.get('/deep', async (req: Request, res: Response) => { throw new Error('Twilio credentials not configured') } const client = twilio(sid, twilioToken) - await withTimeout( - client.api.accounts(sid).fetch(), - 3000, - 'twilio' - ) + await withTimeout(client.api.accounts(sid).fetch(), 3000, 'twilio') return { status: 'healthy', latencyMs: Date.now() - startTwilio } } catch (error: any) { - return { status: 'unhealthy', latencyMs: Date.now() - startTwilio, error: error.message } + return { + status: 'unhealthy', + latencyMs: Date.now() - startTwilio, + error: error.message, + } } } @@ -104,7 +138,8 @@ router.get('/deep', async (req: Request, res: Response) => { } else if (!status.lastTickAt) { checkStatus = 'unhealthy' } else { - const timeSinceLastTick = Date.now() - new Date(status.lastTickAt).getTime() + const timeSinceLastTick = + Date.now() - new Date(status.lastTickAt).getTime() if (timeSinceLastTick > 2 * TICK_INTERVAL_MS) { checkStatus = 'unhealthy' } else if (status.lastError || status.healthStatus === 'degraded') { @@ -114,17 +149,21 @@ router.get('/deep', async (req: Request, res: Response) => { return { status: checkStatus, - lastTickAt: status.lastTickAt ? status.lastTickAt.toISOString() : null + lastTickAt: status.lastTickAt ? status.lastTickAt.toISOString() : null, } } catch (error: any) { - return { status: 'unhealthy' as const, lastTickAt: null, error: error.message } + return { + status: 'unhealthy' as const, + lastTickAt: null, + error: error.message, + } } } const [dbResult, stellarResult, twilioResult] = await Promise.all([ checkDatabase(), checkStellarRpc(), - checkTwilio() + checkTwilio(), ]) const agentResult = checkAgentLoop() @@ -157,8 +196,8 @@ router.get('/deep', async (req: Request, res: Response) => { database: dbResult, stellarRpc: stellarResult, twilio: twilioResult, - agentLoop: agentResult - } + agentLoop: agentResult, + }, }) }) diff --git a/src/routes/metrics.ts b/src/routes/metrics.ts index 4d1b703..c0c8941 100644 --- a/src/routes/metrics.ts +++ b/src/routes/metrics.ts @@ -25,14 +25,18 @@ const router = Router() * - HTTP request metrics * - Analytics API metrics */ -router.get('/', internalAuthGuardStrict, async (_req: Request, res: Response) => { - try { - const metrics = await getMetrics() - res.set('Content-Type', 'text/plain') - res.status(200).send(metrics) - } catch (error) { - res.status(500).json({ error: 'Failed to retrieve metrics' }) +router.get( + '/', + internalAuthGuardStrict, + async (_req: Request, res: Response) => { + try { + const metrics = await getMetrics() + res.set('Content-Type', 'text/plain') + res.status(200).send(metrics) + } catch (error) { + res.status(500).json({ error: 'Failed to retrieve metrics' }) + } } -}) +) export default router diff --git a/src/routes/portfolio.ts b/src/routes/portfolio.ts index d57ec82..252d290 100644 --- a/src/routes/portfolio.ts +++ b/src/routes/portfolio.ts @@ -11,6 +11,12 @@ import { formatPortfolioReply, } from '../whatsapp/formatters' import { userIdParamSchema } from '../validators/common-validators' +import { + buildTaxReport, + taxReportToCsvRows, + TAX_REPORT_CSV_HEADERS, +} from '../tax/report' +import { toCsv } from '../utils/csv' import goalsRouter from './goals' const router = Router() @@ -25,6 +31,16 @@ const portfolioSchema = z.object({ }), }) +const taxReportSchema = z.object({ + params: z.object({ + userId: z.string().uuid(), + }), + query: z.object({ + year: z.coerce.number().int().min(2000).max(2100), + format: z.enum(['json', 'csv']).default('json'), + }), +}) + const historySchema = z.object({ params: z.object({ userId: z.string().uuid(), @@ -34,44 +50,52 @@ const historySchema = z.object({ }), }) -router.get('/:userId', requireAuth, enforceUserAccess, validate(portfolioSchema), async (req: Request, res: Response) => { - const userId = req.params.userId as string - const user = await db.user.findUnique({ - where: { id: userId }, - }) +router.get( + '/:userId', + requireAuth, + enforceUserAccess, + validate(portfolioSchema), + async (req: Request, res: Response) => { + const userId = req.params.userId as string + const user = await db.user.findUnique({ + where: { id: userId }, + }) + + if (!user) { + return sendNotFound(res, 'User') + } - if (!user) { - return sendNotFound(res, 'User') - } + const userPositions = await db.position.findMany({ + where: { userId }, + }) + + const totalBalance = userPositions.reduce((sum: number, position: any) => { + return sum + Number(position.currentValue) + }, 0) + const totalEarnings = userPositions.reduce((sum: number, position: any) => { + return sum + Number(position.yieldEarned) + }, 0) + const activePositions = userPositions.filter( + (p: any) => p.status === 'ACTIVE' + ).length - const userPositions = await db.position.findMany({ - where: { userId }, - }) - - const totalBalance = userPositions.reduce((sum: number, position: any) => { - return sum + Number(position.currentValue) - }, 0) - const totalEarnings = userPositions.reduce((sum: number, position: any) => { - return sum + Number(position.yieldEarned) - }, 0) - const activePositions = userPositions.filter((p: any) => p.status === 'ACTIVE').length - - const positions = userPositions.map(mapPositionToResponse) - - return res.status(200).json({ - userId: user.id, - totalBalance, - totalEarnings, - activePositions, - positions, - whatsappReply: formatPortfolioReply({ + const positions = userPositions.map(mapPositionToResponse) + + return res.status(200).json({ + userId: user.id, totalBalance, totalEarnings, activePositions, positions, - }), - }) -}) + whatsappReply: formatPortfolioReply({ + totalBalance, + totalEarnings, + activePositions, + positions, + }), + }) + } +) router.get( '/:userId/history', @@ -92,11 +116,7 @@ router.get( const now = Date.now() const dayMs = 24 * 60 * 60 * 1000 const periodDays = - req.query.period === '7d' - ? 7 - : req.query.period === '30d' - ? 30 - : 90 + req.query.period === '7d' ? 7 : req.query.period === '30d' ? 30 : 90 const fromDate = new Date(now - periodDays * dayMs) const snapshots = await db.yieldSnapshot.findMany({ @@ -119,7 +139,7 @@ router.get( points, }), }) - }, + } ) router.get( @@ -158,8 +178,7 @@ router.get( ? snapshots.reduce( (sum: number, snapshot: any) => sum + Number(snapshot.apy), 0 - ) / - snapshots.length + ) / snapshots.length : 0 return res.status(200).json({ @@ -173,7 +192,41 @@ router.get( averageApy, }), }) - }, + } +) + +router.get( + '/:userId/tax-report', + requireAuth, + enforceUserAccess, + validate(taxReportSchema), + async (req: Request, res: Response) => { + const userId = req.params.userId as string + const user = await db.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }) + + if (!user) { + return sendNotFound(res, 'User') + } + + const year = req.query.year as unknown as number + const report = await buildTaxReport(userId, year) + + if (req.query.format === 'csv') { + res.setHeader('Content-Type', 'text/csv; charset=utf-8') + res.setHeader( + 'Content-Disposition', + `attachment; filename="tax-report-${year}.csv"` + ) + return res + .status(200) + .send(toCsv(TAX_REPORT_CSV_HEADERS, taxReportToCsvRows(report))) + } + + return res.status(200).json(report) + } ) export default router diff --git a/src/routes/protocols.ts b/src/routes/protocols.ts index 3d8af3d..ad81eda 100644 --- a/src/routes/protocols.ts +++ b/src/routes/protocols.ts @@ -71,7 +71,8 @@ router.get('/risk', async (_req: Request, res: Response) => { methodology: 'docs/PROTOCOL_RISK_SCORING.md', }) } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error' + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' return res.status(500).json({ success: false, error: errorMessage, @@ -99,14 +100,18 @@ router.get('/agent/status', async (req: Request, res: Response) => { healthStatus: agentStatus.healthStatus, lastRebalanceAt: agentStatus.lastRebalanceAt?.toISOString() || null, currentProtocol: agentStatus.currentProtocol, - currentApy: agentStatus.currentApy ? Number(agentStatus.currentApy.toFixed(2)) : null, + currentApy: agentStatus.currentApy + ? Number(agentStatus.currentApy.toFixed(2)) + : null, nextScheduledCheck: agentStatus.nextScheduledCheck.toISOString(), lastError: agentStatus.lastError, - latestLog: latestLog ? { - status: latestLog.status, - action: latestLog.action, - createdAt: latestLog.createdAt.toISOString(), - } : null, + latestLog: latestLog + ? { + status: latestLog.status, + action: latestLog.action, + createdAt: latestLog.createdAt.toISOString(), + } + : null, timestamp: new Date().toISOString(), } @@ -120,7 +125,8 @@ router.get('/agent/status', async (req: Request, res: Response) => { }), }) } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error' + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' return res.status(500).json({ success: false, error: errorMessage, diff --git a/src/routes/stellar.ts b/src/routes/stellar.ts index d56253d..e09ce31 100644 --- a/src/routes/stellar.ts +++ b/src/routes/stellar.ts @@ -1,7 +1,7 @@ -import { Router, Request, Response } from 'express'; -import { getEventMetrics } from '../stellar/events'; +import { Router, Request, Response } from 'express' +import { getEventMetrics } from '../stellar/events' -const router = Router(); +const router = Router() /** * GET /api/stellar/metrics @@ -9,17 +9,17 @@ const router = Router(); */ router.get('/metrics', (_req: Request, res: Response) => { try { - const metrics = getEventMetrics(); + const metrics = getEventMetrics() res.json({ success: true, data: metrics, - }); + }) } catch (error) { res.status(500).json({ success: false, error: error instanceof Error ? error.message : 'Unknown error', - }); + }) } -}); +}) -export default router; +export default router diff --git a/src/routes/transactions.ts b/src/routes/transactions.ts index 95f60a5..19fb271 100644 --- a/src/routes/transactions.ts +++ b/src/routes/transactions.ts @@ -52,7 +52,7 @@ router.get( transaction: item, whatsappReply: formatTransactionDetailReply(item), }) - }, + } ) /** @@ -92,31 +92,31 @@ router.get( limit, total, transactions: items, - whatsappReply: formatTransactionsReply({ page, limit, transactions: items }), + whatsappReply: formatTransactionsReply({ + page, + limit, + transactions: items, + }), }) - }, // ← closes the async handler for /:userId -) // ← closes router.get('/:userId', ...) + } // ← closes the async handler for /:userId +) // ← closes router.get('/:userId', ...) /** * GET /transactions/:id/events * Returns the ordered event history for a transaction (admin only). */ -router.get( - '/:id/events', - requireAuth, - async (req: Request, res: Response) => { - const id = String(req.params.id) // ← String() cast fixes the string | string[] error +router.get('/:id/events', requireAuth, async (req: Request, res: Response) => { + const id = String(req.params.id) // ← String() cast fixes the string | string[] error - const tx = await db.transaction.findUnique({ where: { id } }) - if (!tx) return sendNotFound(res, 'Transaction') + const tx = await db.transaction.findUnique({ where: { id } }) + if (!tx) return sendNotFound(res, 'Transaction') - const events = await (db as any).transactionEvent.findMany({ - where: { transactionId: id }, - orderBy: { occurredAt: 'asc' }, - }) + const events = await (db as any).transactionEvent.findMany({ + where: { transactionId: id }, + orderBy: { occurredAt: 'asc' }, + }) - return res.status(200).json({ transactionId: id, events }) - }, -) + return res.status(200).json({ transactionId: id, events }) +}) -export default router \ No newline at end of file +export default router diff --git a/src/routes/vault.ts b/src/routes/vault.ts index 81df3c5..26ce72c 100644 --- a/src/routes/vault.ts +++ b/src/routes/vault.ts @@ -62,27 +62,33 @@ const buildTransactionSchema = z.object({ * Builds an unsigned XDR transaction for the user to sign client-side (non-custodial). * The backend never holds or decrypts private keys for this flow. */ -router.post('/build-transaction', requireAuth, async (req: Request, res: Response) => { - const parsed = buildTransactionSchema.safeParse(req.body) - if (!parsed.success) { - return res.status(400).json({ error: 'Validation error', details: parsed.error.flatten() }) - } +router.post( + '/build-transaction', + requireAuth, + async (req: Request, res: Response) => { + const parsed = buildTransactionSchema.safeParse(req.body) + if (!parsed.success) { + return res + .status(400) + .json({ error: 'Validation error', details: parsed.error.flatten() }) + } - const walletAddress = req.auth!.walletAddress + const walletAddress = req.auth!.walletAddress - const unsignedXdr = await buildUnsignedVaultTransaction( - parsed.data.type, - walletAddress, - parsed.data.amount, - parsed.data.assetSymbol, - ) + const unsignedXdr = await buildUnsignedVaultTransaction( + parsed.data.type, + walletAddress, + parsed.data.amount, + parsed.data.assetSymbol + ) - return res.status(200).json({ - xdr: unsignedXdr, - type: parsed.data.type, - amount: parsed.data.amount, - walletAddress, - }) -}) + return res.status(200).json({ + xdr: unsignedXdr, + type: parsed.data.type, + amount: parsed.data.amount, + walletAddress, + }) + } +) export default router diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index d30c696..1845494 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -1,19 +1,19 @@ -import { Router, Request, Response } from 'express'; -import db from '../db'; -import { requireAuth } from '../middleware/authenticate'; -import { validate } from '../middleware/validate'; -import { sendNotFound } from '../utils/errors'; -import { generateWebhookSecret } from '../utils/webhookSignature'; +import { Router, Request, Response } from 'express' +import db from '../db' +import { requireAuth } from '../middleware/authenticate' +import { validate } from '../middleware/validate' +import { sendNotFound } from '../utils/errors' +import { generateWebhookSecret } from '../utils/webhookSignature' import { createWebhookSchema, updateWebhookSchema, webhookIdParamSchema, -} from '../validators/webhook-validators'; +} from '../validators/webhook-validators' -const router = Router(); +const router = Router() // All webhook routes require auth -router.use(requireAuth); +router.use(requireAuth) /** * POST /api/webhooks @@ -23,9 +23,9 @@ router.post( '/', validate({ body: createWebhookSchema }), async (req: Request, res: Response) => { - const userId = req.auth!.userId; - const { url, events } = req.body as { url: string; events: string[] }; - const secret = generateWebhookSecret(); + const userId = req.auth!.userId + const { url, events } = req.body as { url: string; events: string[] } + const secret = generateWebhookSecret() const subscription = await (db as any).webhookSubscription.create({ data: { userId, url, events, secret }, @@ -36,19 +36,19 @@ router.post( isActive: true, createdAt: true, }, - }); + }) // Secret is returned only once, at creation time - return res.status(201).json({ ...subscription, secret }); - }, -); + return res.status(201).json({ ...subscription, secret }) + } +) /** * GET /api/webhooks * List all webhook subscriptions for the authenticated user. */ router.get('/', async (req: Request, res: Response) => { - const userId = req.auth!.userId; + const userId = req.auth!.userId const subscriptions = await (db as any).webhookSubscription.findMany({ where: { userId }, @@ -61,10 +61,10 @@ router.get('/', async (req: Request, res: Response) => { updatedAt: true, }, orderBy: { createdAt: 'desc' }, - }); + }) - return res.status(200).json({ subscriptions }); -}); + return res.status(200).json({ subscriptions }) +}) /** * GET /api/webhooks/:id @@ -74,7 +74,7 @@ router.get( '/:id', validate({ params: webhookIdParamSchema }), async (req: Request, res: Response) => { - const userId = req.auth!.userId; + const userId = req.auth!.userId const sub = await (db as any).webhookSubscription.findFirst({ where: { id: req.params.id, userId }, select: { @@ -85,12 +85,12 @@ router.get( createdAt: true, updatedAt: true, }, - }); + }) - if (!sub) return sendNotFound(res, 'Webhook subscription'); - return res.status(200).json(sub); - }, -); + if (!sub) return sendNotFound(res, 'Webhook subscription') + return res.status(200).json(sub) + } +) /** * PATCH /api/webhooks/:id @@ -100,13 +100,13 @@ router.patch( '/:id', validate({ params: webhookIdParamSchema, body: updateWebhookSchema }), async (req: Request, res: Response) => { - const userId = req.auth!.userId; + const userId = req.auth!.userId const existing = await (db as any).webhookSubscription.findFirst({ where: { id: req.params.id, userId }, select: { id: true }, - }); - if (!existing) return sendNotFound(res, 'Webhook subscription'); + }) + if (!existing) return sendNotFound(res, 'Webhook subscription') const updated = await (db as any).webhookSubscription.update({ where: { id: req.params.id }, @@ -118,11 +118,11 @@ router.patch( isActive: true, updatedAt: true, }, - }); + }) - return res.status(200).json(updated); - }, -); + return res.status(200).json(updated) + } +) /** * DELETE /api/webhooks/:id @@ -132,18 +132,20 @@ router.delete( '/:id', validate({ params: webhookIdParamSchema }), async (req: Request, res: Response) => { - const userId = req.auth!.userId; + const userId = req.auth!.userId const existing = await (db as any).webhookSubscription.findFirst({ where: { id: req.params.id, userId }, select: { id: true }, - }); - if (!existing) return sendNotFound(res, 'Webhook subscription'); + }) + if (!existing) return sendNotFound(res, 'Webhook subscription') - await (db as any).webhookSubscription.delete({ where: { id: req.params.id } }); + await (db as any).webhookSubscription.delete({ + where: { id: req.params.id }, + }) - return res.status(204).send(); - }, -); + return res.status(204).send() + } +) -export default router; +export default router diff --git a/src/routes/whatsapp.ts b/src/routes/whatsapp.ts index be6f180..23dcf02 100644 --- a/src/routes/whatsapp.ts +++ b/src/routes/whatsapp.ts @@ -11,7 +11,11 @@ const router = express.Router() * Middleware to allow URL-encoded bodies for Twilio webhooks. * Twilio sends webhook data as application/x-www-form-urlencoded. */ -function allowUrlEncodedBodies(req: Request, _res: Response, next: NextFunction) { +function allowUrlEncodedBodies( + req: Request, + _res: Response, + next: NextFunction +) { ;(req as any).allowUrlEncoded = true next() } @@ -31,41 +35,48 @@ router.get('/webhook', (_req: Request, res: Response) => { * spoofed calls even on staging/dev where NODE_ENV is not 'production'. * https://www.twilio.com/docs/usage/security#validating-requests */ -router.post('/webhook', allowUrlEncodedBodies, validate({ body: whatsappWebhookSchema }), async (req: Request, res: Response) => { - const authToken = process.env.TWILIO_AUTH_TOKEN +router.post( + '/webhook', + allowUrlEncodedBodies, + validate({ body: whatsappWebhookSchema }), + async (req: Request, res: Response) => { + const authToken = process.env.TWILIO_AUTH_TOKEN - if (!authToken) { - // Token not configured: reject rather than silently skip validation - return res.status(403).send('Forbidden: TWILIO_AUTH_TOKEN not configured') - } + if (!authToken) { + // Token not configured: reject rather than silently skip validation + return res.status(403).send('Forbidden: TWILIO_AUTH_TOKEN not configured') + } - const signature = req.header('x-twilio-signature') + const signature = req.header('x-twilio-signature') - if (!signature) { - return res.status(403).send('Forbidden: x-twilio-signature header is required') - } + if (!signature) { + return res + .status(403) + .send('Forbidden: x-twilio-signature header is required') + } - const url = `${req.protocol}://${req.get('host')}${req.originalUrl}` - const isValid = validateRequest(authToken, signature, url, req.body) + const url = `${req.protocol}://${req.get('host')}${req.originalUrl}` + const isValid = validateRequest(authToken, signature, url, req.body) - if (!isValid) { - return res.status(403).send('Forbidden: invalid Twilio signature') - } + if (!isValid) { + return res.status(403).send('Forbidden: invalid Twilio signature') + } - const from = (req.body.From as string) || '' - const body = (req.body.Body as string) || '' + const from = (req.body.From as string) || '' + const body = (req.body.Body as string) || '' - try { - const response = await handleWhatsAppMessage(from, body) - const responseTwiml = new twiml.MessagingResponse() - responseTwiml.message(response.body) - res.type('text/xml').send(responseTwiml.toString()) - } catch (error) { - logger.error('[WhatsApp webhook] error handling message:', error) - const errorTwiml = new twiml.MessagingResponse() - errorTwiml.message('Sorry, something went wrong processing your request.') - res.type('text/xml').send(errorTwiml.toString()) + try { + const response = await handleWhatsAppMessage(from, body) + const responseTwiml = new twiml.MessagingResponse() + responseTwiml.message(response.body) + res.type('text/xml').send(responseTwiml.toString()) + } catch (error) { + logger.error('[WhatsApp webhook] error handling message:', error) + const errorTwiml = new twiml.MessagingResponse() + errorTwiml.message('Sorry, something went wrong processing your request.') + res.type('text/xml').send(errorTwiml.toString()) + } } -}) +) export default router diff --git a/src/services/webhookDispatcher.ts b/src/services/webhookDispatcher.ts index 8f1026b..196e962 100644 --- a/src/services/webhookDispatcher.ts +++ b/src/services/webhookDispatcher.ts @@ -1,13 +1,13 @@ -import db from '../db'; -import { logger } from '../utils/logger'; -import { signPayload } from '../utils/webhookSignature'; -import type { WebhookEvent } from '../validators/webhook-validators'; +import db from '../db' +import { logger } from '../utils/logger' +import { signPayload } from '../utils/webhookSignature' +import type { WebhookEvent } from '../validators/webhook-validators' -const MAX_ATTEMPTS = 3; -const BASE_DELAY_MS = 1000; +const MAX_ATTEMPTS = 3 +const BASE_DELAY_MS = 1000 async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)) } /** @@ -17,30 +17,34 @@ async function sleep(ms: number): Promise { */ export async function dispatchWebhookEvent( event: WebhookEvent, - data: Record, + data: Record ): Promise { const subscriptions = await (db as any).webhookSubscription.findMany({ where: { isActive: true, events: { has: event }, }, - }); + }) - if (subscriptions.length === 0) return; + if (subscriptions.length === 0) return - const payload = JSON.stringify({ event, data, timestamp: new Date().toISOString() }); + const payload = JSON.stringify({ + event, + data, + timestamp: new Date().toISOString(), + }) await Promise.allSettled( - subscriptions.map((sub: any) => deliverToSubscription(sub, event, payload)), - ); + subscriptions.map((sub: any) => deliverToSubscription(sub, event, payload)) + ) } async function deliverToSubscription( sub: { id: string; url: string; secret: string }, event: string, - payload: string, + payload: string ): Promise { - const signature = signPayload(sub.secret, payload); + const signature = signPayload(sub.secret, payload) const delivery = await (db as any).webhookDelivery.create({ data: { @@ -49,15 +53,15 @@ async function deliverToSubscription( payload: JSON.parse(payload), status: 'PENDING', }, - }); + }) - let lastError = ''; - let statusCode: number | undefined; + let lastError = '' + let statusCode: number | undefined for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 10_000); + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 10_000) const res = await fetch(sub.url, { method: 'POST', @@ -67,28 +71,35 @@ async function deliverToSubscription( }, body: payload, signal: controller.signal, - }); - clearTimeout(timer); + }) + clearTimeout(timer) - statusCode = res.status; + statusCode = res.status if (res.ok) { await (db as any).webhookDelivery.update({ where: { id: delivery.id }, - data: { status: 'SUCCESS', statusCode, attempts: attempt, error: null }, - }); - return; + data: { + status: 'SUCCESS', + statusCode, + attempts: attempt, + error: null, + }, + }) + return } - lastError = `HTTP ${res.status}: ${res.statusText}`; + lastError = `HTTP ${res.status}: ${res.statusText}` } catch (err) { - lastError = err instanceof Error ? err.message : String(err); + lastError = err instanceof Error ? err.message : String(err) } - logger.warn(`[Webhook] Delivery attempt ${attempt}/${MAX_ATTEMPTS} failed for ${sub.url}: ${lastError}`); + logger.warn( + `[Webhook] Delivery attempt ${attempt}/${MAX_ATTEMPTS} failed for ${sub.url}: ${lastError}` + ) if (attempt < MAX_ATTEMPTS) { - await sleep(BASE_DELAY_MS * 2 ** (attempt - 1)); // 1s, 2s, 4s + await sleep(BASE_DELAY_MS * 2 ** (attempt - 1)) // 1s, 2s, 4s } } @@ -100,10 +111,13 @@ async function deliverToSubscription( attempts: MAX_ATTEMPTS, error: lastError, }, - }); + }) - logger.error(`[Webhook] All ${MAX_ATTEMPTS} delivery attempts failed for subscription ${sub.id}`, { - url: sub.url, - error: lastError, - }); + logger.error( + `[Webhook] All ${MAX_ATTEMPTS} delivery attempts failed for subscription ${sub.id}`, + { + url: sub.url, + error: lastError, + } + ) } diff --git a/src/stellar/client.ts b/src/stellar/client.ts index 4b5996b..b9f006d 100644 --- a/src/stellar/client.ts +++ b/src/stellar/client.ts @@ -14,39 +14,39 @@ import { Networks, Transaction, Account, -} from '@stellar/stellar-sdk'; -import { config } from '../config'; -import { HttpClientAdapter, TimeoutError } from '../utils/http-client'; -import { logger } from '../utils/logger'; -import { TransactionResult } from './types'; +} from '@stellar/stellar-sdk' +import { config } from '../config' +import { HttpClientAdapter, TimeoutError } from '../utils/http-client' +import { logger } from '../utils/logger' +import { TransactionResult } from './types' import { rpcAttemptCounter, rpcFailoverCounter, rpcCircuitOpenCounter, rpcLatencyHistogram, -} from '../utils/rpc-metrics'; +} from '../utils/rpc-metrics' // ── Network passphrase ──────────────────────────────────────────────────────── export function resolveNetworkPassphrase(network: string | undefined): string { switch (network?.toLowerCase()) { case 'mainnet': - return Networks.PUBLIC; + return Networks.PUBLIC case 'testnet': - return Networks.TESTNET; + return Networks.TESTNET case 'futurenet': - return Networks.FUTURENET; + return Networks.FUTURENET default: throw new Error( `Unknown STELLAR_NETWORK: "${network}". Expected "mainnet", "testnet", or "futurenet".` - ); + ) } } -const NETWORK_PASSPHRASE = resolveNetworkPassphrase(config.stellar.network); +const NETWORK_PASSPHRASE = resolveNetworkPassphrase(config.stellar.network) export function getNetworkPassphrase(): string { - return NETWORK_PASSPHRASE; + return NETWORK_PASSPHRASE } // ── RPC URL list ────────────────────────────────────────────────────────────── @@ -57,33 +57,33 @@ export function getNetworkPassphrase(): string { // 3. config.stellar.rpcUrl function resolveRpcUrls(): string[] { - const multi = process.env.STELLAR_RPC_URLS; + const multi = process.env.STELLAR_RPC_URLS if (multi) { const urls = multi .split(',') - .map(u => u.trim()) - .filter(Boolean); - if (urls.length > 0) return urls; + .map((u) => u.trim()) + .filter(Boolean) + if (urls.length > 0) return urls } - const single = process.env.STELLAR_RPC_URL ?? config.stellar.rpcUrl; - if (single) return [single]; + const single = process.env.STELLAR_RPC_URL ?? config.stellar.rpcUrl + if (single) return [single] throw new Error( 'No Stellar RPC URL configured. Set STELLAR_RPC_URLS or STELLAR_RPC_URL.' - ); + ) } // ── Per-endpoint state ──────────────────────────────────────────────────────── interface EndpointSlot { - url: string; - server: rpc.Server; - adapter: HttpClientAdapter; + url: string + server: rpc.Server + adapter: HttpClientAdapter } function buildSlots(urls: string[]): EndpointSlot[] { - return urls.map(url => ({ + return urls.map((url) => ({ url, server: new rpc.Server(url), adapter: new HttpClientAdapter({ @@ -94,7 +94,7 @@ function buildSlots(urls: string[]): EndpointSlot[] { circuitBreakerThreshold: config.httpClient.circuitBreakerThreshold, circuitBreakerResetMs: config.httpClient.circuitBreakerResetMs, }), - })); + })) } // ── ResilientRpcClient ──────────────────────────────────────────────────────── @@ -106,14 +106,15 @@ function buildSlots(urls: string[]): EndpointSlot[] { * failures are isolated. */ class ResilientRpcClient { - private slots: EndpointSlot[]; + private slots: EndpointSlot[] constructor(urls: string[]) { - if (urls.length === 0) throw new Error('ResilientRpcClient requires at least one URL'); - this.slots = buildSlots(urls); + if (urls.length === 0) + throw new Error('ResilientRpcClient requires at least one URL') + this.slots = buildSlots(urls) logger.info( `[StellarRPC] Initialized with ${urls.length} endpoint(s): ${urls.join(', ')}` - ); + ) } /** Run fn against each endpoint in order, stopping at first success. */ @@ -121,77 +122,87 @@ class ResilientRpcClient { fn: (server: rpc.Server) => Promise, context: string ): Promise { - let lastError: Error | undefined; + let lastError: Error | undefined for (let i = 0; i < this.slots.length; i++) { - const slot = this.slots[i]; - const isPrimary = i === 0; + const slot = this.slots[i] + const isPrimary = i === 0 if (i > 0) { - rpcFailoverCounter.inc({ endpoint: slot.url, context }); + rpcFailoverCounter.inc({ endpoint: slot.url, context }) logger.warn( `[StellarRPC] Failing over to endpoint #${i + 1} (${slot.url}) for "${context}"` - ); + ) } - const cbState = slot.adapter.getState().state; + const cbState = slot.adapter.getState().state if (cbState === 'open') { - rpcCircuitOpenCounter.inc({ endpoint: slot.url, context }); + rpcCircuitOpenCounter.inc({ endpoint: slot.url, context }) logger.warn( `[StellarRPC] Circuit breaker OPEN for ${slot.url}, skipping for "${context}"` - ); - lastError = new Error(`Circuit breaker open for ${slot.url}`); - continue; + ) + lastError = new Error(`Circuit breaker open for ${slot.url}`) + continue } - rpcAttemptCounter.inc({ endpoint: slot.url, context, primary: String(isPrimary) }); - const endTimer = rpcLatencyHistogram.startTimer({ endpoint: slot.url, context }); + rpcAttemptCounter.inc({ + endpoint: slot.url, + context, + primary: String(isPrimary), + }) + const endTimer = rpcLatencyHistogram.startTimer({ + endpoint: slot.url, + context, + }) try { - const result = await slot.adapter.execute(() => fn(slot.server), context); - endTimer({ success: 'true' }); - return result; + const result = await slot.adapter.execute( + () => fn(slot.server), + context + ) + endTimer({ success: 'true' }) + return result } catch (error) { - endTimer({ success: 'false' }); - lastError = error instanceof Error ? error : new Error(String(error)); + endTimer({ success: 'false' }) + lastError = error instanceof Error ? error : new Error(String(error)) logger.warn( `[StellarRPC] Endpoint ${slot.url} failed for "${context}": ${lastError.message}` - ); + ) } } - throw lastError ?? new Error(`All RPC endpoints failed for "${context}"`); + throw lastError ?? new Error(`All RPC endpoints failed for "${context}"`) } /** Expose underlying servers (for advanced callers that need direct access). */ getPrimaryServer(): rpc.Server { - return this.slots[0].server; + return this.slots[0].server } /** Health snapshot for diagnostics. */ getHealthSnapshot(): Array<{ url: string; state: string; failures: number }> { - return this.slots.map(s => ({ + return this.slots.map((s) => ({ url: s.url, ...s.adapter.getState(), - })); + })) } /** Reset all circuit breakers (e.g. after operator confirms endpoints are healthy). */ resetAll(): void { - this.slots.forEach(s => s.adapter.reset()); - logger.info('[StellarRPC] All circuit breakers reset'); + this.slots.forEach((s) => s.adapter.reset()) + logger.info('[StellarRPC] All circuit breakers reset') } } // ── Singletons ──────────────────────────────────────────────────────────────── -let resilientClient: ResilientRpcClient | null = null; +let resilientClient: ResilientRpcClient | null = null export function getResilientClient(): ResilientRpcClient { if (!resilientClient) { - resilientClient = new ResilientRpcClient(resolveRpcUrls()); + resilientClient = new ResilientRpcClient(resolveRpcUrls()) } - return resilientClient; + return resilientClient } /** @@ -199,32 +210,34 @@ export function getResilientClient(): ResilientRpcClient { * Kept for callers that need a raw rpc.Server reference (e.g. legacy event listener). */ export function getRpcServer(): rpc.Server { - return getResilientClient().getPrimaryServer(); + return getResilientClient().getPrimaryServer() } // ── Agent keypair ───────────────────────────────────────────────────────────── -let agentKeypair: Keypair | null = null; +let agentKeypair: Keypair | null = null export function getAgentKeypair(): Keypair { if (!agentKeypair) { - const secret = process.env.STELLAR_AGENT_SECRET_KEY; - if (!secret) throw new Error('STELLAR_AGENT_SECRET_KEY not configured'); - agentKeypair = Keypair.fromSecret(secret); + const secret = process.env.STELLAR_AGENT_SECRET_KEY + if (!secret) throw new Error('STELLAR_AGENT_SECRET_KEY not configured') + agentKeypair = Keypair.fromSecret(secret) } - return agentKeypair; + return agentKeypair } // ── Public RPC helpers ──────────────────────────────────────────────────────── export async function submitTransaction(tx: Transaction): Promise { return getResilientClient().execute(async (server) => { - const response = await server.sendTransaction(tx); + const response = await server.sendTransaction(tx) if (response.status === 'ERROR') { - throw new Error(`Transaction failed: ${response.errorResult?.toXDR('base64')}`); + throw new Error( + `Transaction failed: ${response.errorResult?.toXDR('base64')}` + ) } - return response.hash; - }, 'stellar.submitTransaction'); + return response.hash + }, 'stellar.submitTransaction') } export async function simulateTransaction( @@ -233,28 +246,30 @@ export async function simulateTransaction( return getResilientClient().execute( (server) => server.simulateTransaction(tx), 'stellar.simulateTransaction' - ); + ) } -export async function prepareTransaction(tx: Transaction): Promise { +export async function prepareTransaction( + tx: Transaction +): Promise { return getResilientClient().execute( (server) => server.prepareTransaction(tx) as Promise, 'stellar.prepareTransaction' - ); + ) } export async function getAccount(publicKey: string): Promise { return getResilientClient().execute( (server) => server.getAccount(publicKey), 'stellar.getAccount' - ); + ) } export async function waitForConfirmation( txHash: string, timeoutMs: number = 30_000 ): Promise { - const pollDeadline = Date.now() + timeoutMs; + const pollDeadline = Date.now() + timeoutMs // Polling uses the resilient client so individual poll failures also // benefit from per-endpoint circuit breaking. @@ -262,33 +277,33 @@ export async function waitForConfirmation( const response = await getResilientClient().execute( (server) => server.getTransaction(txHash), 'stellar.waitForConfirmation' - ); + ) if (response.status === 'SUCCESS') { - return { hash: txHash, status: 'success', ledger: response.ledger }; + return { hash: txHash, status: 'success', ledger: response.ledger } } if (response.status === 'FAILED') { - return { hash: txHash, status: 'failed' }; + return { hash: txHash, status: 'failed' } } if (Date.now() >= pollDeadline) { - throw new Error(`Transaction confirmation timeout after ${timeoutMs}ms`); + throw new Error(`Transaction confirmation timeout after ${timeoutMs}ms`) } - await new Promise(resolve => setTimeout(resolve, 1_000)); - return poll(); - }; + await new Promise((resolve) => setTimeout(resolve, 1_000)) + return poll() + } - return poll(); + return poll() } /** Diagnostic helper — returns circuit-breaker state for all endpoints. */ export function getRpcHealthSnapshot() { - return getResilientClient().getHealthSnapshot(); + return getResilientClient().getHealthSnapshot() } /** Operator escape-hatch — reset all circuit breakers without restarting. */ export function resetRpcCircuitBreakers(): void { - getResilientClient().resetAll(); -} \ No newline at end of file + getResilientClient().resetAll() +} diff --git a/src/stellar/events.ts b/src/stellar/events.ts index 1f45f51..6039f03 100644 --- a/src/stellar/events.ts +++ b/src/stellar/events.ts @@ -34,6 +34,10 @@ import { } from '../utils/metrics' import { dispatchWebhookEvent } from '../services/webhookDispatcher' import { checkAndActivateOnDeposit } from '../referral/service' +import { + createLotForDeposit, + recordDisposalsForWithdrawal, +} from '../tax/service' const VAULT_CONTRACT_ID = config.stellar.vaultContractId const POLL_INTERVAL_MS = 5000 @@ -311,6 +315,19 @@ async function handleDepositEvent( depositData.amount, tx ) + + // Tax cost-basis lot (#284): one lot per confirmed deposit Transaction, + // created on the same `tx` handle so it is part of the deposit's DB + // transaction. Never throws (a tax-bookkeeping problem must not roll back + // the deposit) — failures log + alert and are backfillable. + await createLotForDeposit( + user.id, + transaction.id, + depositData.assetSymbol, + depositData.amount, + transaction.confirmedAt ?? new Date(), + tx + ) } /** @@ -385,6 +402,19 @@ async function handleWithdrawEvent( }) ) } + + // Tax FIFO disposals (#284): recorded on the same `tx` handle, even when no + // Position matched — the confirmed Transaction, not the Position, is the + // disposal source of truth. Never throws; a shortfall alerts critically and + // writes nothing (idempotent re-run after backfill repairs the ledger). + await recordDisposalsForWithdrawal( + user.id, + transaction.id, + withdrawData.assetSymbol, + withdrawData.amount, + transaction.confirmedAt ?? new Date(), + tx + ) } /** diff --git a/src/stellar/index.ts b/src/stellar/index.ts index 2d596a3..6fe1e1c 100644 --- a/src/stellar/index.ts +++ b/src/stellar/index.ts @@ -1,6 +1,6 @@ // Stellar Integration Layer - Main Export -export * from './client'; -export * from './contract'; -export * from './events'; -export * from './wallet'; -export * from './types'; +export * from './client' +export * from './contract' +export * from './events' +export * from './wallet' +export * from './types' diff --git a/src/stellar/types.ts b/src/stellar/types.ts index ad82711..7b5b9a8 100644 --- a/src/stellar/types.ts +++ b/src/stellar/types.ts @@ -1,64 +1,64 @@ -import { xdr } from '@stellar/stellar-sdk'; -import { Network } from '@prisma/client'; +import { xdr } from '@stellar/stellar-sdk' +import { Network } from '@prisma/client' export interface ContractEvent { - type: 'deposit' | 'withdraw' | 'rebalance'; - ledger: number; - txHash: string; - contractId: string; - topics: xdr.ScVal[]; - value: xdr.ScVal; + type: 'deposit' | 'withdraw' | 'rebalance' + ledger: number + txHash: string + contractId: string + topics: xdr.ScVal[] + value: xdr.ScVal } export interface DepositEvent { - user: string; - amount: string; - shares: string; - assetSymbol: string; - protocolName: string; - network: Network; + user: string + amount: string + shares: string + assetSymbol: string + protocolName: string + network: Network } export interface WithdrawEvent { - user: string; - amount: string; - shares: string; - assetSymbol: string; - protocolName: string; - network: Network; + user: string + amount: string + shares: string + assetSymbol: string + protocolName: string + network: Network } export interface RebalanceEvent { - protocol: string; - apy: number; - timestamp: number; - assetSymbol: string; - network: Network; + protocol: string + apy: number + timestamp: number + assetSymbol: string + network: Network } export interface EventMetrics { - totalProcessed: number; - totalErrors: number; - processingRatePerMinute: number; - errorRate: number; - ledgerLag: number; - lastDbOperationMs: number; - lastUpdated: Date; + totalProcessed: number + totalErrors: number + processingRatePerMinute: number + errorRate: number + ledgerLag: number + lastDbOperationMs: number + lastUpdated: Date } export interface TransactionResult { - hash: string; - status: 'success' | 'failed'; - ledger?: number; + hash: string + status: 'success' | 'failed' + ledger?: number } export interface OnChainBalance { - balance: string; - shares: string; + balance: string + shares: string } export interface VaultState { - totalAssets: string; - apy: number; - activeProtocol: string; + totalAssets: string + apy: number + activeProtocol: string } diff --git a/src/stellar/wallet.ts b/src/stellar/wallet.ts index f13a636..4c04fb9 100644 --- a/src/stellar/wallet.ts +++ b/src/stellar/wallet.ts @@ -1,10 +1,10 @@ -import { Keypair } from '@stellar/stellar-sdk'; -import * as crypto from 'crypto'; -import db from '../db'; -import { logger } from '../utils/logger'; +import { Keypair } from '@stellar/stellar-sdk' +import * as crypto from 'crypto' +import db from '../db' +import { logger } from '../utils/logger' -const ALGORITHM = 'aes-256-gcm'; -const HEX_64_REGEX = /^[0-9a-fA-F]{64}$/; +const ALGORITHM = 'aes-256-gcm' +const HEX_64_REGEX = /^[0-9a-fA-F]{64}$/ /** * Validate that a value is exactly 64 hex characters (32 bytes). @@ -19,10 +19,12 @@ const HEX_64_REGEX = /^[0-9a-fA-F]{64}$/; */ function assertValidHexKey(value: string, label: string): void { if (!value || value.length !== 64) { - throw new Error(`${label} must be 64 hex characters (32 bytes)`); + throw new Error(`${label} must be 64 hex characters (32 bytes)`) } if (!HEX_64_REGEX.test(value)) { - throw new Error(`${label} must contain only hexadecimal characters (0-9, a-f, A-F)`); + throw new Error( + `${label} must contain only hexadecimal characters (0-9, a-f, A-F)` + ) } } @@ -31,9 +33,9 @@ function assertValidHexKey(value: string, label: string): void { * Must be 64 hex characters (32 bytes). */ function getEncryptionKey(): string { - const key = process.env.WALLET_ENCRYPTION_KEY || ''; - assertValidHexKey(key, 'WALLET_ENCRYPTION_KEY'); - return key; + const key = process.env.WALLET_ENCRYPTION_KEY || '' + assertValidHexKey(key, 'WALLET_ENCRYPTION_KEY') + return key } /** @@ -43,34 +45,38 @@ function getEncryptionKey(): string { * rather than throwing, since a bad fallback key shouldn't break primary reads). */ function getFallbackEncryptionKey(): string | undefined { - const key = process.env.WALLET_ENCRYPTION_KEY_OLD; - if (!key) return undefined; + const key = process.env.WALLET_ENCRYPTION_KEY_OLD + if (!key) return undefined try { - assertValidHexKey(key, 'WALLET_ENCRYPTION_KEY_OLD'); + assertValidHexKey(key, 'WALLET_ENCRYPTION_KEY_OLD') } catch (err) { logger.warn( `WALLET_ENCRYPTION_KEY_OLD invalid; ignoring fallback key: ${err instanceof Error ? err.message : 'unknown error'}` - ); - return undefined; + ) + return undefined } - return key; + return key } -function encryptSecret(secret: string): { encrypted: string; iv: string; authTag: string } { - const key = Buffer.from(getEncryptionKey(), 'hex'); - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); +function encryptSecret(secret: string): { + encrypted: string + iv: string + authTag: string +} { + const key = Buffer.from(getEncryptionKey(), 'hex') + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) - let encrypted = cipher.update(secret, 'utf8', 'hex'); - encrypted += cipher.final('hex'); + let encrypted = cipher.update(secret, 'utf8', 'hex') + encrypted += cipher.final('hex') return { encrypted, iv: iv.toString('hex'), authTag: cipher.getAuthTag().toString('hex'), - }; + } } /** @@ -78,14 +84,18 @@ function encryptSecret(secret: string): { encrypted: string; iv: string; authTag * Throws if decryption fails. */ function decryptSecret(encrypted: string, iv: string, authTag: string): string { - const key = Buffer.from(getEncryptionKey(), 'hex'); - const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex')); - decipher.setAuthTag(Buffer.from(authTag, 'hex')); - - let decrypted = decipher.update(encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - - return decrypted; + const key = Buffer.from(getEncryptionKey(), 'hex') + const decipher = crypto.createDecipheriv( + ALGORITHM, + key, + Buffer.from(iv, 'hex') + ) + decipher.setAuthTag(Buffer.from(authTag, 'hex')) + + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + return decrypted } /** @@ -103,35 +113,47 @@ function decryptSecretDualKey( ): { secret: string; keyUsed: 'primary' | 'fallback' } { // Try primary key try { - const key = Buffer.from(getEncryptionKey(), 'hex'); - const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex')); - decipher.setAuthTag(Buffer.from(authTag, 'hex')); - - let decrypted = decipher.update(encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - - return { secret: decrypted, keyUsed: 'primary' }; + const key = Buffer.from(getEncryptionKey(), 'hex') + const decipher = crypto.createDecipheriv( + ALGORITHM, + key, + Buffer.from(iv, 'hex') + ) + decipher.setAuthTag(Buffer.from(authTag, 'hex')) + + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + return { secret: decrypted, keyUsed: 'primary' } } catch (err) { // Try fallback key if available - const fallbackKey = getFallbackEncryptionKey(); + const fallbackKey = getFallbackEncryptionKey() if (!fallbackKey) { - throw new Error(`Decryption with primary key failed: ${err instanceof Error ? err.message : 'unknown error'}`); + throw new Error( + `Decryption with primary key failed: ${err instanceof Error ? err.message : 'unknown error'}` + ) } try { - const key = Buffer.from(fallbackKey, 'hex'); - const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex')); - decipher.setAuthTag(Buffer.from(authTag, 'hex')); - - let decrypted = decipher.update(encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - - logger.info('[Wallet] Decrypted with fallback key; consider re-encrypting with primary key'); - return { secret: decrypted, keyUsed: 'fallback' }; + const key = Buffer.from(fallbackKey, 'hex') + const decipher = crypto.createDecipheriv( + ALGORITHM, + key, + Buffer.from(iv, 'hex') + ) + decipher.setAuthTag(Buffer.from(authTag, 'hex')) + + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + logger.info( + '[Wallet] Decrypted with fallback key; consider re-encrypting with primary key' + ) + return { secret: decrypted, keyUsed: 'fallback' } } catch (fallbackErr) { throw new Error( `Decryption failed with both primary and fallback keys: ${fallbackErr instanceof Error ? fallbackErr.message : 'unknown error'}` - ); + ) } } } @@ -150,13 +172,13 @@ function decryptSecretDualKey( * key means wallets cannot be recovered. */ export async function createCustodialWallet(userId: string) { - const existing = await db.custodialWallet.findUnique({ where: { userId } }); + const existing = await db.custodialWallet.findUnique({ where: { userId } }) if (existing) { - throw new Error(`Wallet already exists for user ${userId}`); + throw new Error(`Wallet already exists for user ${userId}`) } - const keypair = Keypair.random(); - const { encrypted, iv, authTag } = encryptSecret(keypair.secret()); + const keypair = Keypair.random() + const { encrypted, iv, authTag } = encryptSecret(keypair.secret()) const wallet = await db.custodialWallet.create({ data: { @@ -167,17 +189,17 @@ export async function createCustodialWallet(userId: string) { authTag, keyVersion: 2, }, - }); + }) - logger.info(`[Wallet] Created for user ${userId}: ${wallet.publicKey}`); - return wallet; + logger.info(`[Wallet] Created for user ${userId}: ${wallet.publicKey}`) + return wallet } /** * Get wallet record by user ID. */ export async function getWalletByUserId(userId: string) { - return db.custodialWallet.findUnique({ where: { userId } }); + return db.custodialWallet.findUnique({ where: { userId } }) } /** @@ -185,10 +207,10 @@ export async function getWalletByUserId(userId: string) { * Supports dual-key reads if WALLET_ENCRYPTION_KEY_OLD is configured. */ export async function getKeypairForUser(userId: string): Promise { - const wallet = await getWalletByUserId(userId); + const wallet = await getWalletByUserId(userId) if (!wallet) { - throw new Error(`No wallet found for user ${userId}`); + throw new Error(`No wallet found for user ${userId}`) } // Use dual-key decryption for smooth key rotation support @@ -196,16 +218,18 @@ export async function getKeypairForUser(userId: string): Promise { wallet.encryptedSecret, wallet.iv, wallet.authTag - ); + ) if (keyUsed === 'fallback') { - logger.debug(`[Wallet] User ${userId} decrypted with fallback key; schedule re-encryption`); + logger.debug( + `[Wallet] User ${userId} decrypted with fallback key; schedule re-encryption` + ) } // Lazy re-encryption for wallets on v1 if (wallet.keyVersion === 1) { try { - const { encrypted, iv, authTag } = encryptSecret(secret); + const { encrypted, iv, authTag } = encryptSecret(secret) await db.custodialWallet.update({ where: { id: wallet.id }, data: { @@ -214,23 +238,29 @@ export async function getKeypairForUser(userId: string): Promise { authTag, keyVersion: 2, }, - }); - logger.info(`[Wallet] Upgraded user ${userId} to keyVersion 2 via lazy re-encryption`); + }) + logger.info( + `[Wallet] Upgraded user ${userId} to keyVersion 2 via lazy re-encryption` + ) } catch (err) { - logger.error(`[Wallet] Failed to lazy re-encrypt user ${userId}: ${err instanceof Error ? err.message : 'unknown error'}`); + logger.error( + `[Wallet] Failed to lazy re-encrypt user ${userId}: ${err instanceof Error ? err.message : 'unknown error'}` + ) // Non-fatal, we still return the keypair } } - return Keypair.fromSecret(secret); + return Keypair.fromSecret(secret) } /** * List all wallet public keys (for admin/debugging). */ export async function listWallets(): Promise { - const wallets = await db.custodialWallet.findMany({ select: { publicKey: true } }); - return wallets.map(w => w.publicKey); + const wallets = await db.custodialWallet.findMany({ + select: { publicKey: true }, + }) + return wallets.map((w) => w.publicKey) } /** @@ -242,7 +272,7 @@ export function decryptSecretWithPrimaryKey( iv: string, authTag: string ): string { - return decryptSecret(encrypted, iv, authTag); + return decryptSecret(encrypted, iv, authTag) } /** @@ -258,7 +288,7 @@ export function decryptSecretWithFallback( iv: string, authTag: string ): { secret: string; keyUsed: 'primary' | 'fallback' } { - return decryptSecretDualKey(encrypted, iv, authTag); + return decryptSecretDualKey(encrypted, iv, authTag) } /** @@ -275,18 +305,18 @@ export function createEncryptedSecretWithKey( secret: string, keyHex: string ): { encrypted: string; iv: string; authTag: string } { - assertValidHexKey(keyHex, 'Key'); + assertValidHexKey(keyHex, 'Key') - const key = Buffer.from(keyHex, 'hex'); - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const key = Buffer.from(keyHex, 'hex') + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) - let encrypted = cipher.update(secret, 'utf8', 'hex'); - encrypted += cipher.final('hex'); + let encrypted = cipher.update(secret, 'utf8', 'hex') + encrypted += cipher.final('hex') return { encrypted, iv: iv.toString('hex'), authTag: cipher.getAuthTag().toString('hex'), - }; -} \ No newline at end of file + } +} diff --git a/src/tax/fifo.ts b/src/tax/fifo.ts new file mode 100644 index 0000000..4e7a08d --- /dev/null +++ b/src/tax/fifo.ts @@ -0,0 +1,115 @@ +/** + * Pure FIFO lot-consumption engine for tax cost-basis tracking (#284). + * + * No database access — callers load open lots, run this, then persist the + * returned instructions transactionally (see src/tax/service.ts). Keeping the + * accounting method here (not in the schema) means LIFO/HIFO could be added + * later as sibling functions without a schema change. + */ +import { Decimal } from '@prisma/client/runtime/library' + +export interface OpenLot { + id: string + remainingAmount: Decimal + acquisitionPrice: Decimal | null + acquiredAt: Date +} + +export interface DisposalInstruction { + lotId: string + amount: Decimal + disposalPrice: Decimal | null + // Null when the lot's acquisition price is unknown — never zero, so + // unpriced disposals are visibly excluded from report totals. + costBasis: Decimal | null + proceeds: Decimal | null + realizedGain: Decimal | null +} + +export interface FifoResult { + disposals: DisposalInstruction[] + updatedLots: { id: string; remainingAmount: Decimal }[] +} + +export class InsufficientLotsError extends Error { + readonly requested: Decimal + readonly available: Decimal + readonly shortfall: Decimal + + constructor(requested: Decimal, available: Decimal) { + super( + `Insufficient lot balance: requested ${requested.toString()}, available ${available.toString()}` + ) + this.name = 'InsufficientLotsError' + this.requested = requested + this.available = available + this.shortfall = requested.minus(available) + } +} + +/** + * Consume `amount` from `lots` in FIFO order (acquiredAt asc, id as a stable + * tiebreak). All-or-nothing: throws InsufficientLotsError before producing any + * instructions if the open lots cannot cover the full amount — partial + * disposal rows written under an error path would poison later repair. + */ +export function consumeLotsFifo( + lots: OpenLot[], + amount: Decimal, + disposalPrice: Decimal | null +): FifoResult { + if (amount.isZero()) { + return { disposals: [], updatedLots: [] } + } + + const openLots = lots + .filter((lot) => lot.remainingAmount.greaterThan(0)) + .sort((a, b) => { + const byTime = a.acquiredAt.getTime() - b.acquiredAt.getTime() + if (byTime !== 0) return byTime + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + }) + + const available = openLots.reduce( + (sum, lot) => sum.plus(lot.remainingAmount), + new Decimal(0) + ) + if (available.lessThan(amount)) { + throw new InsufficientLotsError(amount, available) + } + + const disposals: DisposalInstruction[] = [] + const updatedLots: FifoResult['updatedLots'] = [] + let remaining = amount + + for (const lot of openLots) { + if (remaining.isZero()) break + + const consumed = Decimal.min(lot.remainingAmount, remaining) + remaining = remaining.minus(consumed) + updatedLots.push({ + id: lot.id, + remainingAmount: lot.remainingAmount.minus(consumed), + }) + + const costBasis = + lot.acquisitionPrice !== null + ? consumed.times(lot.acquisitionPrice) + : null + const proceeds = + disposalPrice !== null ? consumed.times(disposalPrice) : null + const realizedGain = + costBasis !== null && proceeds !== null ? proceeds.minus(costBasis) : null + + disposals.push({ + lotId: lot.id, + amount: consumed, + disposalPrice, + costBasis, + proceeds, + realizedGain, + }) + } + + return { disposals, updatedLots } +} diff --git a/src/tax/pricing.ts b/src/tax/pricing.ts new file mode 100644 index 0000000..ae9ed8e --- /dev/null +++ b/src/tax/pricing.ts @@ -0,0 +1,22 @@ +/** + * USD pricing for tax lots (#284). v1 prices stablecoins only: USDC is + * assumed 1:1 USD with an explicit STABLECOIN_ASSUMPTION source surfaced in + * the report. Any other asset returns a null price, is flagged per-lot and + * per-disposal, and is excluded from report totals with a visible caveat — + * never silently zeroed. Prices are per token; amounts must be token units + * (see docs/TAX_REPORT.md "Units"). + */ +import { PriceSource } from '@prisma/client' +import { Decimal } from '@prisma/client/runtime/library' + +export interface AssetPrice { + price: Decimal | null + source: PriceSource | null +} + +export function priceForAsset(assetSymbol: string): AssetPrice { + if (assetSymbol === 'USDC') { + return { price: new Decimal(1), source: PriceSource.STABLECOIN_ASSUMPTION } + } + return { price: null, source: null } +} diff --git a/src/tax/report.ts b/src/tax/report.ts new file mode 100644 index 0000000..b4382ab --- /dev/null +++ b/src/tax/report.ts @@ -0,0 +1,156 @@ +/** + * Tax report assembly (#284). A pure read over the LotDisposal ledger — + * disposal rows snapshot cost basis / proceeds / gain at disposal time, so the + * report never recomputes money from mutable state. Totals include only fully + * priced disposals; unpriced ones are flagged and counted in caveats, never + * zeroed into the sums. Year boundaries are UTC. + */ +import { Prisma } from '@prisma/client' +import { Decimal } from '@prisma/client/runtime/library' +import db from '../db' +import { CsvValue } from '../utils/csv' + +type Db = typeof db | Prisma.TransactionClient + +export interface TaxReportDisposal { + disposedAt: string + assetSymbol: string + amount: string + withdrawalTxHash: string | null + acquiredAt: string + acquisitionTxHash: string | null + acquisitionPrice: string | null + disposalPrice: string | null + costBasis: string | null + proceeds: string | null + realizedGain: string | null + priced: boolean +} + +export interface TaxReport { + userId: string + year: number + method: 'FIFO' + disposals: TaxReportDisposal[] + totals: { + proceeds: string + costBasis: string + realizedGain: string + pricedDisposalCount: number + } + caveats: { + unpricedDisposalCount: number + unpricedAssets: string[] + stablecoinAssumption: string + rebalancesNotIncluded: string + } +} + +const str = (value: Decimal | null): string | null => + value === null ? null : new Decimal(value).toString() + +export async function buildTaxReport( + userId: string, + year: number, + database: Db = db +): Promise { + const rows = await (database as any).lotDisposal.findMany({ + where: { + userId, + disposedAt: { + gte: new Date(Date.UTC(year, 0, 1)), + lt: new Date(Date.UTC(year + 1, 0, 1)), + }, + }, + include: { + lot: { include: { transaction: { select: { txHash: true } } } }, + transaction: { select: { txHash: true } }, + }, + orderBy: [{ disposedAt: 'asc' }, { createdAt: 'asc' }], + }) + + const disposals: TaxReportDisposal[] = rows.map((row: any) => ({ + disposedAt: row.disposedAt.toISOString(), + assetSymbol: row.assetSymbol, + amount: new Decimal(row.amount).toString(), + withdrawalTxHash: row.transaction?.txHash ?? null, + acquiredAt: row.lot.acquiredAt.toISOString(), + acquisitionTxHash: row.lot.transaction?.txHash ?? null, + acquisitionPrice: str(row.lot.acquisitionPrice), + disposalPrice: str(row.disposalPrice), + costBasis: str(row.costBasis), + proceeds: str(row.proceeds), + realizedGain: str(row.realizedGain), + priced: row.realizedGain !== null, + })) + + let proceeds = new Decimal(0) + let costBasis = new Decimal(0) + let realizedGain = new Decimal(0) + let pricedDisposalCount = 0 + const unpricedAssets = new Set() + + for (const disposal of disposals) { + if (disposal.priced) { + proceeds = proceeds.plus(disposal.proceeds as string) + costBasis = costBasis.plus(disposal.costBasis as string) + realizedGain = realizedGain.plus(disposal.realizedGain as string) + pricedDisposalCount++ + } else { + unpricedAssets.add(disposal.assetSymbol) + } + } + + return { + userId, + year, + method: 'FIFO', + disposals, + totals: { + proceeds: proceeds.toString(), + costBasis: costBasis.toString(), + realizedGain: realizedGain.toString(), + pricedDisposalCount, + }, + caveats: { + unpricedDisposalCount: disposals.length - pricedDisposalCount, + unpricedAssets: [...unpricedAssets].sort(), + stablecoinAssumption: + 'USDC is priced at 1.00 USD by assumption (STABLECOIN_ASSUMPTION); no market price feed is used.', + rebalancesNotIncluded: + 'Protocol rebalances are same-asset transfers and are not treated as taxable disposals in this report.', + }, + } +} + +export const TAX_REPORT_CSV_HEADERS = [ + 'disposedAt', + 'assetSymbol', + 'amount', + 'withdrawalTxHash', + 'acquiredAt', + 'acquisitionTxHash', + 'acquisitionPrice', + 'disposalPrice', + 'costBasis', + 'proceeds', + 'realizedGain', + 'priced', +] + +export function taxReportToCsvRows(report: TaxReport): CsvValue[][] { + return report.disposals.map((d) => [ + d.disposedAt, + d.assetSymbol, + d.amount, + d.withdrawalTxHash, + d.acquiredAt, + d.acquisitionTxHash, + d.acquisitionPrice, + d.disposalPrice, + d.costBasis, + d.proceeds, + d.realizedGain, + d.priced, + ]) +} diff --git a/src/tax/service.ts b/src/tax/service.ts new file mode 100644 index 0000000..eb9d7bd --- /dev/null +++ b/src/tax/service.ts @@ -0,0 +1,230 @@ +/** + * Cost-basis lot bookkeeping (#284). + * + * Called from the confirmed deposit/withdrawal paths (src/stellar/events.ts) + * INSIDE the event listener's DB transaction, on the shared `tx` handle — so + * lots/disposals are transactionally consistent with the Transaction/Position + * writes they derive from. + * + * Never throws: the CONFIRMED Transaction is the durable source of truth and + * lots are deterministically reconstructible from it (see + * scripts/backfill-cost-basis-lots.ts), so rolling back a confirmed on-chain + * deposit/withdrawal to protect derived bookkeeping would invert the + * dependency. Failures are loud instead: logger.error + fire-and-forget alert + * (no awaited network I/O inside the DB transaction), reconcilable via the + * queries in docs/TAX_REPORT.md. + */ +import { Prisma } from '@prisma/client' +import { Decimal } from '@prisma/client/runtime/library' +import db from '../db' +import { logger } from '../utils/logger' +import { alertingService } from '../services/alerting' +import { consumeLotsFifo, InsufficientLotsError } from './fifo' +import { priceForAsset } from './pricing' + +type Db = typeof db | Prisma.TransactionClient + +/** + * Fire-and-forget alert. Alerting must never break the money path, so both + * synchronous throws (e.g. a broken/stubbed alerting module) and rejected + * promises are swallowed. + */ +function safeAlert( + payload: Parameters[0], + dedupeKey: string +): void { + try { + void alertingService.emit(payload, dedupeKey).catch(() => {}) + } catch { + // deliberately ignored + } +} + +/** + * Create the cost-basis lot for a confirmed deposit Transaction. Idempotent + * under event replay via the unique constraint on transactionId (P2002 is a + * benign duplicate and only debug-logged). + */ +export async function createLotForDeposit( + userId: string, + transactionId: string, + assetSymbol: string, + amount: Decimal | string | number, + acquiredAt: Date, + database: Db = db +): Promise { + try { + const { price, source } = priceForAsset(assetSymbol) + const lotAmount = new Decimal(amount) + + await (database as any).costBasisLot.create({ + data: { + userId, + transactionId, + assetSymbol, + originalAmount: lotAmount, + remainingAmount: lotAmount, + acquisitionPrice: price, + priceSource: source, + acquiredAt, + }, + }) + + logger.info('[Tax] Cost-basis lot created', { + userId, + transactionId, + assetSymbol, + amount: lotAmount.toString(), + priced: price !== null, + }) + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + // Event replay / non-transactional fallback re-ran the handler — the lot + // already exists, which is exactly what idempotency wants. + logger.debug('[Tax] Lot already exists for transaction — skipping', { + transactionId, + }) + return + } + const message = err instanceof Error ? err.message : String(err) + logger.error('[Tax] Lot creation failed (deposit unaffected)', { + userId, + transactionId, + assetSymbol, + error: message, + }) + safeAlert( + { + title: 'Cost-basis lot creation failed', + description: `Lot creation for deposit transaction ${transactionId} (user ${userId}) failed: ${message}. The deposit is unaffected; run scripts/backfill-cost-basis-lots.ts to reconcile.`, + severity: 'warning', + component: 'tax-lot-tracking', + metadata: { userId, transactionId, assetSymbol }, + }, + `tax:lot-create:${transactionId}` + ) + } +} + +/** + * Record FIFO disposals for a confirmed withdrawal Transaction. Idempotent: + * if any disposal already exists for this transactionId the call is a no-op + * (event replay). All-or-nothing: when open lots cannot cover the withdrawal, + * nothing is written — partial rows written under an error path would poison + * later repair, while an idempotent re-run after backfill produces the correct + * ledger. That case alerts critically but never blocks the withdrawal. + */ +export async function recordDisposalsForWithdrawal( + userId: string, + transactionId: string, + assetSymbol: string, + amount: Decimal | string | number, + disposedAt: Date, + database: Db = db +): Promise { + try { + const existing = await (database as any).lotDisposal.findFirst({ + where: { transactionId }, + select: { id: true }, + }) + if (existing) { + logger.debug( + '[Tax] Disposals already recorded for transaction — skipping', + { + transactionId, + } + ) + return + } + + const openLots = await (database as any).costBasisLot.findMany({ + where: { userId, assetSymbol, remainingAmount: { gt: 0 } }, + orderBy: [{ acquiredAt: 'asc' }, { id: 'asc' }], + }) + + const { price } = priceForAsset(assetSymbol) + const { disposals, updatedLots } = consumeLotsFifo( + openLots.map((lot: any) => ({ + id: lot.id, + remainingAmount: new Decimal(lot.remainingAmount), + acquisitionPrice: + lot.acquisitionPrice === null + ? null + : new Decimal(lot.acquisitionPrice), + acquiredAt: lot.acquiredAt, + })), + new Decimal(amount), + price + ) + + for (const lot of updatedLots) { + await (database as any).costBasisLot.update({ + where: { id: lot.id }, + data: { remainingAmount: lot.remainingAmount }, + }) + } + for (const disposal of disposals) { + await (database as any).lotDisposal.create({ + data: { + lotId: disposal.lotId, + userId, + assetSymbol, + transactionId, + amount: disposal.amount, + disposalPrice: disposal.disposalPrice, + costBasis: disposal.costBasis, + proceeds: disposal.proceeds, + realizedGain: disposal.realizedGain, + disposedAt, + }, + }) + } + + logger.info('[Tax] Withdrawal disposals recorded', { + userId, + transactionId, + assetSymbol, + amount: new Decimal(amount).toString(), + lotsConsumed: disposals.length, + }) + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + // Concurrent replay raced past the exists-check — rows already recorded. + logger.debug('[Tax] Disposal rows already exist — skipping', { + transactionId, + }) + return + } + const message = err instanceof Error ? err.message : String(err) + const isShortfall = err instanceof InsufficientLotsError + logger.error('[Tax] Disposal recording failed (withdrawal unaffected)', { + userId, + transactionId, + assetSymbol, + error: message, + ...(isShortfall && { + requested: (err as InsufficientLotsError).requested.toString(), + available: (err as InsufficientLotsError).available.toString(), + shortfall: (err as InsufficientLotsError).shortfall.toString(), + }), + }) + safeAlert( + { + title: isShortfall + ? 'Withdrawal exceeds tracked cost-basis lots' + : 'Disposal recording failed', + description: `Recording disposals for withdrawal transaction ${transactionId} (user ${userId}) failed: ${message}. Nothing was written; the withdrawal is unaffected. Backfill/repair lots (scripts/backfill-cost-basis-lots.ts) — the recorder is idempotent and safe to re-run.`, + severity: 'critical', + component: 'tax-lot-tracking', + metadata: { userId, transactionId, assetSymbol }, + }, + `tax:disposal:${transactionId}` + ) + } +} diff --git a/src/telemetry/otel.ts b/src/telemetry/otel.ts index b16637c..8a5f24a 100644 --- a/src/telemetry/otel.ts +++ b/src/telemetry/otel.ts @@ -15,12 +15,18 @@ import { NodeSDK } from '@opentelemetry/sdk-node' import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' import { resourceFromAttributes } from '@opentelemetry/resources' -import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions' -import { SimpleSpanProcessor, ConsoleSpanExporter, BatchSpanProcessor } from '@opentelemetry/sdk-trace-node' +import { + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, +} from '@opentelemetry/semantic-conventions' +import { + SimpleSpanProcessor, + ConsoleSpanExporter, + BatchSpanProcessor, +} from '@opentelemetry/sdk-trace-node' import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api' import { PrismaInstrumentation } from '@prisma/instrumentation' - // --------------------------------------------------------------------------- // Guard: no-op when OTEL is explicitly disabled or endpoint is not configured // --------------------------------------------------------------------------- @@ -30,6 +36,7 @@ const OTEL_ENABLED = process.env.OTEL_ENABLED !== 'false' && !!OTEL_ENDPOINT if (!OTEL_ENABLED) { // Emit a single info line so ops knows tracing is off — not an error + // eslint-disable-next-line no-console -- telemetry bootstraps before the winston logger can be instrumented console.info( '[OTel] OTEL_EXPORTER_OTLP_ENDPOINT not set or OTEL_ENABLED=false — distributed tracing disabled' ) @@ -39,7 +46,10 @@ if (!OTEL_ENABLED) { // Debug logging (only in development) // --------------------------------------------------------------------------- -if (process.env.NODE_ENV === 'development' && process.env.OTEL_DEBUG === 'true') { +if ( + process.env.NODE_ENV === 'development' && + process.env.OTEL_DEBUG === 'true' +) { diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG) } @@ -51,10 +61,10 @@ const serviceName = process.env.OTEL_SERVICE_NAME ?? 'backend' const serviceVersion = process.env.npm_package_version ?? '0.0.0' const resource = resourceFromAttributes({ - [ATTR_SERVICE_NAME]: serviceName, - [ATTR_SERVICE_VERSION]: serviceVersion, - 'deployment.environment': process.env.NODE_ENV ?? 'development', - }) + [ATTR_SERVICE_NAME]: serviceName, + [ATTR_SERVICE_VERSION]: serviceVersion, + 'deployment.environment': process.env.NODE_ENV ?? 'development', +}) /** * Build the span processor to use. @@ -68,7 +78,7 @@ function buildSpanProcessor() { url: `${OTEL_ENDPOINT}/v1/traces`, headers: process.env.OTEL_EXPORTER_OTLP_HEADERS ? Object.fromEntries( - process.env.OTEL_EXPORTER_OTLP_HEADERS.split(',').map(h => { + process.env.OTEL_EXPORTER_OTLP_HEADERS.split(',').map((h) => { const [k, ...v] = h.split('=') return [k.trim(), v.join('=').trim()] }) @@ -104,7 +114,7 @@ const sdk = new NodeSDK({ }, }, }), - new PrismaInstrumentation(), // ✅ separate, properly typed + new PrismaInstrumentation(), // ✅ separate, properly typed ], }) @@ -115,7 +125,10 @@ const sdk = new NodeSDK({ if (spanProcessor) { try { sdk.start() - console.info(`[OTel] Tracing initialised → service=${serviceName}@${serviceVersion}`) + // eslint-disable-next-line no-console -- telemetry bootstraps before the winston logger can be instrumented + console.info( + `[OTel] Tracing initialised → service=${serviceName}@${serviceVersion}` + ) } catch (err) { console.error('[OTel] Failed to start OpenTelemetry SDK:', err) // Non-fatal — tracing failure must never crash the server @@ -123,7 +136,9 @@ if (spanProcessor) { // Flush remaining spans before the process exits process.on('SIGTERM', () => { - sdk.shutdown().catch((err) => console.error('[OTel] SDK shutdown error:', err)) + sdk + .shutdown() + .catch((err) => console.error('[OTel] SDK shutdown error:', err)) }) } @@ -131,4 +146,4 @@ if (spanProcessor) { // Export the active tracer for manual instrumentation elsewhere in the app // --------------------------------------------------------------------------- -export { sdk } \ No newline at end of file +export { sdk } diff --git a/src/telemetry/sentry.ts b/src/telemetry/sentry.ts index c393f69..d787a99 100644 --- a/src/telemetry/sentry.ts +++ b/src/telemetry/sentry.ts @@ -23,12 +23,14 @@ import { nodeProfilingIntegration } from '@sentry/profiling-node' const SENTRY_DSN = process.env.SENTRY_DSN if (!SENTRY_DSN) { + // eslint-disable-next-line no-console -- telemetry bootstraps before the winston logger can be instrumented console.info('[Sentry] SENTRY_DSN not set — error reporting disabled') } else { Sentry.init({ dsn: SENTRY_DSN, - environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? 'development', + environment: + process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? 'development', // Attach the Git SHA / package version as the release so Sentry can link // errors to the exact deployment and show source maps. @@ -51,7 +53,9 @@ if (!SENTRY_DSN) { // Performance tracing — set to a low value in production to control costs. // Override via SENTRY_TRACES_SAMPLE_RATE env var. - tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? '0.1'), + tracesSampleRate: parseFloat( + process.env.SENTRY_TRACES_SAMPLE_RATE ?? '0.1' + ), // --------------------------------------------------------------------------- // Event filtering @@ -93,7 +97,14 @@ if (!SENTRY_DSN) { // Rule 3 — scrub sensitive fields from the request body if (event.request?.data && typeof event.request.data === 'object') { - const sensitiveKeys = ['password', 'secret', 'token', 'pin', 'mnemonic', 'privateKey'] + const sensitiveKeys = [ + 'password', + 'secret', + 'token', + 'pin', + 'mnemonic', + 'privateKey', + ] const data = { ...(event.request.data as Record) } for (const key of sensitiveKeys) { if (key in data) data[key] = '[Filtered]' @@ -105,6 +116,7 @@ if (!SENTRY_DSN) { }, }) + // eslint-disable-next-line no-console -- telemetry bootstraps before the winston logger can be instrumented console.info( `[Sentry] Initialised → env=${process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV} dsn=***${SENTRY_DSN.slice(-6)}` ) @@ -113,4 +125,4 @@ if (!SENTRY_DSN) { // --------------------------------------------------------------------------- // Convenience re-export so callers don't need to import @sentry/node directly // --------------------------------------------------------------------------- -export { Sentry } \ No newline at end of file +export { Sentry } diff --git a/src/telemetry/spans.ts b/src/telemetry/spans.ts index 0fc883e..2cd4da1 100644 --- a/src/telemetry/spans.ts +++ b/src/telemetry/spans.ts @@ -16,7 +16,13 @@ * }) */ -import { trace, context, SpanStatusCode, Span, SpanKind } from '@opentelemetry/api' +import { + trace, + context, + SpanStatusCode, + Span, + SpanKind, +} from '@opentelemetry/api' const tracer = trace.getTracer('neurowealth-backend') @@ -215,7 +221,10 @@ export async function withDlqSpan( return result } catch (err) { span.recordException(err as Error) - span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message }) + span.setStatus({ + code: SpanStatusCode.ERROR, + message: (err as Error).message, + }) throw err } finally { span.end() @@ -235,4 +244,4 @@ export async function withDlqSpan( export function setSpanUser(userId: string): void { const span = trace.getActiveSpan() if (span) span.setAttribute('user.id', userId) -} \ No newline at end of file +} diff --git a/src/utils/correlation.ts b/src/utils/correlation.ts index 395a6a7..20b6bd6 100644 --- a/src/utils/correlation.ts +++ b/src/utils/correlation.ts @@ -14,7 +14,10 @@ export function generateCorrelationId(): string { } export function isValidCorrelationId(value: string): boolean { - return VALID_CORRELATION_ID.test(value) && value.length <= MAX_CORRELATION_ID_LENGTH + return ( + VALID_CORRELATION_ID.test(value) && + value.length <= MAX_CORRELATION_ID_LENGTH + ) } export function resolveCorrelationId( diff --git a/src/utils/csv.ts b/src/utils/csv.ts new file mode 100644 index 0000000..95aff64 --- /dev/null +++ b/src/utils/csv.ts @@ -0,0 +1,38 @@ +/** + * Minimal CSV writer (RFC 4180) with spreadsheet formula-injection guarding. + * Hand-rolled on purpose — first export feature in the repo; no new dependency. + */ + +export type CsvValue = string | number | boolean | null | undefined + +/** + * Escape a single CSV field: + * - null/undefined → empty string + * - leading `=` `+` `-` `@` tab or CR gets a `'` prefix so spreadsheet apps + * never interpret the cell as a formula (CSV injection guard) + * - fields containing `,` `"` newline or CR are quoted, internal quotes doubled + */ +export function escapeCsvField(value: CsvValue): string { + if (value === null || value === undefined) return '' + + let field = String(value) + + if (/^[=+\-@\t\r]/.test(field)) { + field = `'${field}` + } + + if (/[",\n\r]/.test(field)) { + field = `"${field.replace(/"/g, '""')}"` + } + + return field +} + +/** Build a CSV document: header row + data rows, CRLF line endings. */ +export function toCsv(headers: string[], rows: CsvValue[][]): string { + const lines = [ + headers.map(escapeCsvField).join(','), + ...rows.map((row) => row.map(escapeCsvField).join(',')), + ] + return lines.join('\r\n') +} diff --git a/src/utils/errorResponse.ts b/src/utils/errorResponse.ts index 224f389..cb46a55 100644 --- a/src/utils/errorResponse.ts +++ b/src/utils/errorResponse.ts @@ -59,30 +59,70 @@ export function buildErrorResponse( * Error response builders for common HTTP status codes. */ export const ErrorResponses = { - badRequest: (message: string, requestId: string, details?: Record) => - buildErrorResponse(ErrorCodes.BAD_REQUEST, message, requestId, details), + badRequest: ( + message: string, + requestId: string, + details?: Record + ) => buildErrorResponse(ErrorCodes.BAD_REQUEST, message, requestId, details), - unauthorized: (message: string, requestId: string, details?: Record) => - buildErrorResponse(ErrorCodes.UNAUTHORIZED, message, requestId, details), + unauthorized: ( + message: string, + requestId: string, + details?: Record + ) => buildErrorResponse(ErrorCodes.UNAUTHORIZED, message, requestId, details), - forbidden: (message: string, requestId: string, details?: Record) => - buildErrorResponse(ErrorCodes.FORBIDDEN, message, requestId, details), + forbidden: ( + message: string, + requestId: string, + details?: Record + ) => buildErrorResponse(ErrorCodes.FORBIDDEN, message, requestId, details), - notFound: (message: string, requestId: string, details?: Record) => - buildErrorResponse(ErrorCodes.NOT_FOUND, message, requestId, details), + notFound: ( + message: string, + requestId: string, + details?: Record + ) => buildErrorResponse(ErrorCodes.NOT_FOUND, message, requestId, details), - conflict: (message: string, requestId: string, details?: Record) => - buildErrorResponse(ErrorCodes.CONFLICT, message, requestId, details), + conflict: ( + message: string, + requestId: string, + details?: Record + ) => buildErrorResponse(ErrorCodes.CONFLICT, message, requestId, details), - rateLimited: (message: string, requestId: string, details?: Record) => - buildErrorResponse(ErrorCodes.RATE_LIMITED, message, requestId, details), + rateLimited: ( + message: string, + requestId: string, + details?: Record + ) => buildErrorResponse(ErrorCodes.RATE_LIMITED, message, requestId, details), - validationError: (message: string, requestId: string, details?: Record) => - buildErrorResponse(ErrorCodes.VALIDATION_ERROR, message, requestId, details), + validationError: ( + message: string, + requestId: string, + details?: Record + ) => + buildErrorResponse( + ErrorCodes.VALIDATION_ERROR, + message, + requestId, + details + ), - internalError: (message: string, requestId: string, details?: Record) => + internalError: ( + message: string, + requestId: string, + details?: Record + ) => buildErrorResponse(ErrorCodes.INTERNAL_ERROR, message, requestId, details), - serviceUnavailable: (message: string, requestId: string, details?: Record) => - buildErrorResponse(ErrorCodes.SERVICE_UNAVAILABLE, message, requestId, details), + serviceUnavailable: ( + message: string, + requestId: string, + details?: Record + ) => + buildErrorResponse( + ErrorCodes.SERVICE_UNAVAILABLE, + message, + requestId, + details + ), } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 3a9d5a4..5100637 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -11,7 +11,12 @@ export class AppError extends Error { } } -export const sendError = (res: Response, statusCode: number, message: string, details?: any) => { +export const sendError = ( + res: Response, + statusCode: number, + message: string, + details?: any +) => { return res.status(statusCode).json({ error: message, details, diff --git a/src/utils/fetchWithRetry.ts b/src/utils/fetchWithRetry.ts index 58127b1..c989b55 100644 --- a/src/utils/fetchWithRetry.ts +++ b/src/utils/fetchWithRetry.ts @@ -3,67 +3,71 @@ */ interface FetchOptions { - timeout?: number; - retries?: number; - retryDelay?: number; + timeout?: number + retries?: number + retryDelay?: number } interface CircuitBreaker { - failures: number; - lastFailure: number; - isOpen: boolean; + failures: number + lastFailure: number + isOpen: boolean } -const circuitBreakers: Record = {}; -const CIRCUIT_OPEN_DURATION = 60000; // 1 minute -const FAILURE_THRESHOLD = 3; +const circuitBreakers: Record = {} +const CIRCUIT_OPEN_DURATION = 60000 // 1 minute +const FAILURE_THRESHOLD = 3 export async function fetchWithRetry( url: string, options: FetchOptions = {} ): Promise { - const { timeout = 5000, retries = 3, retryDelay = 1000 } = options; + const { timeout = 5000, retries = 3, retryDelay = 1000 } = options // Check circuit breaker - const breaker = circuitBreakers[url] || { failures: 0, lastFailure: 0, isOpen: false }; + const breaker = circuitBreakers[url] || { + failures: 0, + lastFailure: 0, + isOpen: false, + } if (breaker.isOpen) { - const timeSinceFailure = Date.now() - breaker.lastFailure; + const timeSinceFailure = Date.now() - breaker.lastFailure if (timeSinceFailure < CIRCUIT_OPEN_DURATION) { - throw new Error(`Circuit breaker open for ${url}`); + throw new Error(`Circuit breaker open for ${url}`) } - breaker.isOpen = false; - breaker.failures = 0; + breaker.isOpen = false + breaker.failures = 0 } - let lastError: Error = new Error('Unknown error'); + let lastError: Error = new Error('Unknown error') for (let attempt = 0; attempt < retries; attempt++) { try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeout); + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeout) - const res = await fetch(url, { signal: controller.signal }); - clearTimeout(timer); + const res = await fetch(url, { signal: controller.signal }) + clearTimeout(timer) - if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`) // Reset circuit breaker on success - circuitBreakers[url] = { failures: 0, lastFailure: 0, isOpen: false }; + circuitBreakers[url] = { failures: 0, lastFailure: 0, isOpen: false } - return await res.json(); + return await res.json() } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); + lastError = err instanceof Error ? err : new Error(String(err)) if (attempt < retries - 1) { - await new Promise(r => setTimeout(r, retryDelay * (attempt + 1))); + await new Promise((r) => setTimeout(r, retryDelay * (attempt + 1))) } } } // Trip circuit breaker - breaker.failures += 1; - breaker.lastFailure = Date.now(); - if (breaker.failures >= FAILURE_THRESHOLD) breaker.isOpen = true; - circuitBreakers[url] = breaker; + breaker.failures += 1 + breaker.lastFailure = Date.now() + if (breaker.failures >= FAILURE_THRESHOLD) breaker.isOpen = true + circuitBreakers[url] = breaker - throw lastError; + throw lastError } diff --git a/src/utils/http-client.ts b/src/utils/http-client.ts index 91339a5..9593058 100644 --- a/src/utils/http-client.ts +++ b/src/utils/http-client.ts @@ -96,7 +96,10 @@ export class HttpClientAdapter { throw lastError! } - private async executeWithTimeout(fn: () => Promise, context?: string): Promise { + private async executeWithTimeout( + fn: () => Promise, + context?: string + ): Promise { const { timeoutMs } = this.config return new Promise((resolve, reject) => { @@ -118,9 +121,14 @@ export class HttpClientAdapter { private checkCircuitBreaker(context?: string): void { if (this.state === 'open') { - if (Date.now() - this.lastFailureTime >= this.config.circuitBreakerResetMs) { + if ( + Date.now() - this.lastFailureTime >= + this.config.circuitBreakerResetMs + ) { this.state = 'half-open' - logger.debug('[HttpClientAdapter] Circuit breaker transitioning to half-open') + logger.debug( + '[HttpClientAdapter] Circuit breaker transitioning to half-open' + ) } else { throw new CircuitBreakerError(context) } @@ -129,7 +137,9 @@ export class HttpClientAdapter { private onSuccess(): void { if (this.state === 'half-open') { - logger.debug('[HttpClientAdapter] Circuit breaker closing after successful half-open request') + logger.debug( + '[HttpClientAdapter] Circuit breaker closing after successful half-open request' + ) } this.state = 'closed' this.failures = Math.max(0, this.failures - 1) @@ -139,18 +149,22 @@ export class HttpClientAdapter { this.failures++ this.lastFailureTime = Date.now() - logger.debug(`[HttpClientAdapter] Failure #${this.failures}/${this.config.circuitBreakerThreshold}: ${error.message}`) + logger.debug( + `[HttpClientAdapter] Failure #${this.failures}/${this.config.circuitBreakerThreshold}: ${error.message}` + ) if (this.failures >= this.config.circuitBreakerThreshold) { this.state = 'open' - logger.warn(`[HttpClientAdapter] Circuit breaker OPEN after ${this.failures} failures`) + logger.warn( + `[HttpClientAdapter] Circuit breaker OPEN after ${this.failures} failures` + ) } } private async delay(attempt: number): Promise { const delayMs = Math.min( this.config.baseDelayMs * Math.pow(2, attempt), - this.config.maxDelayMs, + this.config.maxDelayMs ) const jitter = delayMs * (0.5 + Math.random() * 0.5) return new Promise((resolve) => setTimeout(resolve, jitter)) diff --git a/src/utils/logger.ts b/src/utils/logger.ts index c5a26de..450152a 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -11,7 +11,10 @@ try { } } catch (error) { // If we can't create logs directory, fall back to console-only logging - console.error('[Logger] Failed to create logs directory, using console-only:', error) + console.error( + '[Logger] Failed to create logs directory, using console-only:', + error + ) } // Sensitive data patterns to redact @@ -49,18 +52,24 @@ const correlationFormat = winston.format((info) => { }) // Custom format that redacts sensitive data -const redactFormat = winston.format.printf(({ timestamp, level, message, ...meta }) => { - const safeMessage = typeof message === 'string' ? redactSensitiveData(message) : message - const safeMeta: any = {} - for (const [key, value] of Object.entries(meta)) { - safeMeta[key] = typeof value === 'string' ? redactSensitiveData(value) : value +const redactFormat = winston.format.printf( + ({ timestamp, level, message, ...meta }) => { + const safeMessage = + typeof message === 'string' ? redactSensitiveData(message) : message + const safeMeta: any = {} + for (const [key, value] of Object.entries(meta)) { + safeMeta[key] = + typeof value === 'string' ? redactSensitiveData(value) : value + } + const metaStr = Object.keys(safeMeta).length ? JSON.stringify(safeMeta) : '' + return `${timestamp} [${level}]: ${safeMessage} ${metaStr}` } - const metaStr = Object.keys(safeMeta).length ? JSON.stringify(safeMeta) : '' - return `${timestamp} [${level}]: ${safeMessage} ${metaStr}` -}) +) // Determine log level from environment -const logLevel = process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug') +const logLevel = + process.env.LOG_LEVEL || + (process.env.NODE_ENV === 'production' ? 'info' : 'debug') const isProduction = process.env.NODE_ENV === 'production' // Create base transports array @@ -85,7 +94,10 @@ if (fs.existsSync(logsDir) && fs.statSync(logsDir).isDirectory()) { maxsize: 10 * 1024 * 1024, // 10MB maxFiles: 5, format: isProduction - ? winston.format.combine(winston.format.timestamp(), winston.format.json()) + ? winston.format.combine( + winston.format.timestamp(), + winston.format.json() + ) : winston.format.combine(winston.format.timestamp(), redactFormat), }) ) @@ -97,7 +109,10 @@ if (fs.existsSync(logsDir) && fs.statSync(logsDir).isDirectory()) { maxsize: 10 * 1024 * 1024, // 10MB maxFiles: 5, format: isProduction - ? winston.format.combine(winston.format.timestamp(), winston.format.json()) + ? winston.format.combine( + winston.format.timestamp(), + winston.format.json() + ) : winston.format.combine(winston.format.timestamp(), redactFormat), }) ) @@ -161,4 +176,4 @@ export function logBackgroundJob( } else { logger.info(message, logContext) } -} \ No newline at end of file +} diff --git a/src/utils/metrics-registry.ts b/src/utils/metrics-registry.ts index 90ba512..10cad1c 100644 --- a/src/utils/metrics-registry.ts +++ b/src/utils/metrics-registry.ts @@ -22,6 +22,6 @@ * (They are the same object — this file just exports it explicitly.) */ -import { register } from 'prom-client'; +import { register } from 'prom-client' -export { register }; \ No newline at end of file +export { register } diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts index 6594eda..73ee9fc 100644 --- a/src/utils/metrics.ts +++ b/src/utils/metrics.ts @@ -318,7 +318,10 @@ export function recordEventFailed(eventType: string, errorType: string): void { /** * Record event processing duration */ -export function recordEventDuration(eventType: string, durationSeconds: number): void { +export function recordEventDuration( + eventType: string, + durationSeconds: number +): void { eventsProcessingDuration.observe({ event_type: eventType }, durationSeconds) } @@ -353,7 +356,9 @@ export function updateAgentHeartbeat(): void { /** * Update agent loop status */ -export function updateAgentStatus(status: 'stopped' | 'running' | 'degraded'): void { +export function updateAgentStatus( + status: 'stopped' | 'running' | 'degraded' +): void { const statusValue = status === 'stopped' ? 0 : status === 'running' ? 1 : 2 agentLoopStatus.set(statusValue) } @@ -375,7 +380,10 @@ export function recordRebalanceTriggered(): void { /** * Record database operation duration */ -export function recordDbOperation(operation: string, durationSeconds: number): void { +export function recordDbOperation( + operation: string, + durationSeconds: number +): void { dbOperationDuration.observe({ operation }, durationSeconds) } @@ -447,7 +455,10 @@ export function recordExternalServiceError( /** * Record a rate limit hit */ -export function recordRateLimitHit(routeGroup: string, limiterType: string): void { +export function recordRateLimitHit( + routeGroup: string, + limiterType: string +): void { rateLimitHitsTotal.inc({ route_group: routeGroup, limiter_type: limiterType }) } @@ -461,14 +472,19 @@ export function recordAuthFailure(endpoint: string, failureType: string): void { /** * Update active rate limit violations */ -export function updateRateLimitViolations(routeGroup: string, count: number): void { +export function updateRateLimitViolations( + routeGroup: string, + count: number +): void { rateLimitActiveViolations.set({ route_group: routeGroup }, count) } /** * Record a rejected request due to size or content-type */ -export function recordRejectedRequest(reason: 'oversized' | 'content_type'): void { +export function recordRejectedRequest( + reason: 'oversized' | 'content_type' +): void { rejectedRequestsTotal.inc({ reason }) } diff --git a/src/utils/portfolio-cache.ts b/src/utils/portfolio-cache.ts index 21e9f72..100dedc 100644 --- a/src/utils/portfolio-cache.ts +++ b/src/utils/portfolio-cache.ts @@ -1,20 +1,27 @@ // src/utils/portfolio-cache.ts // #213 – per-user portfolio snapshot cache helpers. // Call invalidatePortfolioCache(userId) on every deposit/withdraw mutation. -import { cacheGet, cacheSet, cacheDel } from '../config/redis'; +import { cacheGet, cacheSet, cacheDel } from '../config/redis' -const PORTFOLIO_CACHE_TTL = parseInt(process.env.PORTFOLIO_CACHE_TTL_SECONDS || '60'); +const PORTFOLIO_CACHE_TTL = parseInt( + process.env.PORTFOLIO_CACHE_TTL_SECONDS || '60' +) export function portfolioCacheKey(userId: string): string { - return `portfolio_snapshot:${userId}`; + return `portfolio_snapshot:${userId}` } -export async function getPortfolioSnapshot(userId: string): Promise { - return cacheGet(portfolioCacheKey(userId)); +export async function getPortfolioSnapshot( + userId: string +): Promise { + return cacheGet(portfolioCacheKey(userId)) } -export async function setPortfolioSnapshot(userId: string, data: unknown): Promise { - await cacheSet(portfolioCacheKey(userId), data, PORTFOLIO_CACHE_TTL); +export async function setPortfolioSnapshot( + userId: string, + data: unknown +): Promise { + await cacheSet(portfolioCacheKey(userId), data, PORTFOLIO_CACHE_TTL) } /** @@ -22,6 +29,8 @@ export async function setPortfolioSnapshot(userId: string, data: unknown): Promi * Call this from deposit/withdraw/rebalance mutations so the next * request re-computes from DB instead of returning stale data. */ -export async function invalidatePortfolioSnapshot(userId: string): Promise { - await cacheDel(portfolioCacheKey(userId)); -} \ No newline at end of file +export async function invalidatePortfolioSnapshot( + userId: string +): Promise { + await cacheDel(portfolioCacheKey(userId)) +} diff --git a/src/utils/rpc-metrics.ts b/src/utils/rpc-metrics.ts index 1434ed2..eb8afc8 100644 --- a/src/utils/rpc-metrics.ts +++ b/src/utils/rpc-metrics.ts @@ -11,32 +11,32 @@ * stellar_rpc_request_duration_seconds — latency histogram, labelled by endpoint / context / success */ -import { Counter, Histogram, Registry } from 'prom-client'; +import { Counter, Histogram, Registry } from 'prom-client' // Re-use the default registry so the existing getMetrics() picks these up // automatically — no changes needed in metrics.ts or the /metrics route. -import { register } from './metrics-registry'; +import { register } from './metrics-registry' export const rpcAttemptCounter = new Counter({ name: 'stellar_rpc_attempts_total', help: 'Total Stellar RPC call attempts', labelNames: ['endpoint', 'context', 'primary'] as const, registers: [register], -}); +}) export const rpcFailoverCounter = new Counter({ name: 'stellar_rpc_failovers_total', help: 'Number of times a call failed over to a secondary RPC endpoint', labelNames: ['endpoint', 'context'] as const, registers: [register], -}); +}) export const rpcCircuitOpenCounter = new Counter({ name: 'stellar_rpc_circuit_open_total', help: 'Number of requests blocked because the circuit breaker was OPEN', labelNames: ['endpoint', 'context'] as const, registers: [register], -}); +}) export const rpcLatencyHistogram = new Histogram({ name: 'stellar_rpc_request_duration_seconds', @@ -44,4 +44,4 @@ export const rpcLatencyHistogram = new Histogram({ labelNames: ['endpoint', 'context', 'success'] as const, buckets: [0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10], registers: [register], -}); \ No newline at end of file +}) diff --git a/src/utils/stellar/stellar-verification.ts b/src/utils/stellar/stellar-verification.ts index d708721..1e6cc0b 100644 --- a/src/utils/stellar/stellar-verification.ts +++ b/src/utils/stellar/stellar-verification.ts @@ -1,62 +1,62 @@ -import { config } from '../../config/env'; -import { Keypair } from '@stellar/stellar-sdk'; +import { config } from '../../config/env' +import { Keypair } from '@stellar/stellar-sdk' export interface NonceEntry { - nonce: string; - expiresAt: number; // ms since epoch - stellarPubKey: string; + nonce: string + expiresAt: number // ms since epoch + stellarPubKey: string } /** * In-memory nonce store. Exported as _nonceStoreForTests so integration tests * can inspect and pre-populate nonces without hitting the database. */ -export const _nonceStoreForTests = new Map(); +export const _nonceStoreForTests = new Map() export default class StellarVerification { - /** - * Verify a Stellar signature. - * Freighter signs the raw UTF-8 bytes of the message. - * Stellar's Keypair.verify() expects a Buffer and a base64-encoded signature. - */ - verifyStellarSignature( - publicKey: string, - message: string, - signatureBase64: string, - ): boolean { - try { - const keypair = Keypair.fromPublicKey(publicKey); - const messageBytes = Buffer.from(message, 'utf8'); - const signatureBytes = Buffer.from(signatureBase64, 'base64'); - return keypair.verify(messageBytes, signatureBytes); - } catch { - return false; - } + /** + * Verify a Stellar signature. + * Freighter signs the raw UTF-8 bytes of the message. + * Stellar's Keypair.verify() expects a Buffer and a base64-encoded signature. + */ + verifyStellarSignature( + publicKey: string, + message: string, + signatureBase64: string + ): boolean { + try { + const keypair = Keypair.fromPublicKey(publicKey) + const messageBytes = Buffer.from(message, 'utf8') + const signatureBytes = Buffer.from(signatureBase64, 'base64') + return keypair.verify(messageBytes, signatureBytes) + } catch { + return false } + } - /** Remove all expired nonces (lazy cleanup called from challenge). */ - purgeExpiredNonces(): void { - const now = Date.now(); - for (const [key, entry] of _nonceStoreForTests.entries()) { - if (entry.expiresAt <= now) { - _nonceStoreForTests.delete(key); - } - } + /** Remove all expired nonces (lazy cleanup called from challenge). */ + purgeExpiredNonces(): void { + const now = Date.now() + for (const [key, entry] of _nonceStoreForTests.entries()) { + if (entry.expiresAt <= now) { + _nonceStoreForTests.delete(key) + } } + } - /** Map STELLAR_NETWORK env value to Prisma Network enum */ - resolveNetwork(): 'MAINNET' | 'TESTNET' | 'FUTURENET' { - switch (config.stellar.network.toLowerCase()) { - case 'mainnet': - return 'MAINNET'; - case 'futurenet': - return 'FUTURENET'; - case 'testnet': - default: - return 'TESTNET'; - } + /** Map STELLAR_NETWORK env value to Prisma Network enum */ + resolveNetwork(): 'MAINNET' | 'TESTNET' | 'FUTURENET' { + switch (config.stellar.network.toLowerCase()) { + case 'mainnet': + return 'MAINNET' + case 'futurenet': + return 'FUTURENET' + case 'testnet': + default: + return 'TESTNET' } + } } /** Shared singleton — imported by auth-controller */ -export const stellarVerification = new StellarVerification(); +export const stellarVerification = new StellarVerification() diff --git a/src/utils/transaction-events.ts b/src/utils/transaction-events.ts index 6c3f433..b8acb70 100644 --- a/src/utils/transaction-events.ts +++ b/src/utils/transaction-events.ts @@ -1,17 +1,12 @@ import db from '../db' export type TransactionEventType = - | 'INITIATED' - | 'SUBMITTED' - | 'CONFIRMED' - | 'FAILED' - | 'RETRIED' - | 'REVERSED' + 'INITIATED' | 'SUBMITTED' | 'CONFIRMED' | 'FAILED' | 'RETRIED' | 'REVERSED' export async function recordTransactionEvent( transactionId: string, event: TransactionEventType, - metadata?: Record, + metadata?: Record ): Promise { await (db as any).transactionEvent.create({ data: { @@ -20,4 +15,4 @@ export async function recordTransactionEvent( metadata: metadata ?? undefined, }, }) -} \ No newline at end of file +} diff --git a/src/utils/twilio-client.ts b/src/utils/twilio-client.ts index c8dc32f..02cd807 100644 --- a/src/utils/twilio-client.ts +++ b/src/utils/twilio-client.ts @@ -19,7 +19,9 @@ function getClient(): ReturnType { const sid = config.whatsapp.twilioSid const token = config.whatsapp.twilioToken if (!sid || !token) { - throw new Error('Twilio credentials not configured (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)') + throw new Error( + 'Twilio credentials not configured (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)' + ) } twilioClient = twilio(sid, token) } @@ -31,7 +33,9 @@ export interface SendMessageParams { body: string } -export async function sendWhatsAppMessage(params: SendMessageParams): Promise { +export async function sendWhatsAppMessage( + params: SendMessageParams +): Promise { return httpClient.execute(async () => { const client = getClient() const message = await client.messages.create({ @@ -39,7 +43,9 @@ export async function sendWhatsAppMessage(params: SendMessageParams): Promise !isNaN(Number(val)) && Number(val) >= 0, { - message: "Amount must be a non-negative number string", + user: z.string().min(1, 'User wallet address is required'), + amount: z.string().refine((val) => !isNaN(Number(val)) && Number(val) >= 0, { + message: 'Amount must be a non-negative number string', }), - shares: z.string().refine(val => !isNaN(Number(val)) && Number(val) >= 0, { - message: "Shares must be a non-negative number string", + shares: z.string().refine((val) => !isNaN(Number(val)) && Number(val) >= 0, { + message: 'Shares must be a non-negative number string', }), - assetSymbol: z.string().min(1, "Asset symbol is required"), - protocolName: z.string().min(1, "Protocol name is required"), + assetSymbol: z.string().min(1, 'Asset symbol is required'), + protocolName: z.string().min(1, 'Protocol name is required'), network: z.nativeEnum(Network), -}); +}) export const WithdrawEventSchema = z.object({ - user: z.string().min(1, "User wallet address is required"), - amount: z.string().refine(val => !isNaN(Number(val)) && Number(val) >= 0, { - message: "Amount must be a non-negative number string", + user: z.string().min(1, 'User wallet address is required'), + amount: z.string().refine((val) => !isNaN(Number(val)) && Number(val) >= 0, { + message: 'Amount must be a non-negative number string', }), - shares: z.string().refine(val => !isNaN(Number(val)) && Number(val) >= 0, { - message: "Shares must be a non-negative number string", + shares: z.string().refine((val) => !isNaN(Number(val)) && Number(val) >= 0, { + message: 'Shares must be a non-negative number string', }), - assetSymbol: z.string().min(1, "Asset symbol is required"), - protocolName: z.string().min(1, "Protocol name is required"), + assetSymbol: z.string().min(1, 'Asset symbol is required'), + protocolName: z.string().min(1, 'Protocol name is required'), network: z.nativeEnum(Network), -}); +}) export const RebalanceEventSchema = z.object({ - protocol: z.string().min(1, "Protocol name is required"), - apy: z.number().min(0, "APY must be a non-negative number"), - timestamp: z.number().positive("Timestamp must be a positive number"), - assetSymbol: z.string().min(1, "Asset symbol is required"), + protocol: z.string().min(1, 'Protocol name is required'), + apy: z.number().min(0, 'APY must be a non-negative number'), + timestamp: z.number().positive('Timestamp must be a positive number'), + assetSymbol: z.string().min(1, 'Asset symbol is required'), network: z.nativeEnum(Network), -}); +}) export const ContractEventSchema = z.object({ type: z.enum(['deposit', 'withdraw', 'rebalance']), ledger: z.number().int().nonnegative(), txHash: z.string().min(1), contractId: z.string().min(1), -}); +}) diff --git a/src/validators/goal-validators.ts b/src/validators/goal-validators.ts index 59994ac..db1192f 100644 --- a/src/validators/goal-validators.ts +++ b/src/validators/goal-validators.ts @@ -15,21 +15,39 @@ export const createGoalSchema = z riskCeiling: z.number().min(0).max(100).optional(), }) .refine( - (data) => data.startingAmount === undefined || data.targetAmount > data.startingAmount, - { message: 'targetAmount must be greater than startingAmount', path: ['targetAmount'] }, + (data) => + data.startingAmount === undefined || + data.targetAmount > data.startingAmount, + { + message: 'targetAmount must be greater than startingAmount', + path: ['targetAmount'], + } ) export const updateGoalSchema = z .object({ - targetAmount: z.number().positive('targetAmount must be greater than 0').optional(), + targetAmount: z + .number() + .positive('targetAmount must be greater than 0') + .optional(), targetDate: z.coerce.date().optional(), riskCeiling: z.number().min(0).max(100).optional(), }) .refine( - (data) => data.targetAmount !== undefined || data.targetDate !== undefined || data.riskCeiling !== undefined, - { message: 'At least one of targetAmount, targetDate, riskCeiling must be provided' }, + (data) => + data.targetAmount !== undefined || + data.targetDate !== undefined || + data.riskCeiling !== undefined, + { + message: + 'At least one of targetAmount, targetDate, riskCeiling must be provided', + } + ) + .refine( + (data) => + data.targetDate === undefined || data.targetDate.getTime() > Date.now(), + { + message: 'targetDate must be in the future', + path: ['targetDate'], + } ) - .refine((data) => data.targetDate === undefined || data.targetDate.getTime() > Date.now(), { - message: 'targetDate must be in the future', - path: ['targetDate'], - }) diff --git a/src/validators/webhook-validators.ts b/src/validators/webhook-validators.ts index ffbb2ad..e453341 100644 --- a/src/validators/webhook-validators.ts +++ b/src/validators/webhook-validators.ts @@ -1,9 +1,9 @@ -import { z } from 'zod'; +import { z } from 'zod' export const whatsappWebhookSchema = z.object({ From: z.string().min(1, 'From is required'), Body: z.string().min(1, 'Body is required'), -}); +}) const WEBHOOK_EVENTS = [ 'transaction.confirmed', @@ -12,23 +12,23 @@ const WEBHOOK_EVENTS = [ 'withdraw.completed', 'fiat.order.settled', 'fiat.order.failed', -] as const; +] as const export const createWebhookSchema = z.object({ url: z.string().url('Must be a valid URL'), events: z .array(z.enum(WEBHOOK_EVENTS)) .min(1, 'At least one event is required'), -}); +}) export const updateWebhookSchema = z.object({ url: z.string().url('Must be a valid URL').optional(), events: z.array(z.enum(WEBHOOK_EVENTS)).min(1).optional(), isActive: z.boolean().optional(), -}); +}) export const webhookIdParamSchema = z.object({ id: z.string().uuid('Invalid webhook ID'), -}); +}) -export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]; +export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number] diff --git a/src/whatsapp/formatters.ts b/src/whatsapp/formatters.ts index 3d5d821..81e5efe 100644 --- a/src/whatsapp/formatters.ts +++ b/src/whatsapp/formatters.ts @@ -138,7 +138,10 @@ export function formatGoalProgressReply(input: { projectedCompletionDate: string | null }): string { if (input.status === 'ACHIEVED') { - return ['🎯 *Savings Goal*', `You've reached your goal of $${input.targetAmount.toFixed(2)}! 🎉`].join('\n') + return [ + '🎯 *Savings Goal*', + `You've reached your goal of $${input.targetAmount.toFixed(2)}! 🎉`, + ].join('\n') } if (input.status === 'MISSED') { @@ -168,7 +171,9 @@ export function formatGoalProgressReply(input: { } if (input.projectedCompletionDate) { - lines.push(`Projected completion: _${input.projectedCompletionDate.slice(0, 10)}_`) + lines.push( + `Projected completion: _${input.projectedCompletionDate.slice(0, 10)}_` + ) } return lines.join('\n') diff --git a/src/whatsapp/userManager.ts b/src/whatsapp/userManager.ts index 8d72f7e..556a36a 100644 --- a/src/whatsapp/userManager.ts +++ b/src/whatsapp/userManager.ts @@ -1,7 +1,11 @@ import crypto from 'crypto' import db from '../db' import { createCustodialWallet, getWalletByUserId } from '../stellar/wallet' -import { getGoalForUser, computeGoalProgress, GoalProgress } from '../goals/service' +import { + getGoalForUser, + computeGoalProgress, + GoalProgress, +} from '../goals/service' export type WhatsAppUser = { id: string @@ -232,7 +236,9 @@ export async function getPortfolioYieldSummary( * custodial wallet address the same way getPortfolioYieldSummary is. * Returns null when the user has no DB record yet or no goal at all. */ -export async function getGoalStatus(phone: string): Promise { +export async function getGoalStatus( + phone: string +): Promise { const user = getUserByPhone(phone) if (!user) { return null diff --git a/tests/client.test.ts b/tests/client.test.ts index f322329..654f991 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -116,30 +116,37 @@ describe('ResilientRpcClient', () => { describe('circuit breaker open', () => { it('skips an endpoint whose circuit breaker is open and falls over', async () => { - // threshold = 2, so two failures open the breaker + // threshold = 2 and maxRetries = 1, so the first call's two primary + // attempts fail, open the primary circuit, and the call fails over to + // the secondary in-flight. mockGetAccount .mockRejectedValueOnce(new Error('fail 1')) .mockRejectedValueOnce(new Error('fail 2')) - // After circuit opens, second endpoint should be tried .mockResolvedValueOnce({ id: 'GABC', sequence: '3' }) + .mockResolvedValueOnce({ id: 'GABC', sequence: '4' }) setEnvUrls('https://primary.example.com,https://secondary.example.com') jest.resetModules() const { getAccount } = await import('../src/stellar/client') - // First two calls exhaust retries and open the primary circuit - await expect(getAccount('GABC')).rejects.toThrow() - await expect(getAccount('GABC')).rejects.toThrow() + // First call: primary fails twice (circuit opens), secondary succeeds. + const first = await getAccount('GABC') + expect(first).toEqual({ id: 'GABC', sequence: '3' }) + expect(mockGetAccount).toHaveBeenCalledTimes(3) - // Third call: primary circuit is open → skipped → secondary succeeds - const result = await getAccount('GABC') - expect(result).toEqual({ id: 'GABC', sequence: '3' }) + // Second call: primary circuit is open → skipped → only secondary is hit. + const second = await getAccount('GABC') + expect(second).toEqual({ id: 'GABC', sequence: '4' }) + expect(mockGetAccount).toHaveBeenCalledTimes(4) }) }) describe('submitTransaction', () => { it('returns hash on PENDING status', async () => { - mockSendTransaction.mockResolvedValueOnce({ status: 'PENDING', hash: 'abc123' }) + mockSendTransaction.mockResolvedValueOnce({ + status: 'PENDING', + hash: 'abc123', + }) setEnvUrls('https://primary.example.com') jest.resetModules() @@ -150,7 +157,9 @@ describe('ResilientRpcClient', () => { }) it('throws on ERROR status', async () => { - mockSendTransaction.mockResolvedValueOnce({ + // Persistent (not once): the adapter retries after the thrown error, so + // every attempt must see the same ERROR response. + mockSendTransaction.mockResolvedValue({ status: 'ERROR', errorResult: { toXDR: () => 'base64err' }, }) @@ -159,7 +168,9 @@ describe('ResilientRpcClient', () => { jest.resetModules() const { submitTransaction } = await import('../src/stellar/client') - await expect(submitTransaction({} as any)).rejects.toThrow('Transaction failed') + await expect(submitTransaction({} as any)).rejects.toThrow( + 'Transaction failed' + ) }) }) @@ -171,8 +182,14 @@ describe('ResilientRpcClient', () => { const snap = getRpcHealthSnapshot() expect(snap).toHaveLength(2) - expect(snap[0]).toMatchObject({ url: 'https://primary.example.com', state: 'closed' }) - expect(snap[1]).toMatchObject({ url: 'https://secondary.example.com', state: 'closed' }) + expect(snap[0]).toMatchObject({ + url: 'https://primary.example.com', + state: 'closed', + }) + expect(snap[1]).toMatchObject({ + url: 'https://secondary.example.com', + state: 'closed', + }) }) }) @@ -180,11 +197,14 @@ describe('ResilientRpcClient', () => { it('resets all endpoints to closed state', async () => { setEnvUrls('https://primary.example.com') jest.resetModules() - const { resetRpcCircuitBreakers, getRpcHealthSnapshot } = await import('../src/stellar/client') + const { resetRpcCircuitBreakers, getRpcHealthSnapshot } = + await import('../src/stellar/client') resetRpcCircuitBreakers() const snap = getRpcHealthSnapshot() - expect(snap.every((e: { state: string }) => e.state === 'closed')).toBe(true) + expect(snap.every((e: { state: string }) => e.state === 'closed')).toBe( + true + ) }) }) -}) \ No newline at end of file +}) diff --git a/tests/cors.test.ts b/tests/cors.test.ts index 1d42dbc..2e998bd 100644 --- a/tests/cors.test.ts +++ b/tests/cors.test.ts @@ -1,74 +1,90 @@ -import request from 'supertest'; -import express from 'express'; -import { corsMiddleware } from '../src/middleware/corsandbody'; +import request from 'supertest' +import express from 'express' + +// The middleware only enforces the origin allowlist in production mode — in +// dev/test every origin is allowed. Mock a production config so the rejection +// path is actually exercised. +jest.mock('../src/config/env', () => ({ + config: { + nodeEnv: 'production', + security: { + allowedOrigins: ['http://localhost:3000', 'http://localhost:3001'], + bodySizeLimit: '100kb', + }, + }, +})) + +import { corsMiddleware } from '../src/middleware/corsandbody' describe('CORS Middleware', () => { - let app: express.Application; + let app: express.Application beforeEach(() => { - app = express(); - app.use(corsMiddleware); - app.get('/test', (req, res) => res.json({ message: 'success' })); - app.post('/test', (req, res) => res.status(201).json({ created: true })); - app.put('/test/:id', (req, res) => res.json({ updated: true })); - app.delete('/test/:id', (req, res) => res.json({ deleted: true })); - }); + app = express() + app.use(corsMiddleware) + app.get('/test', (req, res) => res.json({ message: 'success' })) + app.post('/test', (req, res) => res.status(201).json({ created: true })) + app.put('/test/:id', (req, res) => res.json({ updated: true })) + app.delete('/test/:id', (req, res) => res.json({ deleted: true })) + }) describe('Allowed Origins', () => { it('should allow requests from localhost:3000', async () => { const response = await request(app) .get('/test') - .set('Origin', 'http://localhost:3000'); - expect(response.status).toBe(200); - expect(response.headers['access-control-allow-origin']).toBe('http://localhost:3000'); - }); + .set('Origin', 'http://localhost:3000') + expect(response.status).toBe(200) + expect(response.headers['access-control-allow-origin']).toBe( + 'http://localhost:3000' + ) + }) it('should allow requests from localhost:3001', async () => { const response = await request(app) .get('/test') - .set('Origin', 'http://localhost:3001'); - expect(response.status).toBe(200); - }); - }); + .set('Origin', 'http://localhost:3001') + expect(response.status).toBe(200) + }) + }) describe('Disallowed Origins', () => { it('should reject requests from unauthorized origins', async () => { const response = await request(app) .get('/test') - .set('Origin', 'https://malicious.com'); - expect(response.status).toBe(403); - }); - }); + .set('Origin', 'https://malicious.com') + expect(response.status).toBe(403) + }) + }) describe('HTTP Methods', () => { it('should allow GET requests', async () => { const response = await request(app) .get('/test') - .set('Origin', 'http://localhost:3000'); - expect(response.status).toBe(200); - }); + .set('Origin', 'http://localhost:3000') + expect(response.status).toBe(200) + }) it('should allow POST requests', async () => { const response = await request(app) .post('/test') - .set('Origin', 'http://localhost:3000'); - expect(response.status).toBe(201); - }); + .set('Origin', 'http://localhost:3000') + expect(response.status).toBe(201) + }) it('should allow PUT requests', async () => { const response = await request(app) .put('/test/123') - .set('Origin', 'http://localhost:3000'); - expect(response.status).toBe(200); - }); + .set('Origin', 'http://localhost:3000') + expect(response.status).toBe(200) + }) it('should allow DELETE requests', async () => { const response = await request(app) .delete('/test/123') - .set('Origin', 'http://localhost:3000'); - expect(response.status).toBe(200); - }); - }); + .set('Origin', 'http://localhost:3000') + expect(response.status).toBe(200) + }) + }) describe('Preflight Requests', () => { it('should handle OPTIONS preflight requests', async () => { @@ -76,52 +92,53 @@ describe('CORS Middleware', () => { .options('/test') .set('Origin', 'http://localhost:3000') .set('Access-Control-Request-Method', 'POST') - .set('Access-Control-Request-Headers', 'Content-Type'); - expect(response.status).toBe(200); - expect(response.headers['access-control-allow-methods']).toContain('POST'); - }); - }); + .set('Access-Control-Request-Headers', 'Content-Type') + // The cors package answers preflights with its default 204 No Content. + expect(response.status).toBe(204) + expect(response.headers['access-control-allow-methods']).toContain('POST') + }) + }) describe('Required Headers', () => { it('should allow Content-Type header', async () => { const response = await request(app) .get('/test') .set('Origin', 'http://localhost:3000') - .set('Content-Type', 'application/json'); - expect(response.status).toBe(200); - }); + .set('Content-Type', 'application/json') + expect(response.status).toBe(200) + }) it('should allow Authorization header', async () => { const response = await request(app) .get('/test') .set('Origin', 'http://localhost:3000') - .set('Authorization', 'Bearer token123'); - expect(response.status).toBe(200); - }); + .set('Authorization', 'Bearer token123') + expect(response.status).toBe(200) + }) it('should allow Idempotency-Key header', async () => { const response = await request(app) .get('/test') .set('Origin', 'http://localhost:3000') - .set('Idempotency-Key', 'unique-key-123'); - expect(response.status).toBe(200); - }); + .set('Idempotency-Key', 'unique-key-123') + expect(response.status).toBe(200) + }) it('should allow X-Correlation-ID header', async () => { const response = await request(app) .get('/test') .set('Origin', 'http://localhost:3000') - .set('X-Correlation-ID', 'correlation-123'); - expect(response.status).toBe(200); - }); - }); + .set('X-Correlation-ID', 'correlation-123') + expect(response.status).toBe(200) + }) + }) describe('Credentials', () => { it('should allow credentials for allowed origins', async () => { const response = await request(app) .get('/test') - .set('Origin', 'http://localhost:3000'); - expect(response.headers['access-control-allow-credentials']).toBe('true'); - }); - }); -}); + .set('Origin', 'http://localhost:3000') + expect(response.headers['access-control-allow-credentials']).toBe('true') + }) + }) +}) diff --git a/tests/endpoints-auth.test.ts b/tests/endpoints-auth.test.ts index 8468f03..3bcb7cc 100644 --- a/tests/endpoints-auth.test.ts +++ b/tests/endpoints-auth.test.ts @@ -31,7 +31,7 @@ describe('Internal Endpoint Authentication', () => { it('should return 200 with valid X-Internal-Token', async () => { const token = process.env.INTERNAL_SERVICE_TOKEN if (!token) { - console.log('Skipping: INTERNAL_SERVICE_TOKEN not set') + console.warn('Skipping: INTERNAL_SERVICE_TOKEN not set') return } const res = await request(app) @@ -44,7 +44,7 @@ describe('Internal Endpoint Authentication', () => { it('should return 200 with valid Bearer token', async () => { const token = process.env.ADMIN_API_TOKEN if (!token) { - console.log('Skipping: ADMIN_API_TOKEN not set') + console.warn('Skipping: ADMIN_API_TOKEN not set') return } const res = await request(app) @@ -79,7 +79,7 @@ describe('Internal Endpoint Authentication', () => { it('should return 200 with valid X-Internal-Token', async () => { const token = process.env.INTERNAL_SERVICE_TOKEN if (!token) { - console.log('Skipping: INTERNAL_SERVICE_TOKEN not set') + console.warn('Skipping: INTERNAL_SERVICE_TOKEN not set') return } const res = await request(app) @@ -93,7 +93,7 @@ describe('Internal Endpoint Authentication', () => { it('should return 200 with valid Bearer token', async () => { const token = process.env.ADMIN_API_TOKEN if (!token) { - console.log('Skipping: ADMIN_API_TOKEN not set') + console.warn('Skipping: ADMIN_API_TOKEN not set') return } const res = await request(app) diff --git a/tests/fixtures/dlq.ts b/tests/fixtures/dlq.ts index 5c1fadd..3bbed4e 100644 --- a/tests/fixtures/dlq.ts +++ b/tests/fixtures/dlq.ts @@ -10,7 +10,7 @@ export const FIXTURE_DLQ_PENDING = { retryCount: 0, createdAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-01T00:00:00Z'), -}; +} export const FIXTURE_DLQ_RETRIED = { id: 'dlq-0002-0002-0002-000000000002', @@ -24,7 +24,7 @@ export const FIXTURE_DLQ_RETRIED = { retryCount: 2, createdAt: new Date('2024-01-01T01:00:00Z'), updatedAt: new Date('2024-01-01T02:00:00Z'), -}; +} export const FIXTURE_DLQ_RESOLVED = { id: 'dlq-0003-0003-0003-000000000003', @@ -38,10 +38,10 @@ export const FIXTURE_DLQ_RESOLVED = { retryCount: 1, createdAt: new Date('2024-01-01T02:00:00Z'), updatedAt: new Date('2024-01-01T03:00:00Z'), -}; +} export function makeDlqEntry( - overrides: Partial = {}, + overrides: Partial = {} ) { - return { ...FIXTURE_DLQ_PENDING, ...overrides }; + return { ...FIXTURE_DLQ_PENDING, ...overrides } } diff --git a/tests/fixtures/events.ts b/tests/fixtures/events.ts index 7c3626d..6899cf5 100644 --- a/tests/fixtures/events.ts +++ b/tests/fixtures/events.ts @@ -5,7 +5,7 @@ export const FIXTURE_PROCESSED_EVENT_1 = { eventType: 'deposit', ledger: 1000, processedAt: new Date('2024-01-01T00:00:00Z'), -}; +} export const FIXTURE_PROCESSED_EVENT_2 = { id: 'evt-0002-0002-0002-000000000002', @@ -14,10 +14,10 @@ export const FIXTURE_PROCESSED_EVENT_2 = { eventType: 'withdrawal', ledger: 1001, processedAt: new Date('2024-01-01T01:00:00Z'), -}; +} export function makeProcessedEvent( - overrides: Partial = {}, + overrides: Partial = {} ) { - return { ...FIXTURE_PROCESSED_EVENT_1, ...overrides }; + return { ...FIXTURE_PROCESSED_EVENT_1, ...overrides } } diff --git a/tests/fixtures/index.ts b/tests/fixtures/index.ts index b5df583..10572ac 100644 --- a/tests/fixtures/index.ts +++ b/tests/fixtures/index.ts @@ -1,4 +1,4 @@ -export * from './users'; -export * from './sessions'; -export * from './events'; -export * from './dlq'; +export * from './users' +export * from './sessions' +export * from './events' +export * from './dlq' diff --git a/tests/fixtures/sessions.ts b/tests/fixtures/sessions.ts index 8e2819c..36d2efa 100644 --- a/tests/fixtures/sessions.ts +++ b/tests/fixtures/sessions.ts @@ -1,4 +1,4 @@ -import { FIXTURE_USER_1 } from './users'; +import { FIXTURE_USER_1 } from './users' export const FIXTURE_SESSION_VALID = { id: 'session1', @@ -11,7 +11,7 @@ export const FIXTURE_SESSION_VALID = { userAgent: 'jest-test-agent', createdAt: new Date('2024-01-01T00:00:00Z'), user: { id: FIXTURE_USER_1.id, isActive: true }, -}; +} export const FIXTURE_SESSION_EXPIRED = { id: 'session-expired-1', @@ -24,11 +24,11 @@ export const FIXTURE_SESSION_EXPIRED = { userAgent: 'jest-test-agent', createdAt: new Date('2020-01-01T00:00:00Z'), user: { id: FIXTURE_USER_1.id, isActive: true }, -}; +} export function makeSession( token: string, - overrides: Record = {}, + overrides: Record = {} ) { - return { ...FIXTURE_SESSION_VALID, token, ...overrides }; + return { ...FIXTURE_SESSION_VALID, token, ...overrides } } diff --git a/tests/fixtures/users.ts b/tests/fixtures/users.ts index 313c0e3..f5b7a27 100644 --- a/tests/fixtures/users.ts +++ b/tests/fixtures/users.ts @@ -9,7 +9,7 @@ export const FIXTURE_USER_1 = { isActive: true, createdAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-01T00:00:00Z'), -}; +} export const FIXTURE_USER_2 = { id: 'a1b2c3d4-0002-0002-0002-000000000002', @@ -22,7 +22,7 @@ export const FIXTURE_USER_2 = { isActive: true, createdAt: new Date('2024-01-02T00:00:00Z'), updatedAt: new Date('2024-01-02T00:00:00Z'), -}; +} export const FIXTURE_USER_TESTNET = { id: 'a1b2c3d4-0003-0003-0003-000000000003', @@ -35,8 +35,8 @@ export const FIXTURE_USER_TESTNET = { isActive: true, createdAt: new Date('2024-01-03T00:00:00Z'), updatedAt: new Date('2024-01-03T00:00:00Z'), -}; +} export function makeUser(overrides: Partial = {}) { - return { ...FIXTURE_USER_1, ...overrides }; + return { ...FIXTURE_USER_1, ...overrides } } diff --git a/tests/integration/agent/goal-strategy-selection.integration.test.ts b/tests/integration/agent/goal-strategy-selection.integration.test.ts index 299dbcf..5a7ae6a 100644 --- a/tests/integration/agent/goal-strategy-selection.integration.test.ts +++ b/tests/integration/agent/goal-strategy-selection.integration.test.ts @@ -11,30 +11,31 @@ * TARGET_ALLOCATION with no better configured target) would not have. */ -import { executeRebalanceIfNeeded } from '../../../src/agent/router'; +import { executeRebalanceIfNeeded } from '../../../src/agent/router' -const mockSubmitRebalance = jest.fn(); +const mockSubmitRebalance = jest.fn() jest.mock('../../../src/stellar/contract', () => ({ triggerRebalance: (...args: unknown[]) => mockSubmitRebalance(...args), -})); +})) -const mockScanAllProtocols = jest.fn(); -const mockGetCurrentOnChainApy = jest.fn(); +const mockScanAllProtocols = jest.fn() +const mockGetCurrentOnChainApy = jest.fn() jest.mock('../../../src/agent/scanner', () => ({ scanAllProtocols: (...args: unknown[]) => mockScanAllProtocols(...args), - getCurrentOnChainApy: (...args: unknown[]) => mockGetCurrentOnChainApy(...args), -})); + getCurrentOnChainApy: (...args: unknown[]) => + mockGetCurrentOnChainApy(...args), +})) jest.mock('../../../src/utils/logger', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, -})); +})) -const mockSavingsGoalFindFirst = jest.fn(); -const mockProtocolRiskScoreFindMany = jest.fn().mockResolvedValue([]); -const mockPositionFindFirst = jest.fn().mockResolvedValue(null); -const mockPositionFindMany = jest.fn().mockResolvedValue([]); -const mockTransactionCreate = jest.fn().mockResolvedValue({}); -const mockAgentLogCreate = jest.fn().mockResolvedValue({ id: 'log-1' }); +const mockSavingsGoalFindFirst = jest.fn() +const mockProtocolRiskScoreFindMany = jest.fn().mockResolvedValue([]) +const mockPositionFindFirst = jest.fn().mockResolvedValue(null) +const mockPositionFindMany = jest.fn().mockResolvedValue([]) +const mockTransactionCreate = jest.fn().mockResolvedValue({}) +const mockAgentLogCreate = jest.fn().mockResolvedValue({ id: 'log-1' }) jest.mock('../../../src/db', () => ({ __esModule: true, @@ -56,39 +57,57 @@ jest.mock('../../../src/db', () => ({ create: (...args: unknown[]) => mockAgentLogCreate(...args), }, }, -})); +})) describe('Goal-driven strategy selection integration', () => { beforeEach(() => { - jest.clearAllMocks(); - mockPositionFindFirst.mockResolvedValue(null); - mockPositionFindMany.mockResolvedValue([]); - mockTransactionCreate.mockResolvedValue({}); - mockAgentLogCreate.mockResolvedValue({ id: 'log-1' }); - mockSubmitRebalance.mockResolvedValue({ hash: 'tx-hash-goal' }); - }); + jest.clearAllMocks() + mockPositionFindFirst.mockResolvedValue(null) + mockPositionFindMany.mockResolvedValue([]) + mockTransactionCreate.mockResolvedValue({}) + mockAgentLogCreate.mockResolvedValue({ id: 'log-1' }) + mockSubmitRebalance.mockResolvedValue({ hash: 'tx-hash-goal' }) + }) it('non-goal user: MAX_YIELD preference behaves exactly as before (no active goal query short-circuits it)', async () => { - mockSavingsGoalFindFirst.mockResolvedValue(null); - mockGetCurrentOnChainApy.mockResolvedValue(3.0); + mockSavingsGoalFindFirst.mockResolvedValue(null) + mockGetCurrentOnChainApy.mockResolvedValue(3.0) mockScanAllProtocols.mockResolvedValue([ - { name: 'Luma', apy: 8.0, assetSymbol: 'USDC', lastUpdated: new Date(), isAvailable: true }, - { name: 'Blend', apy: 3.0, assetSymbol: 'USDC', lastUpdated: new Date(), isAvailable: true }, - ]); + { + name: 'Luma', + apy: 8.0, + assetSymbol: 'USDC', + lastUpdated: new Date(), + isAvailable: true, + }, + { + name: 'Blend', + apy: 3.0, + assetSymbol: 'USDC', + lastUpdated: new Date(), + isAvailable: true, + }, + ]) const result = await executeRebalanceIfNeeded( 'Blend', - [{ id: 'pos-1', amount: '10000000000000000000000', userId: 'user-no-goal' }], + [ + { + id: 'pos-1', + amount: '10000000000000000000000', + userId: 'user-no-goal', + }, + ], undefined, - [{ userId: 'user-no-goal', strategyName: 'MAX_YIELD' }], - ); + [{ userId: 'user-no-goal', strategyName: 'MAX_YIELD' }] + ) expect(mockSavingsGoalFindFirst).toHaveBeenCalledWith({ where: { userId: 'user-no-goal', status: 'ACTIVE' }, - }); - expect(result).not.toBeNull(); - expect(result?.toProtocol).toBe('Luma'); - }); + }) + expect(result).not.toBeNull() + expect(result?.toProtocol).toBe('Luma') + }) it('goal user: GoalTrackingStrategy overrides a TARGET_ALLOCATION preference that would not otherwise rebalance', async () => { mockSavingsGoalFindFirst.mockResolvedValue({ @@ -98,27 +117,45 @@ describe('Goal-driven strategy selection integration', () => { startingAmount: '10000', targetDate: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), riskCeiling: null, - }); - mockGetCurrentOnChainApy.mockResolvedValue(3.0); + }) + mockGetCurrentOnChainApy.mockResolvedValue(3.0) mockScanAllProtocols.mockResolvedValue([ - { name: 'Luma', apy: 35.0, assetSymbol: 'USDC', lastUpdated: new Date(), isAvailable: true }, - { name: 'Blend', apy: 3.0, assetSymbol: 'USDC', lastUpdated: new Date(), isAvailable: true }, - ]); + { + name: 'Luma', + apy: 35.0, + assetSymbol: 'USDC', + lastUpdated: new Date(), + isAvailable: true, + }, + { + name: 'Blend', + apy: 3.0, + assetSymbol: 'USDC', + lastUpdated: new Date(), + isAvailable: true, + }, + ]) const result = await executeRebalanceIfNeeded( 'Blend', - [{ id: 'pos-2', amount: '10000000000000000000000', userId: 'user-with-goal' }], + [ + { + id: 'pos-2', + amount: '10000000000000000000000', + userId: 'user-with-goal', + }, + ], undefined, // TARGET_ALLOCATION with no targetAllocations configured would normally // decline to rebalance (see TargetAllocationStrategy's "no target // allocations configured" branch) — the active goal must override this. - [{ userId: 'user-with-goal', strategyName: 'TARGET_ALLOCATION' }], - ); + [{ userId: 'user-with-goal', strategyName: 'TARGET_ALLOCATION' }] + ) expect(mockSavingsGoalFindFirst).toHaveBeenCalledWith({ where: { userId: 'user-with-goal', status: 'ACTIVE' }, - }); - expect(result).not.toBeNull(); - expect(result?.toProtocol).toBe('Luma'); - }); -}); + }) + expect(result).not.toBeNull() + expect(result?.toProtocol).toBe('Luma') + }) +}) diff --git a/tests/integration/agent/rebalance.integration.test.ts b/tests/integration/agent/rebalance.integration.test.ts index 28f0160..c3d4a36 100644 --- a/tests/integration/agent/rebalance.integration.test.ts +++ b/tests/integration/agent/rebalance.integration.test.ts @@ -8,14 +8,14 @@ * - System-level ANALYZE logs produced by the rebalance check have userId=null */ -import { triggerRebalance, logAgentAction } from '../../../src/agent/router'; +import { triggerRebalance, logAgentAction } from '../../../src/agent/router' // ---- mock external dependencies ---------------------------------------- -const mockSubmitRebalance = jest.fn(); +const mockSubmitRebalance = jest.fn() jest.mock('../../../src/stellar/contract', () => ({ triggerRebalance: (...args: unknown[]) => mockSubmitRebalance(...args), -})); +})) jest.mock('../../../src/agent/scanner', () => ({ scanAllProtocols: jest.fn().mockResolvedValue([ @@ -28,23 +28,23 @@ jest.mock('../../../src/agent/scanner', () => ({ }, ]), getCurrentOnChainApy: jest.fn().mockResolvedValue(5.0), -})); +})) jest.mock('../../../src/utils/logger', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, -})); +})) // In-memory log store to simulate db.agentLog.create const agentLogStore: Array<{ - userId: string | null; - positionId: string | null; - action: string; - status: string; -}> = []; - -const mockPositionFindFirst = jest.fn(); -const mockPositionFindMany = jest.fn(); -const mockTransactionCreate = jest.fn().mockResolvedValue({}); + userId: string | null + positionId: string | null + action: string + status: string +}> = [] + +const mockPositionFindFirst = jest.fn() +const mockPositionFindMany = jest.fn() +const mockTransactionCreate = jest.fn().mockResolvedValue({}) const mockAgentLogCreate = jest .fn() .mockImplementation(({ data }: { data: any }) => { @@ -53,9 +53,9 @@ const mockAgentLogCreate = jest positionId: data.positionId ?? null, action: data.action, status: data.status, - }); - return Promise.resolve({ id: `log-${agentLogStore.length}` }); - }); + }) + return Promise.resolve({ id: `log-${agentLogStore.length}` }) + }) jest.mock('../../../src/db', () => ({ __esModule: true, @@ -72,22 +72,24 @@ jest.mock('../../../src/db', () => ({ }, user: { // Should NOT be called by logAgentAction anymore - findMany: jest.fn().mockRejectedValue( - new Error('db.user.findMany should not be called for agent logging'), - ), + findMany: jest + .fn() + .mockRejectedValue( + new Error('db.user.findMany should not be called for agent logging') + ), }, }, -})); +})) // ------------------------------------------------------------------------ describe('Rebalance integration: per-user agent log attribution', () => { beforeEach(() => { - jest.clearAllMocks(); - agentLogStore.length = 0; + jest.clearAllMocks() + agentLogStore.length = 0 - mockSubmitRebalance.mockResolvedValue({ hash: 'tx-hash-001' }); - }); + mockSubmitRebalance.mockResolvedValue({ hash: 'tx-hash-001' }) + }) describe('triggerRebalance with multiple impacted positions', () => { it('creates one REBALANCE log per (userId, positionId) pair', async () => { @@ -105,31 +107,33 @@ describe('Rebalance integration: per-user agent log attribution', () => { assetSymbol: 'USDC', user: { network: 'MAINNET' }, }, - ]; + ] // findFirst is used to create the Transaction record (existing behaviour) - mockPositionFindFirst.mockResolvedValue(positions[0]); + mockPositionFindFirst.mockResolvedValue(positions[0]) // findMany is used by logAgentAction to get all affected positions - mockPositionFindMany.mockResolvedValue(positions); + mockPositionFindMany.mockResolvedValue(positions) - const positionIds = positions.map((p) => p.id); + const positionIds = positions.map((p) => p.id) - await triggerRebalance('protocol-a', 'protocol-b', '1000000', positionIds); + await triggerRebalance('protocol-a', 'protocol-b', '1000000', positionIds) - const rebalanceLogs = agentLogStore.filter((l) => l.action === 'REBALANCE'); - expect(rebalanceLogs).toHaveLength(2); + const rebalanceLogs = agentLogStore.filter( + (l) => l.action === 'REBALANCE' + ) + expect(rebalanceLogs).toHaveLength(2) - const loggedUserIds = rebalanceLogs.map((l) => l.userId); - expect(loggedUserIds).toContain('user-1'); - expect(loggedUserIds).toContain('user-2'); + const loggedUserIds = rebalanceLogs.map((l) => l.userId) + expect(loggedUserIds).toContain('user-1') + expect(loggedUserIds).toContain('user-2') - const loggedPositionIds = rebalanceLogs.map((l) => l.positionId); - expect(loggedPositionIds).toContain('pos-user1'); - expect(loggedPositionIds).toContain('pos-user2'); - }); + const loggedPositionIds = rebalanceLogs.map((l) => l.positionId) + expect(loggedPositionIds).toContain('pos-user1') + expect(loggedPositionIds).toContain('pos-user2') + }) it('does not write a log row against an arbitrary first user', async () => { - const arbitraryFirstUserId = 'first-user-in-db'; + const arbitraryFirstUserId = 'first-user-in-db' const positions = [ { @@ -138,33 +142,37 @@ describe('Rebalance integration: per-user agent log attribution', () => { assetSymbol: 'USDC', user: { network: 'MAINNET' }, }, - ]; - mockPositionFindFirst.mockResolvedValue(positions[0]); - mockPositionFindMany.mockResolvedValue(positions); + ] + mockPositionFindFirst.mockResolvedValue(positions[0]) + mockPositionFindMany.mockResolvedValue(positions) await triggerRebalance( 'protocol-a', 'protocol-b', '500000', - positions.map((p) => p.id), - ); + positions.map((p) => p.id) + ) - const rebalanceLogs = agentLogStore.filter((l) => l.action === 'REBALANCE'); - const writtenUserIds = rebalanceLogs.map((l) => l.userId); + const rebalanceLogs = agentLogStore.filter( + (l) => l.action === 'REBALANCE' + ) + const writtenUserIds = rebalanceLogs.map((l) => l.userId) - expect(writtenUserIds).not.toContain(arbitraryFirstUserId); - expect(writtenUserIds).toEqual(['user-alice']); - }); + expect(writtenUserIds).not.toContain(arbitraryFirstUserId) + expect(writtenUserIds).toEqual(['user-alice']) + }) it('writes log with userId=null when no positionIds are provided (system-level)', async () => { // triggerRebalance called without position ids - await triggerRebalance('protocol-a', 'protocol-b', '1000', []); + await triggerRebalance('protocol-a', 'protocol-b', '1000', []) - const rebalanceLogs = agentLogStore.filter((l) => l.action === 'REBALANCE'); - expect(rebalanceLogs).toHaveLength(1); - expect(rebalanceLogs[0].userId).toBeNull(); - expect(rebalanceLogs[0].positionId).toBeNull(); - }); + const rebalanceLogs = agentLogStore.filter( + (l) => l.action === 'REBALANCE' + ) + expect(rebalanceLogs).toHaveLength(1) + expect(rebalanceLogs[0].userId).toBeNull() + expect(rebalanceLogs[0].positionId).toBeNull() + }) it('deduplicates logs when the same userId/positionId appears multiple times', async () => { // Same position repeated twice (edge case guard) @@ -181,37 +189,37 @@ describe('Rebalance integration: per-user agent log attribution', () => { assetSymbol: 'USDC', user: { network: 'MAINNET' }, }, - ]; - mockPositionFindFirst.mockResolvedValue(positions[0]); - mockPositionFindMany.mockResolvedValue(positions); - - await triggerRebalance( - 'protocol-a', - 'protocol-b', - '250000', - ['pos-dup', 'pos-dup'], - ); - - const rebalanceLogs = agentLogStore.filter((l) => l.action === 'REBALANCE'); - expect(rebalanceLogs).toHaveLength(1); - }); - }); + ] + mockPositionFindFirst.mockResolvedValue(positions[0]) + mockPositionFindMany.mockResolvedValue(positions) + + await triggerRebalance('protocol-a', 'protocol-b', '250000', [ + 'pos-dup', + 'pos-dup', + ]) + + const rebalanceLogs = agentLogStore.filter( + (l) => l.action === 'REBALANCE' + ) + expect(rebalanceLogs).toHaveLength(1) + }) + }) describe('logAgentAction standalone', () => { it('system scan (ANALYZE) log has userId=null', async () => { - await logAgentAction('ANALYZE', 'SUCCESS', { positionsChecked: 10 }); + await logAgentAction('ANALYZE', 'SUCCESS', { positionsChecked: 10 }) - expect(agentLogStore).toHaveLength(1); - expect(agentLogStore[0].userId).toBeNull(); - expect(agentLogStore[0].action).toBe('ANALYZE'); - }); + expect(agentLogStore).toHaveLength(1) + expect(agentLogStore[0].userId).toBeNull() + expect(agentLogStore[0].action).toBe('ANALYZE') + }) it('per-user log has correct userId and positionId', async () => { - await logAgentAction('REBALANCE', 'SUCCESS', {}, 'user-xyz', 'pos-xyz'); - - expect(agentLogStore).toHaveLength(1); - expect(agentLogStore[0].userId).toBe('user-xyz'); - expect(agentLogStore[0].positionId).toBe('pos-xyz'); - }); - }); -}); + await logAgentAction('REBALANCE', 'SUCCESS', {}, 'user-xyz', 'pos-xyz') + + expect(agentLogStore).toHaveLength(1) + expect(agentLogStore[0].userId).toBe('user-xyz') + expect(agentLogStore[0].positionId).toBe('pos-xyz') + }) + }) +}) diff --git a/tests/integration/correlationId.integration.test.ts b/tests/integration/correlationId.integration.test.ts index 970d5db..de068de 100644 --- a/tests/integration/correlationId.integration.test.ts +++ b/tests/integration/correlationId.integration.test.ts @@ -1,3 +1,8 @@ +// requestLogger samples healthy requests (LOG_SAMPLE_RATE, default 0.1) and +// reads the rate at module load — force full logging BEFORE the import below +// so the log assertion is deterministic. +process.env.LOG_SAMPLE_RATE = '1' + import express from 'express' import request from 'supertest' import { correlationIdMiddleware } from '../../src/middleware/correlationId' diff --git a/tests/integration/deposit-withdraw.integration.test.ts b/tests/integration/deposit-withdraw.integration.test.ts index bf184b1..c800832 100644 --- a/tests/integration/deposit-withdraw.integration.test.ts +++ b/tests/integration/deposit-withdraw.integration.test.ts @@ -16,6 +16,7 @@ declare const beforeEach: any declare const expect: any import { createCustodialWallet } from '../../src/stellar/wallet' +import { JwtAdapter } from '../../src/config' import { config } from '../../src/config/env' function uuid(): string { @@ -28,6 +29,13 @@ function uuid(): string { const mockDepositForUser = jest.fn() const mockWithdrawForUser = jest.fn() +// The fake contract events below carry plain JS objects, not XDR ScVals — +// pass them straight through the parser. +jest.mock('@stellar/stellar-sdk', () => { + const actual = jest.requireActual('@stellar/stellar-sdk') + return { ...actual, scValToNative: (v: unknown) => v } +}) + jest.mock('../../src/stellar/contract', () => ({ __esModule: true, // controller imports depositForUser/withdrawForUser @@ -44,11 +52,15 @@ jest.mock('../../src/utils/metrics', () => ({ recordEventDuration: jest.fn(), recordEventFailed: jest.fn(), recordEventProcessed: jest.fn(), + recordHttpRequest: jest.fn(), + recordRequestTimeout: jest.fn(), + recordRejectedRequest: jest.fn(), })) // Avoid external alerting side effects jest.mock('../../src/services/alerting', () => ({ alertingService: { + emit: jest.fn(async () => {}), emitDLQAlert: jest.fn(), clearDLQAlertState: jest.fn(), }, @@ -92,10 +104,6 @@ jest.mock('../../src/stellar/events', () => { } }) -function randomToken(): string { - return `it-token-${uuid()}` -} - async function seedAuthAndWallet(): Promise<{ userId: string walletAddress: string @@ -121,7 +129,11 @@ async function seedAuthAndWallet(): Promise<{ await createCustodialWallet(user.id) - const sessionToken = randomToken() + // requireAuth verifies the JWT signature before the DB session lookup, so + // the session token must be a real signed JWT — a random string 401s. + const sessionToken = (await JwtAdapter.generateToken({ + id: user.id, + })) as string await db.session.create({ data: { @@ -168,8 +180,10 @@ describe('E2E integration — deposit and withdraw flows (#219)', () => { const { userId, walletAddress, sessionToken } = await seedAuthAndWallet() // Seed cursor row so we can assert it advances. - await db.eventCursor.create({ - data: { + await db.eventCursor.upsert({ + where: { contractId: config.stellar.vaultContractId }, + update: { lastProcessedLedger: 10 }, + create: { contractId: config.stellar.vaultContractId, lastProcessedLedger: 10, }, @@ -220,7 +234,7 @@ describe('E2E integration — deposit and withdraw flows (#219)', () => { ledger: 50, txHash, contractId: config.stellar.vaultContractId, - topics: ['deposit', walletAddress, protocolName], + topics: ['deposit', assetSymbol, protocolName], value: { user: walletAddress, amount: depositAmount.toString(), @@ -249,11 +263,19 @@ describe('E2E integration — deposit and withdraw flows (#219)', () => { expect(Number(position!.depositedAmount)).toBeCloseTo(depositAmount) expect(Number(position!.currentValue)).toBeCloseTo(depositAmount) - const cursor = await db.eventCursor.findUnique({ - where: { contractId: config.stellar.vaultContractId }, + // The cursor advances in the polling loop (fetchEvents), not per-event — + // handleEvent's completion marker is the ProcessedEvent dedup row. + const processed = await db.processedEvent.findUnique({ + where: { + contractId_txHash_eventType_ledger: { + contractId: config.stellar.vaultContractId, + txHash, + eventType: 'deposit', + ledger: 50, + }, + }, }) - expect(cursor).toBeTruthy() - expect(cursor!.lastProcessedLedger).toBe(50) + expect(processed).toBeTruthy() }) it('POST /api/withdraw (happy path): verifies balance deduction + transaction record', async () => { @@ -272,8 +294,10 @@ describe('E2E integration — deposit and withdraw flows (#219)', () => { }, }) - await db.eventCursor.create({ - data: { + await db.eventCursor.upsert({ + where: { contractId: config.stellar.vaultContractId }, + update: { lastProcessedLedger: 10 }, + create: { contractId: config.stellar.vaultContractId, lastProcessedLedger: 10, }, @@ -307,7 +331,7 @@ describe('E2E integration — deposit and withdraw flows (#219)', () => { ledger: 70, txHash, contractId: config.stellar.vaultContractId, - topics: ['withdraw', walletAddress, 'Blend'], + topics: ['withdraw', 'USDC', 'Blend'], value: { user: walletAddress, amount: withdrawAmount.toString(), @@ -335,10 +359,19 @@ describe('E2E integration — deposit and withdraw flows (#219)', () => { expect(transactionRow?.type).toBe('WITHDRAWAL') expect(transactionRow?.status).toBe('CONFIRMED') - const cursor = await db.eventCursor.findUnique({ - where: { contractId: config.stellar.vaultContractId }, + // Cursor advancement is the polling loop's job — assert the per-event + // ProcessedEvent marker instead. + const processed = await db.processedEvent.findUnique({ + where: { + contractId_txHash_eventType_ledger: { + contractId: config.stellar.vaultContractId, + txHash, + eventType: 'withdraw', + ledger: 70, + }, + }, }) - expect(cursor!.lastProcessedLedger).toBe(70) + expect(processed).toBeTruthy() }) it('POST /api/withdraw (error path): RPC/event processing failure → DLQ row created', async () => { @@ -382,7 +415,7 @@ describe('E2E integration — deposit and withdraw flows (#219)', () => { ledger: 90, txHash, contractId: config.stellar.vaultContractId, - topics: ['withdraw', 'bad-wallet', 'Blend'], + topics: ['withdraw', 'USDC', 'Blend'], value: { user: 'bad-wallet', amount: '1', shares: '1' }, } as any) } catch { @@ -396,7 +429,7 @@ describe('E2E integration — deposit and withdraw flows (#219)', () => { ledger: 90, txHash, contractId: config.stellar.vaultContractId, - topics: ['withdraw', 'bad-wallet', 'Blend'], + topics: ['withdraw', 'USDC', 'Blend'], value: { user: 'bad-wallet', amount: '1', @@ -409,6 +442,6 @@ describe('E2E integration — deposit and withdraw flows (#219)', () => { const dlqRows = await db.deadLetterEvent.findMany({ where: { txHash } }) expect(dlqRows.length).toBeGreaterThanOrEqual(1) expect(dlqRows[0].status).toBe('PENDING') - expect(dlqRows[0].eventType).toBe('withdrawal') + expect(dlqRows[0].eventType).toBe('withdraw') }) }) diff --git a/tests/integration/fiat.integration.test.ts b/tests/integration/fiat.integration.test.ts index 886aef6..1e25fd6 100644 --- a/tests/integration/fiat.integration.test.ts +++ b/tests/integration/fiat.integration.test.ts @@ -2,14 +2,17 @@ // Express app with the auth middleware and service layer mocked, so it verifies // the HTTP wiring (validation, status codes, owner-scoping, and the raw-body // webhook signature path) without a live database or provider network calls. +const mockUserId = '11111111-1111-4111-8111-111111111111' +const mockOtherUserId = '22222222-2222-4222-8222-222222222222' + import request from 'supertest' import express from 'express' // --- Auth: stub requireAuth/enforceUserAccess to inject a fixed identity ------ jest.mock('../../src/middleware/authenticate', () => ({ requireAuth: (req: any, _res: any, next: any) => { - req.userId = 'user-1' - req.auth = { userId: 'user-1', walletAddress: 'GWALLET_USER_1' } + req.userId = mockUserId + req.auth = { userId: mockUserId, walletAddress: 'GWALLET_USER_1' } next() }, enforceUserAccess: (req: any, res: any, next: any) => { @@ -22,7 +25,12 @@ jest.mock('../../src/middleware/authenticate', () => ({ })) jest.mock('../../src/utils/logger', () => ({ - logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, })) // --- Service layer: mock so no DB / provider network is touched --------------- @@ -79,50 +87,74 @@ beforeEach(() => { describe('POST /api/fiat/quote', () => { it('returns a quote for a valid request', async () => { - mockGetFiatQuote.mockResolvedValue({ provider: 'moonpay', cryptoAmount: 98.5 }) - const res = await request(app) - .post('/api/fiat/quote') - .send({ direction: 'ON_RAMP', fiatAmount: 100, fiatCurrency: 'usd', assetSymbol: 'USDC' }) + mockGetFiatQuote.mockResolvedValue({ + provider: 'moonpay', + cryptoAmount: 98.5, + }) + const res = await request(app).post('/api/fiat/quote').send({ + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'usd', + assetSymbol: 'USDC', + }) expect(res.status).toBe(200) expect(res.body.cryptoAmount).toBe(98.5) }) it('rejects an invalid body with 400', async () => { - const res = await request(app) - .post('/api/fiat/quote') - .send({ direction: 'SIDEWAYS', fiatAmount: -5, fiatCurrency: 'usd', assetSymbol: 'USDC' }) + const res = await request(app).post('/api/fiat/quote').send({ + direction: 'SIDEWAYS', + fiatAmount: -5, + fiatCurrency: 'usd', + assetSymbol: 'USDC', + }) expect(res.status).toBe(400) expect(mockGetFiatQuote).not.toHaveBeenCalled() }) it('returns 502 when the provider errors', async () => { mockGetFiatQuote.mockRejectedValue(new Error('provider down')) - const res = await request(app) - .post('/api/fiat/quote') - .send({ direction: 'ON_RAMP', fiatAmount: 100, fiatCurrency: 'USD', assetSymbol: 'USDC' }) + const res = await request(app).post('/api/fiat/quote').send({ + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }) expect(res.status).toBe(502) }) }) describe('POST /api/fiat/orders', () => { it('creates an order for the authenticated user', async () => { - mockCreateFiatOrder.mockResolvedValue({ id: 'order-1', status: 'PENDING', checkoutUrl: 'https://pay' }) - const res = await request(app) - .post('/api/fiat/orders') - .send({ userId: 'user-1', direction: 'ON_RAMP', fiatAmount: 100, fiatCurrency: 'USD', assetSymbol: 'USDC' }) + mockCreateFiatOrder.mockResolvedValue({ + id: 'order-1', + status: 'PENDING', + checkoutUrl: 'https://pay', + }) + const res = await request(app).post('/api/fiat/orders').send({ + userId: mockUserId, + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }) expect(res.status).toBe(201) expect(res.body.checkoutUrl).toBe('https://pay') // The wallet address comes from the authenticated session, not the body. expect(mockCreateFiatOrder).toHaveBeenCalledWith( expect.any(Object), - expect.objectContaining({ walletAddress: 'GWALLET_USER_1' }), + expect.objectContaining({ walletAddress: 'GWALLET_USER_1' }) ) }) it('forbids creating an order on behalf of another user', async () => { - const res = await request(app) - .post('/api/fiat/orders') - .send({ userId: 'someone-else', direction: 'ON_RAMP', fiatAmount: 100, fiatCurrency: 'USD', assetSymbol: 'USDC' }) + const res = await request(app).post('/api/fiat/orders').send({ + userId: mockOtherUserId, + direction: 'ON_RAMP', + fiatAmount: 100, + fiatCurrency: 'USD', + assetSymbol: 'USDC', + }) expect(res.status).toBe(403) expect(mockCreateFiatOrder).not.toHaveBeenCalled() }) @@ -135,21 +167,27 @@ describe('GET /api/fiat/orders', () => { expect(res.status).toBe(200) expect(res.body.orders).toHaveLength(1) expect(mockDb.fiatOrder.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { userId: 'user-1' } }), + expect.objectContaining({ where: { userId: mockUserId } }) ) }) }) describe('GET /api/fiat/orders/:id', () => { it('returns the order when owned by the caller', async () => { - mockDb.fiatOrder.findUnique.mockResolvedValue({ id: 'order-1', userId: 'user-1' }) + mockDb.fiatOrder.findUnique.mockResolvedValue({ + id: 'order-1', + userId: mockUserId, + }) const res = await request(app).get('/api/fiat/orders/order-1') expect(res.status).toBe(200) expect(res.body.id).toBe('order-1') }) it("returns 404 for another user's order (no existence leak)", async () => { - mockDb.fiatOrder.findUnique.mockResolvedValue({ id: 'order-1', userId: 'other' }) + mockDb.fiatOrder.findUnique.mockResolvedValue({ + id: 'order-1', + userId: 'other', + }) const res = await request(app).get('/api/fiat/orders/order-1') expect(res.status).toBe(404) }) @@ -174,14 +212,21 @@ describe('POST /api/fiat/webhook/:provider', () => { it('processes a verified delivery and ACKs 200', async () => { mockVerify.mockReturnValue(true) mockParse.mockReturnValue({ providerOrderId: 'mp_1', status: 'PROCESSING' }) - mockProcessProviderWebhook.mockResolvedValue({ handled: true, orderId: 'order-1', status: 'PROCESSING' }) + mockProcessProviderWebhook.mockResolvedValue({ + handled: true, + orderId: 'order-1', + status: 'PROCESSING', + }) const res = await request(app) .post('/api/fiat/webhook/moonpay') .set('Content-Type', 'application/json') .send({ data: { id: 'mp_1', status: 'pending' } }) expect(res.status).toBe(200) expect(res.body.received).toBe(true) - expect(mockProcessProviderWebhook).toHaveBeenCalledWith('moonpay', { providerOrderId: 'mp_1', status: 'PROCESSING' }) + expect(mockProcessProviderWebhook).toHaveBeenCalledWith('moonpay', { + providerOrderId: 'mp_1', + status: 'PROCESSING', + }) }) it('returns 400 on a malformed (unparseable) payload', async () => { diff --git a/tests/integration/http-client.integration.test.ts b/tests/integration/http-client.integration.test.ts index 3c3127f..0810323 100644 --- a/tests/integration/http-client.integration.test.ts +++ b/tests/integration/http-client.integration.test.ts @@ -41,13 +41,16 @@ describe('HttpClientAdapter Integration — simulated failures', () => { const simulateTimeoutThenSuccess = async (): Promise => { callCount++ if (callCount <= 1) { - await new Promise(r => setTimeout(r, 600)) + await new Promise((r) => setTimeout(r, 600)) throw new TimeoutError(500, 'simulated') } return 'data' } - const result = await adapter.execute(simulateTimeoutThenSuccess, 'timeoutApi.fetch') + const result = await adapter.execute( + simulateTimeoutThenSuccess, + 'timeoutApi.fetch' + ) expect(result).toBe('data') expect(callCount).toBe(2) }) @@ -55,7 +58,9 @@ describe('HttpClientAdapter Integration — simulated failures', () => { describe('persistent failures — circuit breaker opens', () => { it('should open circuit after consecutive failures', async () => { - const simulateDownstream = jest.fn().mockRejectedValue(new Error('HTTP 502 Bad Gateway')) + const simulateDownstream = jest + .fn() + .mockRejectedValue(new Error('HTTP 502 Bad Gateway')) // First execute exhausts all retries (1 initial + 2 retries = 3 failures) // After 3 failures circuit breaker opens @@ -77,7 +82,9 @@ describe('HttpClientAdapter Integration — simulated failures', () => { }) it('should block requests with CircuitBreakerError after threshold', async () => { - const simulateDownstream = jest.fn().mockRejectedValue(new Error('Service Down')) + const simulateDownstream = jest + .fn() + .mockRejectedValue(new Error('Service Down')) // Exhaust all retries for first execute (should consume all 3 failure slots) await expect( @@ -139,13 +146,16 @@ describe('HttpClientAdapter Integration — simulated failures', () => { const simulateSlowThenFast = async (): Promise => { callCount++ if (callCount <= 1) { - await new Promise(r => setTimeout(r, 100)) + await new Promise((r) => setTimeout(r, 100)) throw new TimeoutError(30, 'simulated') } return 'fast response' } - const result = await fastTimeoutAdapter.execute(simulateSlowThenFast, 'slowApi.get') + const result = await fastTimeoutAdapter.execute( + simulateSlowThenFast, + 'slowApi.get' + ) expect(result).toBe('fast response') expect(callCount).toBe(2) }) @@ -168,7 +178,7 @@ describe('HttpClientAdapter Integration — simulated failures', () => { callCount++ switch (callCount) { case 1: - await new Promise(r => setTimeout(r, 100)) + await new Promise((r) => setTimeout(r, 100)) throw new TimeoutError(50, 'simulated timeout') case 2: throw new Error('HTTP 500 Internal Server Error') @@ -179,7 +189,10 @@ describe('HttpClientAdapter Integration — simulated failures', () => { } } - const result = await mixedAdapter.execute(simulateChaoticApi, 'chaoticApi.fetch') + const result = await mixedAdapter.execute( + simulateChaoticApi, + 'chaoticApi.fetch' + ) expect(result).toBe('success after chaos') expect(callCount).toBe(3) }) @@ -214,7 +227,10 @@ describe('HttpClientAdapter Integration — simulated failures', () => { jest.advanceTimersByTime(600) simulateStellarRpc.mockResolvedValue('tx_hash_abc') - const hash = await stellarAdapter.execute(simulateStellarRpc, 'stellar.submitTransaction') + const hash = await stellarAdapter.execute( + simulateStellarRpc, + 'stellar.submitTransaction' + ) expect(hash).toBe('tx_hash_abc') jest.useRealTimers() @@ -237,10 +253,17 @@ describe('HttpClientAdapter Integration — simulated failures', () => { // Transient failure then success simulateAnthropicApi .mockRejectedValueOnce(new Error('anthropic: rate limited')) - .mockResolvedValueOnce({ content: [{ type: 'text', text: '{"action":"balance"}' }] }) - - const result = await anthropicAdapter.execute(simulateAnthropicApi, 'anthropic.parseIntent') - expect(result).toEqual({ content: [{ type: 'text', text: '{"action":"balance"}' }] }) + .mockResolvedValueOnce({ + content: [{ type: 'text', text: '{"action":"balance"}' }], + }) + + const result = await anthropicAdapter.execute( + simulateAnthropicApi, + 'anthropic.parseIntent' + ) + expect(result).toEqual({ + content: [{ type: 'text', text: '{"action":"balance"}' }], + }) expect(simulateAnthropicApi).toHaveBeenCalledTimes(2) }) }) @@ -263,7 +286,10 @@ describe('HttpClientAdapter Integration — simulated failures', () => { .mockRejectedValueOnce(new Error('twilio: upstream timeout')) .mockResolvedValueOnce({ sid: 'SM12345' }) - const result = await twilioAdapter.execute(simulateTwilioApi, 'twilio.sendWhatsAppMessage') + const result = await twilioAdapter.execute( + simulateTwilioApi, + 'twilio.sendWhatsAppMessage' + ) expect(result).toEqual({ sid: 'SM12345' }) expect(simulateTwilioApi).toHaveBeenCalledTimes(2) }) diff --git a/tests/integration/rateLimiter.integration.test.ts b/tests/integration/rateLimiter.integration.test.ts index cfe950c..888db9c 100644 --- a/tests/integration/rateLimiter.integration.test.ts +++ b/tests/integration/rateLimiter.integration.test.ts @@ -52,7 +52,11 @@ describe('rate limiter – IETF rate-limit headers', () => { }) it('reflects the configured limit and window in RateLimit-Policy for custom limiters', async () => { - const limiter = buildRateLimiter({ windowMs: 60000, max: 30, limiterType: 'test' }) + const limiter = buildRateLimiter({ + windowMs: 60000, + max: 30, + limiterType: 'test', + }) const app = buildTestApp(limiter) const res = await request(app).get('/test') @@ -62,7 +66,11 @@ describe('rate limiter – IETF rate-limit headers', () => { describe('throttled (429) responses', () => { it('returns 429 after the request budget is exhausted', async () => { - const limiter = buildRateLimiter({ windowMs: 60000, max: 1, limiterType: 'test' }) + const limiter = buildRateLimiter({ + windowMs: 60000, + max: 1, + limiterType: 'test', + }) const app = buildTestApp(limiter) await request(app).get('/test') // uses up the single allowed request @@ -73,7 +81,11 @@ describe('rate limiter – IETF rate-limit headers', () => { }) it('sets Retry-After (positive integer, seconds) on 429 responses', async () => { - const limiter = buildRateLimiter({ windowMs: 60000, max: 1, limiterType: 'test' }) + const limiter = buildRateLimiter({ + windowMs: 60000, + max: 1, + limiterType: 'test', + }) const app = buildTestApp(limiter) await request(app).get('/test') @@ -87,7 +99,11 @@ describe('rate limiter – IETF rate-limit headers', () => { }) it('sets RateLimit-Policy on 429 responses', async () => { - const limiter = buildRateLimiter({ windowMs: 60000, max: 1, limiterType: 'test' }) + const limiter = buildRateLimiter({ + windowMs: 60000, + max: 1, + limiterType: 'test', + }) const app = buildTestApp(limiter) await request(app).get('/test') diff --git a/tests/integration/security-headers.integration.test.ts b/tests/integration/security-headers.integration.test.ts index e03935c..6efd939 100644 --- a/tests/integration/security-headers.integration.test.ts +++ b/tests/integration/security-headers.integration.test.ts @@ -1,6 +1,9 @@ import express from 'express' import request from 'supertest' -import { securityHeaders, permissionsPolicy } from '../../src/middleware/security' +import { + securityHeaders, + permissionsPolicy, +} from '../../src/middleware/security' jest.mock('../../src/config/env', () => ({ config: { @@ -97,8 +100,9 @@ describe('security headers — non-production (CSP and HSTS disabled)', () => { jest.doMock('../../src/config/env', () => ({ config: { nodeEnv: 'test', security: { trustProxy: 1 } }, })) - const { securityHeaders: sh, permissionsPolicy: pp } = - jest.requireActual('../../src/middleware/security') as typeof import('../../src/middleware/security') + const { securityHeaders: sh, permissionsPolicy: pp } = jest.requireActual( + '../../src/middleware/security' + ) as typeof import('../../src/middleware/security') app = express() app.use(sh()) app.use(pp()) diff --git a/tests/integration/tax-report.integration.test.ts b/tests/integration/tax-report.integration.test.ts new file mode 100644 index 0000000..7502208 --- /dev/null +++ b/tests/integration/tax-report.integration.test.ts @@ -0,0 +1,250 @@ +import request from 'supertest' + +import db from '../../src/db' +import app from '../../src' +import { JwtAdapter } from '../../src/config' +import { + createLotForDeposit, + recordDisposalsForWithdrawal, +} from '../../src/tax/service' + +// Keep the server boot from polling real RPC / emitting external alerts. +jest.mock('../../src/stellar/events', () => { + const actual = jest.requireActual('../../src/stellar/events') + return { + __esModule: true, + ...actual, + startEventListener: jest.fn().mockResolvedValue(undefined), + stopEventListener: jest.fn(), + } +}) +jest.mock('../../src/services/alerting', () => ({ + alertingService: { + emit: jest.fn().mockResolvedValue({ sent: true }), + emitDLQAlert: jest.fn(), + clearDLQAlertState: jest.fn(), + }, +})) +jest.mock('../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})) + +function uuid(): string { + return `t-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +async function seedUserWithSession(): Promise<{ + userId: string + token: string +}> { + const walletAddress = + `G${uuid().replace(/-/g, '').slice(0, 47)}TAXREPORT`.slice(0, 56) + + const user = await db.user.create({ + data: { + walletAddress, + network: 'TESTNET', + displayName: 'Tax IT', + email: `tax-${Date.now()}-${Math.random()}@example.com`, + isActive: true, + }, + }) + + const token = (await JwtAdapter.generateToken({ id: user.id })) as string + await db.session.create({ + data: { + userId: user.id, + token, + walletAddress, + network: 'TESTNET', + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }) + + return { userId: user.id, token } +} + +async function seedConfirmedTransaction( + userId: string, + type: 'DEPOSIT' | 'WITHDRAWAL', + assetSymbol: string, + amount: string, + confirmedAt: Date +) { + return db.transaction.create({ + data: { + userId, + txHash: `tx-${uuid()}`, + type, + status: 'CONFIRMED', + assetSymbol, + amount, + network: 'TESTNET', + confirmedAt, + }, + }) +} + +describe('GET /api/v1/portfolio/:userId/tax-report (#284)', () => { + it('rejects unauthenticated requests with 401', async () => { + const { userId } = await seedUserWithSession() + + const res = await request(app).get( + `/api/v1/portfolio/${userId}/tax-report?year=2026` + ) + + expect(res.status).toBe(401) + }) + + it("rejects another user's report with 401 (enforceUserAccess)", async () => { + const alice = await seedUserWithSession() + const bob = await seedUserWithSession() + + const res = await request(app) + .get(`/api/v1/portfolio/${bob.userId}/tax-report?year=2026`) + .set('Authorization', `Bearer ${alice.token}`) + + expect(res.status).toBe(401) + }) + + it('returns the FIFO report as JSON with priced totals', async () => { + const { userId, token } = await seedUserWithSession() + + const deposit = await seedConfirmedTransaction( + userId, + 'DEPOSIT', + 'USDC', + '100', + new Date('2026-01-15T00:00:00Z') + ) + await createLotForDeposit( + userId, + deposit.id, + 'USDC', + '100', + deposit.confirmedAt as Date + ) + + const withdrawal = await seedConfirmedTransaction( + userId, + 'WITHDRAWAL', + 'USDC', + '40', + new Date('2026-06-15T00:00:00Z') + ) + await recordDisposalsForWithdrawal( + userId, + withdrawal.id, + 'USDC', + '40', + withdrawal.confirmedAt as Date + ) + + const res = await request(app) + .get(`/api/v1/portfolio/${userId}/tax-report?year=2026`) + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(200) + expect(res.body.method).toBe('FIFO') + expect(res.body.disposals).toHaveLength(1) + expect(res.body.disposals[0]).toMatchObject({ + assetSymbol: 'USDC', + amount: '40', + priced: true, + realizedGain: '0', + withdrawalTxHash: withdrawal.txHash, + acquisitionTxHash: deposit.txHash, + }) + expect(res.body.totals).toEqual({ + proceeds: '40', + costBasis: '40', + realizedGain: '0', + pricedDisposalCount: 1, + }) + + // Lot remaining was decremented in the database. + const lot = await db.costBasisLot.findUnique({ + where: { transactionId: deposit.id }, + }) + expect(Number(lot!.remainingAmount)).toBe(60) + }) + + it('returns CSV with headers and injection-safe cells', async () => { + const { userId, token } = await seedUserWithSession() + + // Excel-hostile asset symbol: must arrive prefixed, never as a formula. + const evilAsset = '=SUM(A1:A9)' + const deposit = await seedConfirmedTransaction( + userId, + 'DEPOSIT', + evilAsset, + '10', + new Date('2026-02-01T00:00:00Z') + ) + await createLotForDeposit( + userId, + deposit.id, + evilAsset, + '10', + deposit.confirmedAt as Date + ) + const withdrawal = await seedConfirmedTransaction( + userId, + 'WITHDRAWAL', + evilAsset, + '10', + new Date('2026-03-01T00:00:00Z') + ) + await recordDisposalsForWithdrawal( + userId, + withdrawal.id, + evilAsset, + '10', + withdrawal.confirmedAt as Date + ) + + const res = await request(app) + .get(`/api/v1/portfolio/${userId}/tax-report?year=2026&format=csv`) + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(200) + expect(res.headers['content-type']).toContain('text/csv') + expect(res.headers['content-disposition']).toBe( + 'attachment; filename="tax-report-2026.csv"' + ) + const [headerLine, firstRow] = res.text.split('\r\n') + expect(headerLine).toBe( + 'disposedAt,assetSymbol,amount,withdrawalTxHash,acquiredAt,acquisitionTxHash,acquisitionPrice,disposalPrice,costBasis,proceeds,realizedGain,priced' + ) + expect(firstRow).toContain(`,'=SUM(A1:A9),`) + expect(firstRow).not.toContain(',=SUM') + }) + + it('returns a valid empty report for a year with no activity', async () => { + const { userId, token } = await seedUserWithSession() + + const res = await request(app) + .get(`/api/v1/portfolio/${userId}/tax-report?year=2020`) + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(200) + expect(res.body.disposals).toEqual([]) + expect(res.body.totals.realizedGain).toBe('0') + expect(res.body.caveats.unpricedDisposalCount).toBe(0) + }) + + it('rejects an invalid year with 400', async () => { + const { userId, token } = await seedUserWithSession() + + const res = await request(app) + .get(`/api/v1/portfolio/${userId}/tax-report?year=notayear`) + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(400) + }) +}) diff --git a/tests/metrics-endpoint.test.ts b/tests/metrics-endpoint.test.ts index 22f0aa0..94812e5 100644 --- a/tests/metrics-endpoint.test.ts +++ b/tests/metrics-endpoint.test.ts @@ -34,7 +34,10 @@ describe('GET /metrics', () => { const token = process.env.INTERNAL_SERVICE_TOKEN beforeAll(() => { - if (!token) console.log('Skipping authorized tests: INTERNAL_SERVICE_TOKEN not set') + if (!token) + console.warn( + 'Skipping authorized tests: INTERNAL_SERVICE_TOKEN not set' + ) }) it('returns 200 with valid X-Internal-Token', async () => { @@ -67,7 +70,8 @@ describe('GET /metrics', () => { const token = process.env.ADMIN_API_TOKEN beforeAll(() => { - if (!token) console.log('Skipping authorized tests: ADMIN_API_TOKEN not set') + if (!token) + console.warn('Skipping authorized tests: ADMIN_API_TOKEN not set') }) it('returns 200 with valid ADMIN_API_TOKEN as Bearer', async () => { diff --git a/tests/regression.test.ts b/tests/regression.test.ts index 0d85d7e..ac4b908 100644 --- a/tests/regression.test.ts +++ b/tests/regression.test.ts @@ -300,7 +300,7 @@ describe('Regression Tests - Critical Prisma-Backed Flows', () => { role: 'admin', scopes: ['dlq:read', 'dlq:write'], hash: 'test-hash', - tokenPrefix: 'test-prefix', + tokenPrefix: 'sha256:test-prefix', }, }) diff --git a/tests/setup-env.ts b/tests/setup-env.ts new file mode 100644 index 0000000..a4a0c69 --- /dev/null +++ b/tests/setup-env.ts @@ -0,0 +1,18 @@ +/** + * Jest environment bootstrap — runs before any test module (and therefore + * before src/config/env.ts validates configuration at import time). + * + * Loads .env.test with `override: true` so the test environment is hermetic: + * a developer's ambient shell variables (a real ANTHROPIC_API_KEY, a personal + * DATABASE_URL) can never leak in and fail validation or point the suite at + * the wrong database. CI and local runs therefore see identical configuration, + * and .env.test is the single source of truth for both. + */ + +import path from 'path' +import dotenv from 'dotenv' + +dotenv.config({ + path: path.resolve(__dirname, '..', '.env.test'), + override: true, +}) diff --git a/tests/unit/agent/goalTrackingStrategy.test.ts b/tests/unit/agent/goalTrackingStrategy.test.ts index 864b5ee..e3e07f0 100644 --- a/tests/unit/agent/goalTrackingStrategy.test.ts +++ b/tests/unit/agent/goalTrackingStrategy.test.ts @@ -3,12 +3,12 @@ import { calculateRequiredApy, calculateYearsRemaining, NO_ELIGIBLE_PROTOCOLS_REASON, -} from '../../../src/agent/strategies'; -import { StrategyParams, YieldProtocol } from '../../../src/agent/types'; +} from '../../../src/agent/strategies' +import { StrategyParams, YieldProtocol } from '../../../src/agent/types' jest.mock('../../../src/utils/logger', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, -})); +})) function makeProtocol(overrides: Partial = {}): YieldProtocol { return { @@ -18,17 +18,17 @@ function makeProtocol(overrides: Partial = {}): YieldProtocol { lastUpdated: new Date(), isAvailable: true, ...overrides, - }; + } } function daysFromNow(days: number): Date { - return new Date(Date.now() + days * 24 * 60 * 60 * 1000); + return new Date(Date.now() + days * 24 * 60 * 60 * 1000) } const defaultThresholds = { minimumImprovement: 0.5, maxGasPercent: 0.1, -}; +} function baseParams(overrides: Partial = {}): StrategyParams { return { @@ -39,90 +39,110 @@ function baseParams(overrides: Partial = {}): StrategyParams { thresholds: defaultThresholds, userStrategyPreferences: [], ...overrides, - }; + } } describe('calculateRequiredApy', () => { it('computes the simple annualized rate needed to close the gap', () => { // (1500 - 1000) / 1000 / 1 year = 50% - expect(calculateRequiredApy(1000, 1500, 1)).toBeCloseTo(50, 5); - }); + expect(calculateRequiredApy(1000, 1500, 1)).toBeCloseTo(50, 5) + }) it('returns 0 when the target is already met or exceeded', () => { - expect(calculateRequiredApy(1000, 1000, 1)).toBe(0); - expect(calculateRequiredApy(1500, 1000, 1)).toBe(0); - }); + expect(calculateRequiredApy(1000, 1000, 1)).toBe(0) + expect(calculateRequiredApy(1500, 1000, 1)).toBe(0) + }) it('returns Infinity when there is no time left and the target is unmet', () => { - expect(calculateRequiredApy(1000, 1500, 0)).toBe(Infinity); - expect(calculateRequiredApy(1000, 1500, -0.1)).toBe(Infinity); - }); -}); + expect(calculateRequiredApy(1000, 1500, 0)).toBe(Infinity) + expect(calculateRequiredApy(1000, 1500, -0.1)).toBe(Infinity) + }) +}) describe('calculateYearsRemaining', () => { it('is positive for a future date and negative for a past date', () => { - const from = new Date('2026-01-01T00:00:00Z'); - expect(calculateYearsRemaining(new Date('2027-01-01T00:00:00Z'), from)).toBeGreaterThan(0.9); - expect(calculateYearsRemaining(new Date('2025-01-01T00:00:00Z'), from)).toBeLessThan(0); - }); -}); + const from = new Date('2026-01-01T00:00:00Z') + expect( + calculateYearsRemaining(new Date('2027-01-01T00:00:00Z'), from) + ).toBeGreaterThan(0.9) + expect( + calculateYearsRemaining(new Date('2025-01-01T00:00:00Z'), from) + ).toBeLessThan(0) + }) +}) describe('GoalTrackingStrategy', () => { - const strategy = new GoalTrackingStrategy(); + const strategy = new GoalTrackingStrategy() it('returns strategy name as GOAL_TRACKING', () => { - expect(strategy.name).toBe('GOAL_TRACKING'); - }); + expect(strategy.name).toBe('GOAL_TRACKING') + }) it('does nothing when no goal is configured', async () => { - const decision = await strategy.analyze(baseParams({ goal: undefined })); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toContain('No active savings goal'); - }); + const decision = await strategy.analyze(baseParams({ goal: undefined })) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toContain('No active savings goal') + }) it('reports already achieved when targetAmount <= startingAmount', async () => { const params = baseParams({ - goal: { targetAmount: 1000, startingAmount: 1500, targetDate: daysFromNow(180) }, - }); - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toContain('already achieved'); - }); + goal: { + targetAmount: 1000, + startingAmount: 1500, + targetDate: daysFromNow(180), + }, + }) + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toContain('already achieved') + }) it('reports a missed goal when the target date has passed without being met', async () => { const params = baseParams({ - goal: { targetAmount: 2000, startingAmount: 1000, targetDate: daysFromNow(-10) }, - }); - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toContain('target date has passed'); - }); + goal: { + targetAmount: 2000, + startingAmount: 1000, + targetDate: daysFromNow(-10), + }, + }) + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toContain('target date has passed') + }) it('on-track: does not rebalance when current APY already meets the required rate', async () => { // Required: (11000-10000)/10000/1 = 10%. Current APY of 12% already covers it. const params = baseParams({ currentApy: 12, availableProtocols: [makeProtocol({ name: 'Blend', apy: 12 })], - goal: { targetAmount: 11000, startingAmount: 10000, targetDate: daysFromNow(365) }, - }); - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toContain('On track'); - expect(decision.details?.requiredApy).toBeCloseTo(10, 1); - }); + goal: { + targetAmount: 11000, + startingAmount: 10000, + targetDate: daysFromNow(365), + }, + }) + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toContain('On track') + expect(decision.details?.requiredApy).toBeCloseTo(10, 1) + }) it('ahead-of-schedule: does not rebalance when current APY comfortably exceeds the required rate', async () => { // Required: (10500-10000)/10000/2 = 2.5%. Current APY of 15% is well ahead. const params = baseParams({ currentApy: 15, availableProtocols: [makeProtocol({ name: 'Blend', apy: 15 })], - goal: { targetAmount: 10500, startingAmount: 10000, targetDate: daysFromNow(730) }, - }); - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toContain('On track'); - expect(decision.details?.requiredApy).toBeCloseTo(2.5, 1); - }); + goal: { + targetAmount: 10500, + startingAmount: 10000, + targetDate: daysFromNow(730), + }, + }) + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toContain('On track') + expect(decision.details?.requiredApy).toBeCloseTo(2.5, 1) + }) it('behind schedule: delegates to MaxYieldStrategy when required rate is reachable', async () => { // Required: (13000-10000)/10000/1 = 30%. Current APY 3%, but Luma offers 35%. @@ -133,14 +153,18 @@ describe('GoalTrackingStrategy', () => { makeProtocol({ name: 'Luma', apy: 35 }), makeProtocol({ name: 'Blend', apy: 3 }), ], - goal: { targetAmount: 13000, startingAmount: 10000, targetDate: daysFromNow(365) }, - }); - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(true); - expect(decision.targetProtocol).toBe('Luma'); - expect(decision.reasoning).toContain('Behind schedule'); - expect(decision.details?.requiredApy).toBeCloseTo(30, 1); - }); + goal: { + targetAmount: 13000, + startingAmount: 10000, + targetDate: daysFromNow(365), + }, + }) + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(true) + expect(decision.targetProtocol).toBe('Luma') + expect(decision.reasoning).toContain('Behind schedule') + expect(decision.details?.requiredApy).toBeCloseTo(30, 1) + }) it('unreachable-within-risk-ceiling: surfaces target-not-reachable instead of exceeding the ceiling', async () => { // Required: (20000-10000)/10000/1 = 100%. No protocol comes close. @@ -151,13 +175,19 @@ describe('GoalTrackingStrategy', () => { makeProtocol({ name: 'Blend', apy: 3 }), makeProtocol({ name: 'Luma', apy: 8 }), ], - goal: { targetAmount: 20000, startingAmount: 10000, targetDate: daysFromNow(365) }, - }); - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toContain('not reachable within your risk tolerance'); - expect(decision.details?.unreachable).toBe(true); - }); + goal: { + targetAmount: 20000, + startingAmount: 10000, + targetDate: daysFromNow(365), + }, + }) + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toContain( + 'not reachable within your risk tolerance' + ) + expect(decision.details?.unreachable).toBe(true) + }) it('unreachable-within-risk-ceiling: never overrides an explicit riskCeiling that excludes every protocol', async () => { const params = baseParams({ @@ -169,10 +199,14 @@ describe('GoalTrackingStrategy', () => { ], riskCeiling: 80, protocolRiskScores: { Blend: 40, Luma: 20 }, - goal: { targetAmount: 13000, startingAmount: 10000, targetDate: daysFromNow(365) }, - }); - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toBe(NO_ELIGIBLE_PROTOCOLS_REASON); - }); -}); + goal: { + targetAmount: 13000, + startingAmount: 10000, + targetDate: daysFromNow(365), + }, + }) + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toBe(NO_ELIGIBLE_PROTOCOLS_REASON) + }) +}) diff --git a/tests/unit/agent/logAgentAction.test.ts b/tests/unit/agent/logAgentAction.test.ts index 828f66b..f9c32fc 100644 --- a/tests/unit/agent/logAgentAction.test.ts +++ b/tests/unit/agent/logAgentAction.test.ts @@ -12,24 +12,35 @@ // resolution chain and prevents env-var validation from firing. jest.mock('../../../src/stellar/contract', () => ({ triggerRebalance: jest.fn(), -})); +})) jest.mock('../../../src/config', () => ({ config: { - stellar: { network: 'TESTNET', rpcUrl: '', agentSecretKey: '', vaultContractId: '', usdcTokenAddress: '' }, + stellar: { + network: 'TESTNET', + rpcUrl: '', + agentSecretKey: '', + vaultContractId: '', + usdcTokenAddress: '', + }, jwt: { seed: 'test-seed' }, walletEncryption: { key: 'test-key' }, - twilio: { authToken: 'test-token', accountSid: '', phoneNumber: '', whatsappNumber: '' }, + twilio: { + authToken: 'test-token', + accountSid: '', + phoneNumber: '', + whatsappNumber: '', + }, anthropic: { apiKey: 'test-key' }, database: { url: 'postgresql://test' }, }, -})); +})) -import { logAgentAction } from '../../../src/agent/router'; +import { logAgentAction } from '../../../src/agent/router' // ---- mock the db module ----------------------------------------------- -const mockAgentLogCreate = jest.fn().mockResolvedValue({ id: 'log-1' }); -const mockAgentLogFindMany = jest.fn(); -const mockUserFindMany = jest.fn(); +const mockAgentLogCreate = jest.fn().mockResolvedValue({ id: 'log-1' }) +const mockAgentLogFindMany = jest.fn() +const mockUserFindMany = jest.fn() jest.mock('../../../src/db', () => ({ __esModule: true, @@ -42,7 +53,7 @@ jest.mock('../../../src/db', () => ({ findMany: (...args: unknown[]) => mockUserFindMany(...args), }, }, -})); +})) // ---- mock logger so tests stay silent --------------------------------- jest.mock('../../../src/utils/logger', () => ({ @@ -51,63 +62,63 @@ jest.mock('../../../src/utils/logger', () => ({ warn: jest.fn(), error: jest.fn(), }, -})); +})) // ----------------------------------------------------------------------- describe('logAgentAction', () => { beforeEach(() => { - jest.clearAllMocks(); - mockAgentLogCreate.mockResolvedValue({ id: 'log-1' }); - }); + jest.clearAllMocks() + mockAgentLogCreate.mockResolvedValue({ id: 'log-1' }) + }) describe('system-level actions (no userId)', () => { it('writes a log row with userId=null when no userId is provided', async () => { - await logAgentAction('ANALYZE', 'SUCCESS', { positionsChecked: 5 }); + await logAgentAction('ANALYZE', 'SUCCESS', { positionsChecked: 5 }) - expect(mockAgentLogCreate).toHaveBeenCalledTimes(1); - const callArg = mockAgentLogCreate.mock.calls[0][0]; - expect(callArg.data.userId).toBeNull(); - expect(callArg.data.positionId).toBeNull(); - }); + expect(mockAgentLogCreate).toHaveBeenCalledTimes(1) + const callArg = mockAgentLogCreate.mock.calls[0][0] + expect(callArg.data.userId).toBeNull() + expect(callArg.data.positionId).toBeNull() + }) it('does NOT call db.user.findMany (no first-user lookup)', async () => { - await logAgentAction('ANALYZE', 'SUCCESS'); + await logAgentAction('ANALYZE', 'SUCCESS') - expect(mockUserFindMany).not.toHaveBeenCalled(); - }); + expect(mockUserFindMany).not.toHaveBeenCalled() + }) it('stores action and status correctly', async () => { - await logAgentAction('ANALYZE', 'FAILED', { error: 'timeout' }); + await logAgentAction('ANALYZE', 'FAILED', { error: 'timeout' }) - const callArg = mockAgentLogCreate.mock.calls[0][0]; - expect(callArg.data.action).toBe('ANALYZE'); - expect(callArg.data.status).toBe('FAILED'); - expect(callArg.data.errorMessage).toBe('timeout'); - }); - }); + const callArg = mockAgentLogCreate.mock.calls[0][0] + expect(callArg.data.action).toBe('ANALYZE') + expect(callArg.data.status).toBe('FAILED') + expect(callArg.data.errorMessage).toBe('timeout') + }) + }) describe('user-level actions (explicit userId)', () => { it('writes a log row with the supplied userId', async () => { - await logAgentAction('REBALANCE', 'SUCCESS', {}, 'user-abc'); + await logAgentAction('REBALANCE', 'SUCCESS', {}, 'user-abc') - const callArg = mockAgentLogCreate.mock.calls[0][0]; - expect(callArg.data.userId).toBe('user-abc'); - }); + const callArg = mockAgentLogCreate.mock.calls[0][0] + expect(callArg.data.userId).toBe('user-abc') + }) it('writes a log row with the supplied positionId', async () => { - await logAgentAction('REBALANCE', 'SUCCESS', {}, 'user-abc', 'pos-xyz'); + await logAgentAction('REBALANCE', 'SUCCESS', {}, 'user-abc', 'pos-xyz') - const callArg = mockAgentLogCreate.mock.calls[0][0]; - expect(callArg.data.positionId).toBe('pos-xyz'); - }); + const callArg = mockAgentLogCreate.mock.calls[0][0] + expect(callArg.data.positionId).toBe('pos-xyz') + }) it('does NOT call db.user.findMany when userId is explicitly provided', async () => { - await logAgentAction('REBALANCE', 'SUCCESS', {}, 'user-1'); + await logAgentAction('REBALANCE', 'SUCCESS', {}, 'user-1') - expect(mockUserFindMany).not.toHaveBeenCalled(); - }); - }); + expect(mockUserFindMany).not.toHaveBeenCalled() + }) + }) describe('multiple users', () => { it('creates separate log rows for each user without cross-contamination', async () => { @@ -115,47 +126,47 @@ describe('logAgentAction', () => { { id: 'user-1', positionId: 'pos-1' }, { id: 'user-2', positionId: 'pos-2' }, { id: 'user-3', positionId: 'pos-3' }, - ]; + ] for (const u of users) { - await logAgentAction('REBALANCE', 'SUCCESS', {}, u.id, u.positionId); + await logAgentAction('REBALANCE', 'SUCCESS', {}, u.id, u.positionId) } - expect(mockAgentLogCreate).toHaveBeenCalledTimes(3); + expect(mockAgentLogCreate).toHaveBeenCalledTimes(3) const calls = mockAgentLogCreate.mock.calls.map((c) => ({ userId: c[0].data.userId, positionId: c[0].data.positionId, - })); + })) expect(calls).toEqual([ { userId: 'user-1', positionId: 'pos-1' }, { userId: 'user-2', positionId: 'pos-2' }, { userId: 'user-3', positionId: 'pos-3' }, - ]); - }); + ]) + }) it('does not write any log with a random/first-user ID', async () => { - const explicitUsers = ['user-alice', 'user-bob']; + const explicitUsers = ['user-alice', 'user-bob'] for (const uid of explicitUsers) { - await logAgentAction('REBALANCE', 'SUCCESS', {}, uid); + await logAgentAction('REBALANCE', 'SUCCESS', {}, uid) } const writtenUserIds = mockAgentLogCreate.mock.calls.map( - (c) => c[0].data.userId, - ); - expect(writtenUserIds).not.toContain('first-user-id'); - expect(writtenUserIds).toEqual(explicitUsers); - }); - }); + (c) => c[0].data.userId + ) + expect(writtenUserIds).not.toContain('first-user-id') + expect(writtenUserIds).toEqual(explicitUsers) + }) + }) describe('error handling', () => { it('does not throw if db.agentLog.create rejects', async () => { - mockAgentLogCreate.mockRejectedValueOnce(new Error('DB connection lost')); + mockAgentLogCreate.mockRejectedValueOnce(new Error('DB connection lost')) await expect( - logAgentAction('ANALYZE', 'FAILED', {}, 'user-1'), - ).resolves.toBeUndefined(); - }); - }); -}); + logAgentAction('ANALYZE', 'FAILED', {}, 'user-1') + ).resolves.toBeUndefined() + }) + }) +}) diff --git a/tests/unit/agent/riskScoring.test.ts b/tests/unit/agent/riskScoring.test.ts index fe6280c..5a806a6 100644 --- a/tests/unit/agent/riskScoring.test.ts +++ b/tests/unit/agent/riskScoring.test.ts @@ -11,28 +11,33 @@ import { AGE_SATURATION_DAYS, INSUFFICIENT_HISTORY_SCORE, WEIGHTS, -} from '../../../src/agent/riskScoring'; +} from '../../../src/agent/riskScoring' -const DAY_MS = 24 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000 // Fixed reference "now" so every fixture is deterministic. -const NOW = new Date('2026-07-16T00:00:00.000Z'); +const NOW = new Date('2026-07-16T00:00:00.000Z') /** Build a sample `daysAgo` before NOW. */ -function sample(daysAgo: number, supplyApy: number, tvl: number | null = 1_000_000): RateSample { +function sample( + daysAgo: number, + supplyApy: number, + tvl: number | null = 1_000_000 +): RateSample { return { supplyApy, tvl, fetchedAt: new Date(NOW.getTime() - daysAgo * DAY_MS), - }; + } } describe('riskScoring — weights invariant', () => { it('component weights sum to 1', () => { - const sum = WEIGHTS.audit + WEIGHTS.volatility + WEIGHTS.tvlTrend + WEIGHTS.age; - expect(sum).toBeCloseTo(1, 10); - }); -}); + const sum = + WEIGHTS.audit + WEIGHTS.volatility + WEIGHTS.tvlTrend + WEIGHTS.age + expect(sum).toBeCloseTo(1, 10) + }) +}) describe('filterToWindow', () => { it('keeps only samples within the trailing window and sorts ascending by time', () => { @@ -40,63 +45,76 @@ describe('filterToWindow', () => { sample(1, 5), sample(TRAILING_WINDOW_DAYS + 5, 5), // outside window — dropped sample(10, 5), - ]; - const windowed = filterToWindow(samples, NOW); - expect(windowed).toHaveLength(2); + ] + const windowed = filterToWindow(samples, NOW) + expect(windowed).toHaveLength(2) // Ascending order: the 10-days-ago sample comes before the 1-day-ago one. - expect(windowed[0].fetchedAt.getTime()).toBeLessThan(windowed[1].fetchedAt.getTime()); - }); -}); + expect(windowed[0].fetchedAt.getTime()).toBeLessThan( + windowed[1].fetchedAt.getTime() + ) + }) +}) describe('computeApyVolatilityFactor', () => { it('returns 1 for a perfectly stable APY', () => { - const samples = [sample(3, 5), sample(2, 5), sample(1, 5)]; - expect(computeApyVolatilityFactor(samples)).toBe(1); - }); + const samples = [sample(3, 5), sample(2, 5), sample(1, 5)] + expect(computeApyVolatilityFactor(samples)).toBe(1) + }) it('returns a lower factor as APY volatility increases', () => { - const stable = [sample(3, 5), sample(2, 5.1), sample(1, 4.9)]; - const volatile = [sample(3, 1), sample(2, 9), sample(1, 5)]; + const stable = [sample(3, 5), sample(2, 5.1), sample(1, 4.9)] + const volatile = [sample(3, 1), sample(2, 9), sample(1, 5)] expect(computeApyVolatilityFactor(volatile)).toBeLessThan( - computeApyVolatilityFactor(stable), - ); - }); + computeApyVolatilityFactor(stable) + ) + }) it('floors at 0 once stdev reaches APY_STDEV_FLOOR', () => { // Two points at +/- APY_STDEV_FLOOR around the mean → stdev == APY_STDEV_FLOOR. - const samples = [sample(2, 10 - APY_STDEV_FLOOR), sample(1, 10 + APY_STDEV_FLOOR)]; - expect(computeApyVolatilityFactor(samples)).toBe(0); - }); + const samples = [ + sample(2, 10 - APY_STDEV_FLOOR), + sample(1, 10 + APY_STDEV_FLOOR), + ] + expect(computeApyVolatilityFactor(samples)).toBe(0) + }) it('returns 0 when fewer than two APY points exist', () => { - expect(computeApyVolatilityFactor([sample(1, 5)])).toBe(0); - }); -}); + expect(computeApyVolatilityFactor([sample(1, 5)])).toBe(0) + }) +}) describe('computeTvlTrendFactor', () => { it('is > 0.5 when TVL is growing across the window', () => { - const samples = [sample(3, 5, 1_000_000), sample(2, 5, 1_100_000), sample(1, 5, 1_200_000)]; - expect(computeTvlTrendFactor(samples)).toBeGreaterThan(0.5); - }); + const samples = [ + sample(3, 5, 1_000_000), + sample(2, 5, 1_100_000), + sample(1, 5, 1_200_000), + ] + expect(computeTvlTrendFactor(samples)).toBeGreaterThan(0.5) + }) it('is < 0.5 when TVL is declining across the window', () => { - const samples = [sample(3, 5, 1_200_000), sample(2, 5, 1_000_000), sample(1, 5, 800_000)]; - expect(computeTvlTrendFactor(samples)).toBeLessThan(0.5); - }); + const samples = [ + sample(3, 5, 1_200_000), + sample(2, 5, 1_000_000), + sample(1, 5, 800_000), + ] + expect(computeTvlTrendFactor(samples)).toBeLessThan(0.5) + }) it('is neutral (0.5) when there is no usable TVL data', () => { - const samples = [sample(3, 5, null), sample(2, 5, null), sample(1, 5, null)]; - expect(computeTvlTrendFactor(samples)).toBe(0.5); - }); -}); + const samples = [sample(3, 5, null), sample(2, 5, null), sample(1, 5, null)] + expect(computeTvlTrendFactor(samples)).toBe(0.5) + }) +}) describe('computeAgeFactor', () => { it('is 0 at age 0 and saturates to 1 at AGE_SATURATION_DAYS', () => { - expect(computeAgeFactor(0)).toBe(0); - expect(computeAgeFactor(AGE_SATURATION_DAYS)).toBe(1); - expect(computeAgeFactor(AGE_SATURATION_DAYS * 2)).toBe(1); // clamped - }); -}); + expect(computeAgeFactor(0)).toBe(0) + expect(computeAgeFactor(AGE_SATURATION_DAYS)).toBe(1) + expect(computeAgeFactor(AGE_SATURATION_DAYS * 2)).toBe(1) // clamped + }) +}) describe('computeRiskScore', () => { it('produces a normalized 0-100 score with the full factor breakdown', () => { @@ -107,63 +125,64 @@ describe('computeRiskScore', () => { sample(10, 4.9, 1_100_000), sample(5, 5.0, 1_150_000), sample(1, 5.05, 1_200_000), - ]; - const result = computeRiskScore('Blend', samples, NOW); - - expect(result.protocolName).toBe('Blend'); - expect(result.score).toBeGreaterThanOrEqual(0); - expect(result.score).toBeLessThanOrEqual(100); - expect(result.insufficientHistory).toBe(false); - expect(result.sampleCount).toBe(5); - expect(result.auditStatus).toBe('THIRD_PARTY_AUDITED'); - expect(result.protocolAgeDays).toBeGreaterThan(0); + ] + const result = computeRiskScore('Blend', samples, NOW) + + expect(result.protocolName).toBe('Blend') + expect(result.score).toBeGreaterThanOrEqual(0) + expect(result.score).toBeLessThanOrEqual(100) + expect(result.insufficientHistory).toBe(false) + expect(result.sampleCount).toBe(5) + expect(result.auditStatus).toBe('THIRD_PARTY_AUDITED') + expect(result.protocolAgeDays).toBeGreaterThan(0) // Stable APY + growing TVL + audited + mature → a high (low-risk) score. - expect(result.score).toBeGreaterThan(70); - }); + expect(result.score).toBeGreaterThan(70) + }) it('scores an audited, stable protocol higher than an unaudited, volatile one', () => { const stableAudited = [ sample(20, 5.0, 1_000_000), sample(10, 5.0, 1_100_000), sample(1, 5.0, 1_200_000), - ]; + ] const volatileUnaudited = [ sample(20, 1.0, 1_200_000), sample(10, 9.0, 900_000), sample(1, 4.0, 700_000), - ]; - const good = computeRiskScore('Blend', stableAudited, NOW); + ] + const good = computeRiskScore('Blend', stableAudited, NOW) // 'UnknownProtocol' is not curated → UNAUDITED, age 0. - const bad = computeRiskScore('UnknownProtocol', volatileUnaudited, NOW); - expect(good.score).toBeGreaterThan(bad.score); - }); + const bad = computeRiskScore('UnknownProtocol', volatileUnaudited, NOW) + expect(good.score).toBeGreaterThan(bad.score) + }) describe('insufficient-history policy', () => { it('flags a brand-new protocol with too few samples and scores it conservatively low', () => { - const samples = [sample(2, 5.0), sample(1, 5.2)]; // 2 < MIN_SAMPLES_FOR_HISTORY - const result = computeRiskScore('Blend', samples, NOW); - expect(result.insufficientHistory).toBe(true); - expect(result.score).toBe(INSUFFICIENT_HISTORY_SCORE); - }); + const samples = [sample(2, 5.0), sample(1, 5.2)] // 2 < MIN_SAMPLES_FOR_HISTORY + const result = computeRiskScore('Blend', samples, NOW) + expect(result.insufficientHistory).toBe(true) + expect(result.score).toBe(INSUFFICIENT_HISTORY_SCORE) + }) it('does not give a no-history protocol a misleadingly neutral score', () => { - const result = computeRiskScore('Blend', [], NOW); - expect(result.sampleCount).toBe(0); - expect(result.insufficientHistory).toBe(true); - expect(result.score).toBe(INSUFFICIENT_HISTORY_SCORE); + const result = computeRiskScore('Blend', [], NOW) + expect(result.sampleCount).toBe(0) + expect(result.insufficientHistory).toBe(true) + expect(result.score).toBe(INSUFFICIENT_HISTORY_SCORE) // Even though Blend is audited + mature, the score is NOT pulled up by // audit/age alone when history is absent. - expect(result.score).toBeLessThan(50); - }); + expect(result.score).toBeLessThan(50) + }) it('requires at least MIN_SAMPLES_FOR_HISTORY in-window samples', () => { - const justEnough = Array.from({ length: MIN_SAMPLES_FOR_HISTORY }, (_, i) => - sample(i + 1, 5.0), - ); - const result = computeRiskScore('Blend', justEnough, NOW); - expect(result.insufficientHistory).toBe(false); - }); - }); + const justEnough = Array.from( + { length: MIN_SAMPLES_FOR_HISTORY }, + (_, i) => sample(i + 1, 5.0) + ) + const result = computeRiskScore('Blend', justEnough, NOW) + expect(result.insufficientHistory).toBe(false) + }) + }) describe('data-gap policy', () => { it('treats out-of-window samples as absent, not as favorable data', () => { @@ -176,12 +195,12 @@ describe('computeRiskScore', () => { sample(150, 5.0), // ancient, outside window sample(2, 5.0), // in window sample(1, 5.0), // in window - ]; - const result = computeRiskScore('Blend', samples, NOW); - expect(result.sampleCount).toBe(2); - expect(result.insufficientHistory).toBe(true); - expect(result.score).toBe(INSUFFICIENT_HISTORY_SCORE); - }); + ] + const result = computeRiskScore('Blend', samples, NOW) + expect(result.sampleCount).toBe(2) + expect(result.insufficientHistory).toBe(true) + expect(result.score).toBe(INSUFFICIENT_HISTORY_SCORE) + }) it('ignores null-TVL samples when computing the TVL trend rather than treating them as zero', () => { const withGaps = [ @@ -190,18 +209,22 @@ describe('computeRiskScore', () => { sample(10, 5.0, null), // gap in TVL data sample(5, 5.0, 1_100_000), sample(1, 5.0, 1_200_000), - ]; - const result = computeRiskScore('Blend', withGaps, NOW); + ] + const result = computeRiskScore('Blend', withGaps, NOW) // Trend computed from the 3 real TVL points (all growing) → > 0.5, and // never dragged to 0 by treating the nulls as zero TVL. - expect(result.tvlTrendFactor).toBeGreaterThan(0.5); - }); - }); + expect(result.tvlTrendFactor).toBeGreaterThan(0.5) + }) + }) it('is deterministic for the same inputs', () => { - const samples = [sample(10, 5.0, 1_000_000), sample(5, 5.1, 1_050_000), sample(1, 4.9, 1_100_000)]; - const a = computeRiskScore('Blend', samples, NOW); - const b = computeRiskScore('Blend', samples, NOW); - expect(a).toEqual(b); - }); -}); + const samples = [ + sample(10, 5.0, 1_000_000), + sample(5, 5.1, 1_050_000), + sample(1, 4.9, 1_100_000), + ] + const a = computeRiskScore('Blend', samples, NOW) + const b = computeRiskScore('Blend', samples, NOW) + expect(a).toEqual(b) + }) +}) diff --git a/tests/unit/agent/strategies.test.ts b/tests/unit/agent/strategies.test.ts index d6dc75f..099f621 100644 --- a/tests/unit/agent/strategies.test.ts +++ b/tests/unit/agent/strategies.test.ts @@ -2,12 +2,12 @@ import { MaxYieldStrategy, TargetAllocationStrategy, NO_ELIGIBLE_PROTOCOLS_REASON, -} from '../../../src/agent/strategies'; -import { StrategyParams, YieldProtocol } from '../../../src/agent/types'; +} from '../../../src/agent/strategies' +import { StrategyParams, YieldProtocol } from '../../../src/agent/types' jest.mock('../../../src/utils/logger', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, -})); +})) function makeProtocol(overrides: Partial = {}): YieldProtocol { return { @@ -17,20 +17,20 @@ function makeProtocol(overrides: Partial = {}): YieldProtocol { lastUpdated: new Date(), isAvailable: true, ...overrides, - }; + } } const defaultThresholds = { minimumImprovement: 0.5, maxGasPercent: 0.1, -}; +} describe('MaxYieldStrategy', () => { - const strategy = new MaxYieldStrategy(); + const strategy = new MaxYieldStrategy() it('returns strategy name as MAX_YIELD', () => { - expect(strategy.name).toBe('MAX_YIELD'); - }); + expect(strategy.name).toBe('MAX_YIELD') + }) it('recommends rebalance when a better protocol exists and net gain exceeds threshold', async () => { // Use 10000 USDC so gas costs (~$0.50) are negligible (0.005%) @@ -44,15 +44,15 @@ describe('MaxYieldStrategy', () => { ], thresholds: defaultThresholds, userStrategyPreferences: [], - }; + } - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(true); - expect(decision.targetProtocol).toBe('Luma'); - expect(decision.reasoning).toContain('Luma'); - expect(decision.deviationTrigger).toContain('APY delta'); - expect(decision.details).toBeDefined(); - }); + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(true) + expect(decision.targetProtocol).toBe('Luma') + expect(decision.reasoning).toContain('Luma') + expect(decision.deviationTrigger).toContain('APY delta') + expect(decision.details).toBeDefined() + }) it('does NOT rebalance when current protocol is already the best', async () => { const params: StrategyParams = { @@ -65,13 +65,13 @@ describe('MaxYieldStrategy', () => { ], thresholds: defaultThresholds, userStrategyPreferences: [], - }; + } - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.targetProtocol).toBe('Blend'); - expect(decision.reasoning).toContain('Already on the highest-yielding'); - }); + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.targetProtocol).toBe('Blend') + expect(decision.reasoning).toContain('Already on the highest-yielding') + }) it('does NOT rebalance when net improvement is below threshold', async () => { const params: StrategyParams = { @@ -84,12 +84,12 @@ describe('MaxYieldStrategy', () => { ], thresholds: defaultThresholds, userStrategyPreferences: [], - }; + } - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toContain('below threshold'); - }); + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toContain('below threshold') + }) it('does NOT rebalance when no protocols are available', async () => { const params: StrategyParams = { @@ -99,37 +99,35 @@ describe('MaxYieldStrategy', () => { availableProtocols: [], thresholds: defaultThresholds, userStrategyPreferences: [], - }; + } - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toContain('No protocols available'); - }); + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toContain('No protocols available') + }) it('handles very small amounts without crashing', async () => { const params: StrategyParams = { currentProtocol: 'Blend', totalAmount: '1', currentApy: 3.0, - availableProtocols: [ - makeProtocol({ name: 'Luma', apy: 8.0 }), - ], + availableProtocols: [makeProtocol({ name: 'Luma', apy: 8.0 })], thresholds: defaultThresholds, userStrategyPreferences: [], - }; + } - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toBeDefined(); - }); -}); + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toBeDefined() + }) +}) describe('TargetAllocationStrategy', () => { - const strategy = new TargetAllocationStrategy(); + const strategy = new TargetAllocationStrategy() it('returns strategy name as TARGET_ALLOCATION', () => { - expect(strategy.name).toBe('TARGET_ALLOCATION'); - }); + expect(strategy.name).toBe('TARGET_ALLOCATION') + }) it('recommends rebalance when protocol has significantly lower target than the preferred protocol', async () => { const params: StrategyParams = { @@ -148,15 +146,15 @@ describe('TargetAllocationStrategy', () => { targetAllocations: { Blend: 30, 'Stellar DEX': 40, Luma: 30 }, }, ], - }; + } - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(true); - expect(decision.targetProtocol).toBe('Stellar DEX'); - expect(decision.reasoning).toContain('significantly below'); - expect(decision.deviationTrigger).toContain('Target ratio'); - expect(decision.details).toBeDefined(); - }); + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(true) + expect(decision.targetProtocol).toBe('Stellar DEX') + expect(decision.reasoning).toContain('significantly below') + expect(decision.deviationTrigger).toContain('Target ratio') + expect(decision.details).toBeDefined() + }) it('does NOT rebalance when targets are within acceptable range of each other', async () => { const params: StrategyParams = { @@ -175,57 +173,51 @@ describe('TargetAllocationStrategy', () => { targetAllocations: { Blend: 33, 'Stellar DEX': 33, Luma: 34 }, }, ], - }; + } - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toContain('within acceptable range'); - }); + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toContain('within acceptable range') + }) it('does NOT rebalance when no target allocations configured', async () => { const params: StrategyParams = { currentProtocol: 'Blend', totalAmount: '10000000000000000000', currentApy: 5.0, - availableProtocols: [ - makeProtocol({ name: 'Luma', apy: 6.0 }), - ], + availableProtocols: [makeProtocol({ name: 'Luma', apy: 6.0 })], thresholds: defaultThresholds, userStrategyPreferences: [ { userId: 'user-1', strategyName: 'TARGET_ALLOCATION' }, ], - }; + } - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toContain('No target allocations configured'); - }); + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toContain('No target allocations configured') + }) it('does NOT rebalance when no preferences match', async () => { const params: StrategyParams = { currentProtocol: 'Blend', totalAmount: '10000000000000000000', currentApy: 5.0, - availableProtocols: [ - makeProtocol({ name: 'Luma', apy: 6.0 }), - ], + availableProtocols: [makeProtocol({ name: 'Luma', apy: 6.0 })], thresholds: defaultThresholds, userStrategyPreferences: [], - }; + } - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toContain('No target allocations configured'); - }); + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toContain('No target allocations configured') + }) it('does NOT rebalance when current protocol has no target', async () => { const params: StrategyParams = { currentProtocol: 'UnknownProtocol', totalAmount: '10000000000000000000', currentApy: 5.0, - availableProtocols: [ - makeProtocol({ name: 'Luma', apy: 6.0 }), - ], + availableProtocols: [makeProtocol({ name: 'Luma', apy: 6.0 })], thresholds: defaultThresholds, userStrategyPreferences: [ { @@ -234,13 +226,13 @@ describe('TargetAllocationStrategy', () => { targetAllocations: { Blend: 50, Luma: 50 }, }, ], - }; + } - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toContain('No target allocation set'); - }); -}); + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toContain('No target allocation set') + }) +}) // ── Risk ceiling (issue #291) ──────────────────────────────────────────────── // @@ -253,7 +245,7 @@ describe('TargetAllocationStrategy', () => { // than falling back to allocating somewhere the user disallowed. describe('MaxYieldStrategy — riskCeiling', () => { - const strategy = new MaxYieldStrategy(); + const strategy = new MaxYieldStrategy() const baseParams = (): StrategyParams => ({ currentProtocol: 'Blend', @@ -265,74 +257,74 @@ describe('MaxYieldStrategy — riskCeiling', () => { ], thresholds: defaultThresholds, userStrategyPreferences: [], - }); + }) it('is a no-op when riskCeiling is undefined (identical decision to before)', async () => { - const params = baseParams(); + const params = baseParams() // Even if scores are supplied, an unset ceiling must ignore them entirely. - params.protocolRiskScores = { Luma: 10, Blend: 10 }; + params.protocolRiskScores = { Luma: 10, Blend: 10 } - const withoutCeiling = await strategy.analyze(baseParams()); - const withScoresButNoCeiling = await strategy.analyze(params); + const withoutCeiling = await strategy.analyze(baseParams()) + const withScoresButNoCeiling = await strategy.analyze(params) - expect(withScoresButNoCeiling).toEqual(withoutCeiling); - expect(withScoresButNoCeiling.shouldRebalance).toBe(true); - expect(withScoresButNoCeiling.targetProtocol).toBe('Luma'); - }); + expect(withScoresButNoCeiling).toEqual(withoutCeiling) + expect(withScoresButNoCeiling.shouldRebalance).toBe(true) + expect(withScoresButNoCeiling.targetProtocol).toBe('Luma') + }) it('filters out protocols below the ceiling before optimizing for yield', async () => { - const params = baseParams(); - params.riskCeiling = 50; + const params = baseParams() + params.riskCeiling = 50 // Luma is the highest yield but too risky; a lower-yield protocol clears it. params.availableProtocols = [ makeProtocol({ name: 'Luma', apy: 8.0 }), makeProtocol({ name: 'Stellar DEX', apy: 6.0 }), makeProtocol({ name: 'Blend', apy: 3.0 }), - ]; - params.protocolRiskScores = { Luma: 20, 'Stellar DEX': 70, Blend: 80 }; + ] + params.protocolRiskScores = { Luma: 20, 'Stellar DEX': 70, Blend: 80 } - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(true); + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(true) // Must NOT pick Luma even though it has the best APY. - expect(decision.targetProtocol).toBe('Stellar DEX'); - }); + expect(decision.targetProtocol).toBe('Stellar DEX') + }) it('surfaces an explicit "no eligible protocols" state rather than bypassing the ceiling', async () => { - const params = baseParams(); - params.riskCeiling = 90; - params.protocolRiskScores = { Luma: 20, Blend: 30 }; // nothing clears 90 + const params = baseParams() + params.riskCeiling = 90 + params.protocolRiskScores = { Luma: 20, Blend: 30 } // nothing clears 90 - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.targetProtocol).toBe('Blend'); // stays put, no silent move - expect(decision.reasoning).toBe(NO_ELIGIBLE_PROTOCOLS_REASON); - }); + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.targetProtocol).toBe('Blend') // stays put, no silent move + expect(decision.reasoning).toBe(NO_ELIGIBLE_PROTOCOLS_REASON) + }) it('fail-closed: a protocol with no known score is excluded under a ceiling', async () => { - const params = baseParams(); - params.riskCeiling = 50; + const params = baseParams() + params.riskCeiling = 50 // Luma has a passing score; Blend (current) has no score at all. - params.protocolRiskScores = { Luma: 70 }; + params.protocolRiskScores = { Luma: 70 } - const decision = await strategy.analyze(params); + const decision = await strategy.analyze(params) // Luma is eligible and higher yield -> rebalance to it. - expect(decision.shouldRebalance).toBe(true); - expect(decision.targetProtocol).toBe('Luma'); - }); + expect(decision.shouldRebalance).toBe(true) + expect(decision.targetProtocol).toBe('Luma') + }) it('fail-closed: when scores are entirely absent, a ceiling excludes everything', async () => { - const params = baseParams(); - params.riskCeiling = 50; - params.protocolRiskScores = undefined; + const params = baseParams() + params.riskCeiling = 50 + params.protocolRiskScores = undefined - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.reasoning).toBe(NO_ELIGIBLE_PROTOCOLS_REASON); - }); -}); + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.reasoning).toBe(NO_ELIGIBLE_PROTOCOLS_REASON) + }) +}) describe('TargetAllocationStrategy — riskCeiling', () => { - const strategy = new TargetAllocationStrategy(); + const strategy = new TargetAllocationStrategy() const baseParams = (): StrategyParams => ({ currentProtocol: 'Blend', @@ -350,23 +342,23 @@ describe('TargetAllocationStrategy — riskCeiling', () => { targetAllocations: { Blend: 30, 'Stellar DEX': 40, Luma: 30 }, }, ], - }); + }) it('is a no-op when riskCeiling is undefined (identical decision to before)', async () => { - const params = baseParams(); - params.protocolRiskScores = { Blend: 10, 'Stellar DEX': 10, Luma: 10 }; + const params = baseParams() + params.protocolRiskScores = { Blend: 10, 'Stellar DEX': 10, Luma: 10 } - const withoutCeiling = await strategy.analyze(baseParams()); - const withScoresButNoCeiling = await strategy.analyze(params); + const withoutCeiling = await strategy.analyze(baseParams()) + const withScoresButNoCeiling = await strategy.analyze(params) - expect(withScoresButNoCeiling).toEqual(withoutCeiling); - expect(withScoresButNoCeiling.shouldRebalance).toBe(true); - expect(withScoresButNoCeiling.targetProtocol).toBe('Stellar DEX'); - }); + expect(withScoresButNoCeiling).toEqual(withoutCeiling) + expect(withScoresButNoCeiling.shouldRebalance).toBe(true) + expect(withScoresButNoCeiling.targetProtocol).toBe('Stellar DEX') + }) it('excludes target protocols below the ceiling before choosing a rebalance target', async () => { - const params = baseParams(); - params.riskCeiling = 50; + const params = baseParams() + params.riskCeiling = 50 // Weight Luma above the current protocol so a rebalance is warranted once // the higher-weighted Stellar DEX is excluded by the ceiling. params.userStrategyPreferences = [ @@ -375,23 +367,23 @@ describe('TargetAllocationStrategy — riskCeiling', () => { strategyName: 'TARGET_ALLOCATION', targetAllocations: { Blend: 20, 'Stellar DEX': 40, Luma: 40 }, }, - ]; + ] // Stellar DEX has the highest target weight but fails the ceiling; Luma clears it. - params.protocolRiskScores = { Blend: 80, 'Stellar DEX': 20, Luma: 70 }; + params.protocolRiskScores = { Blend: 80, 'Stellar DEX': 20, Luma: 70 } - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(true); - expect(decision.targetProtocol).toBe('Luma'); - }); + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(true) + expect(decision.targetProtocol).toBe('Luma') + }) it('surfaces "no eligible protocols" when the ceiling excludes every target', async () => { - const params = baseParams(); - params.riskCeiling = 90; - params.protocolRiskScores = { Blend: 95, 'Stellar DEX': 20, Luma: 30 }; - - const decision = await strategy.analyze(params); - expect(decision.shouldRebalance).toBe(false); - expect(decision.targetProtocol).toBe('Blend'); - expect(decision.reasoning).toBe(NO_ELIGIBLE_PROTOCOLS_REASON); - }); -}); + const params = baseParams() + params.riskCeiling = 90 + params.protocolRiskScores = { Blend: 95, 'Stellar DEX': 20, Luma: 30 } + + const decision = await strategy.analyze(params) + expect(decision.shouldRebalance).toBe(false) + expect(decision.targetProtocol).toBe('Blend') + expect(decision.reasoning).toBe(NO_ELIGIBLE_PROTOCOLS_REASON) + }) +}) diff --git a/tests/unit/config/secrets.test.ts b/tests/unit/config/secrets.test.ts index d6fe692..9f73a9b 100644 --- a/tests/unit/config/secrets.test.ts +++ b/tests/unit/config/secrets.test.ts @@ -1,4 +1,9 @@ -import { createSecretsProvider, bootstrapSecrets, getSecretsProvider } from '../../../src/config/secrets' +import { + createSecretsProvider, + bootstrapSecrets, + getSecretsProvider, +} from '../../../src/config/secrets' +import { logger } from '../../../src/utils/logger' jest.mock('../../../src/utils/logger', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, @@ -28,20 +33,23 @@ function deleteEnv(...keys: string[]) { describe('EnvSecretsProvider (SECRET_BACKEND=env)', () => { beforeEach(() => { deleteEnv('SECRET_BACKEND') - jest.resetModules() }) it('returns the env var value', async () => { setEnv({ JWT_SEED: 'my-super-secret-seed-at-least-32-chars!!' }) const provider = createSecretsProvider() - await expect(provider.get('JWT_SEED')).resolves.toBe('my-super-secret-seed-at-least-32-chars!!') + await expect(provider.get('JWT_SEED')).resolves.toBe( + 'my-super-secret-seed-at-least-32-chars!!' + ) deleteEnv('JWT_SEED') }) it('throws when the env var is missing', async () => { deleteEnv('JWT_SEED') const provider = createSecretsProvider() - await expect(provider.get('JWT_SEED')).rejects.toThrow('Missing env var: JWT_SEED') + await expect(provider.get('JWT_SEED')).rejects.toThrow( + 'Missing env var: JWT_SEED' + ) }) it('refresh() resolves without error', async () => { @@ -63,7 +71,9 @@ describe('AwsSsmSecretsProvider (SECRET_BACKEND=aws-ssm)', () => { }) it('fetches a secret from SSM and caches it', async () => { - mockSend.mockResolvedValueOnce({ Parameter: { Value: 'ssm-jwt-seed-value' } }) + mockSend.mockResolvedValueOnce({ + Parameter: { Value: 'ssm-jwt-seed-value' }, + }) const provider = createSecretsProvider() const val = await provider.get('JWT_SEED') @@ -79,7 +89,9 @@ describe('AwsSsmSecretsProvider (SECRET_BACKEND=aws-ssm)', () => { it('throws when SSM returns no value', async () => { mockSend.mockResolvedValueOnce({ Parameter: {} }) const provider = createSecretsProvider() - await expect(provider.get('WALLET_ENCRYPTION_KEY')).rejects.toThrow('SSM parameter not found') + await expect(provider.get('WALLET_ENCRYPTION_KEY')).rejects.toThrow( + 'SSM parameter not found' + ) }) it('refresh() re-fetches all cached keys', async () => { @@ -88,8 +100,8 @@ describe('AwsSsmSecretsProvider (SECRET_BACKEND=aws-ssm)', () => { .mockResolvedValueOnce({ Parameter: { Value: 'refreshed-value' } }) const provider = createSecretsProvider() - await provider.get('JWT_SEED') // populates cache - await provider.refresh() // re-fetches + await provider.get('JWT_SEED') // populates cache + await provider.refresh() // re-fetches // After refresh the cache should hold the refreshed value. const afterRefresh = await provider.get('JWT_SEED') @@ -98,17 +110,16 @@ describe('AwsSsmSecretsProvider (SECRET_BACKEND=aws-ssm)', () => { }) it('refresh() logs a warning and continues if one key fails', async () => { - const { logger } = jest.requireMock('../../../src/utils/logger') as { logger: { warn: jest.Mock } } mockSend - .mockResolvedValueOnce({ Parameter: { Value: 'ok-value' } }) // initial get - .mockRejectedValueOnce(new Error('SSM throttled')) // refresh fails + .mockResolvedValueOnce({ Parameter: { Value: 'ok-value' } }) // initial get + .mockRejectedValueOnce(new Error('SSM throttled')) // refresh fails const provider = createSecretsProvider() await provider.get('JWT_SEED') await provider.refresh() expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to refresh SSM key'), + expect.stringContaining('Failed to refresh SSM key') ) }) }) @@ -118,7 +129,6 @@ describe('AwsSsmSecretsProvider (SECRET_BACKEND=aws-ssm)', () => { describe('bootstrapSecrets()', () => { afterEach(() => { deleteEnv('SECRET_BACKEND') - jest.resetModules() }) it('is a no-op when SECRET_BACKEND=env', async () => { diff --git a/tests/unit/fiat/moonpay.test.ts b/tests/unit/fiat/moonpay.test.ts index 41e1d27..7fc2449 100644 --- a/tests/unit/fiat/moonpay.test.ts +++ b/tests/unit/fiat/moonpay.test.ts @@ -4,13 +4,20 @@ import { createHmac } from 'crypto' import { MoonPayProvider } from '../../../src/fiat/providers/moonpay' jest.mock('../../../src/utils/logger', () => ({ - logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, + logger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, })) const WEBHOOK_KEY = 'whsec_test_key' function sign(rawBody: string, timestamp: string, key = WEBHOOK_KEY): string { - const sig = createHmac('sha256', key).update(`${timestamp}.${rawBody}`).digest('hex') + const sig = createHmac('sha256', key) + .update(`${timestamp}.${rawBody}`) + .digest('hex') return `t=${timestamp},s=${sig}` } @@ -18,11 +25,14 @@ describe('MoonPayProvider.verifyWebhookSignature', () => { const provider = new MoonPayProvider({ webhookKey: WEBHOOK_KEY }) it('accepts a correctly signed payload', () => { - const body = JSON.stringify({ type: 'transaction_updated', data: { id: 'mp_1', status: 'completed' } }) + const body = JSON.stringify({ + type: 'transaction_updated', + data: { id: 'mp_1', status: 'completed' }, + }) const ts = '1700000000' const header = sign(body, ts) expect( - provider.verifyWebhookSignature(body, { 'moonpay-signature-v2': header }), + provider.verifyWebhookSignature(body, { 'moonpay-signature-v2': header }) ).toBe(true) }) @@ -30,9 +40,13 @@ describe('MoonPayProvider.verifyWebhookSignature', () => { const body = JSON.stringify({ data: { id: 'mp_1', status: 'completed' } }) const ts = '1700000000' const header = sign(body, ts) - const tampered = JSON.stringify({ data: { id: 'mp_1', status: 'refunded' } }) + const tampered = JSON.stringify({ + data: { id: 'mp_1', status: 'refunded' }, + }) expect( - provider.verifyWebhookSignature(tampered, { 'moonpay-signature-v2': header }), + provider.verifyWebhookSignature(tampered, { + 'moonpay-signature-v2': header, + }) ).toBe(false) }) @@ -40,22 +54,30 @@ describe('MoonPayProvider.verifyWebhookSignature', () => { const body = JSON.stringify({ data: { id: 'mp_1' } }) const header = sign(body, '1700000000', 'wrong_key') expect( - provider.verifyWebhookSignature(body, { 'moonpay-signature-v2': header }), + provider.verifyWebhookSignature(body, { 'moonpay-signature-v2': header }) ).toBe(false) }) it('rejects when the signature header is missing or malformed', () => { const body = '{}' expect(provider.verifyWebhookSignature(body, {})).toBe(false) - expect(provider.verifyWebhookSignature(body, { 'moonpay-signature-v2': 'garbage' })).toBe(false) - expect(provider.verifyWebhookSignature(body, { 'moonpay-signature-v2': 't=1' })).toBe(false) + expect( + provider.verifyWebhookSignature(body, { + 'moonpay-signature-v2': 'garbage', + }) + ).toBe(false) + expect( + provider.verifyWebhookSignature(body, { 'moonpay-signature-v2': 't=1' }) + ).toBe(false) }) it('rejects everything when no webhook key is configured', () => { const noKey = new MoonPayProvider({ webhookKey: '' }) const body = '{}' const header = sign(body, '1700000000', '') - expect(noKey.verifyWebhookSignature(body, { 'moonpay-signature-v2': header })).toBe(false) + expect( + noKey.verifyWebhookSignature(body, { 'moonpay-signature-v2': header }) + ).toBe(false) }) }) @@ -65,7 +87,12 @@ describe('MoonPayProvider.parseWebhookPayload', () => { it('normalizes a completed transaction to SETTLED and extracts the tx hash', () => { const body = JSON.stringify({ type: 'transaction_updated', - data: { id: 'mp_42', status: 'completed', cryptoTransactionId: '0xabc', quoteCurrencyAmount: 98.5 }, + data: { + id: 'mp_42', + status: 'completed', + cryptoTransactionId: '0xabc', + quoteCurrencyAmount: 98.5, + }, }) const parsed = provider.parseWebhookPayload(body) expect(parsed).toMatchObject({ @@ -77,24 +104,38 @@ describe('MoonPayProvider.parseWebhookPayload', () => { }) it('maps waitingPayment to PENDING', () => { - const body = JSON.stringify({ data: { id: 'mp_1', status: 'waitingPayment' } }) + const body = JSON.stringify({ + data: { id: 'mp_1', status: 'waitingPayment' }, + }) expect(provider.parseWebhookPayload(body).status).toBe('PENDING') }) it('maps failed to FAILED and carries the reason', () => { - const body = JSON.stringify({ data: { id: 'mp_1', status: 'failed', failureReason: 'card_declined' } }) + const body = JSON.stringify({ + data: { id: 'mp_1', status: 'failed', failureReason: 'card_declined' }, + }) const parsed = provider.parseWebhookPayload(body) expect(parsed.status).toBe('FAILED') expect(parsed.reason).toBe('card_declined') }) it('maps refunded/chargedback to REFUNDED', () => { - expect(provider.parseWebhookPayload(JSON.stringify({ data: { id: 'a', status: 'refunded' } })).status).toBe('REFUNDED') - expect(provider.parseWebhookPayload(JSON.stringify({ data: { id: 'b', status: 'chargedback' } })).status).toBe('REFUNDED') + expect( + provider.parseWebhookPayload( + JSON.stringify({ data: { id: 'a', status: 'refunded' } }) + ).status + ).toBe('REFUNDED') + expect( + provider.parseWebhookPayload( + JSON.stringify({ data: { id: 'b', status: 'chargedback' } }) + ).status + ).toBe('REFUNDED') }) it('flags KYC_REQUIRED when a kyc redirect url is present and not yet settled', () => { - const body = JSON.stringify({ data: { id: 'mp_1', status: 'pending', kycRedirectUrl: 'https://kyc' } }) + const body = JSON.stringify({ + data: { id: 'mp_1', status: 'pending', kycRedirectUrl: 'https://kyc' }, + }) const parsed = provider.parseWebhookPayload(body) expect(parsed.status).toBe('KYC_REQUIRED') expect(parsed.kycUrl).toBe('https://kyc') diff --git a/tests/unit/fiat/service.test.ts b/tests/unit/fiat/service.test.ts index 2ba3488..98aa689 100644 --- a/tests/unit/fiat/service.test.ts +++ b/tests/unit/fiat/service.test.ts @@ -18,7 +18,12 @@ import type { ParsedWebhook } from '../../../src/fiat/types' jest.mock('../../../src/db', () => ({ __esModule: true, default: {} })) jest.mock('../../../src/utils/logger', () => ({ - logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, + logger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, })) jest.mock('../../../src/services/webhookDispatcher', () => ({ dispatchWebhookEvent: jest.fn().mockResolvedValue(undefined), @@ -63,7 +68,10 @@ beforeEach(() => { describe('processProviderWebhook', () => { it('advances a PROCESSING/completed signal to PROCESSING, never SETTLED', async () => { mockDb.fiatOrder.findUnique.mockResolvedValue(baseOrder()) - mockDb.fiatOrder.update.mockImplementation(({ data }: any) => ({ ...baseOrder(), ...data })) + mockDb.fiatOrder.update.mockImplementation(({ data }: any) => ({ + ...baseOrder(), + ...data, + })) const parsed: ParsedWebhook = { providerOrderId: 'mp_1', status: 'SETTLED' } const res = await processProviderWebhook('moonpay', parsed) @@ -75,9 +83,14 @@ describe('processProviderWebhook', () => { }) it('is idempotent — a terminal order is not mutated by a later delivery', async () => { - mockDb.fiatOrder.findUnique.mockResolvedValue(baseOrder({ status: 'SETTLED' })) + mockDb.fiatOrder.findUnique.mockResolvedValue( + baseOrder({ status: 'SETTLED' }) + ) - const res = await processProviderWebhook('moonpay', { providerOrderId: 'mp_1', status: 'FAILED' }) + const res = await processProviderWebhook('moonpay', { + providerOrderId: 'mp_1', + status: 'FAILED', + }) expect(res.handled).toBe(true) expect(res.reason).toBe('already terminal') @@ -87,7 +100,10 @@ describe('processProviderWebhook', () => { it('acknowledges but does not act on an unknown order', async () => { mockDb.fiatOrder.findUnique.mockResolvedValue(null) - const res = await processProviderWebhook('moonpay', { providerOrderId: 'nope', status: 'FAILED' }) + const res = await processProviderWebhook('moonpay', { + providerOrderId: 'nope', + status: 'FAILED', + }) expect(res.handled).toBe(false) expect(res.reason).toBe('unknown order') @@ -96,7 +112,10 @@ describe('processProviderWebhook', () => { it('marks FAILED with a reason and emits an outbound webhook', async () => { mockDb.fiatOrder.findUnique.mockResolvedValue(baseOrder()) - mockDb.fiatOrder.update.mockImplementation(({ data }: any) => ({ ...baseOrder(), ...data })) + mockDb.fiatOrder.update.mockImplementation(({ data }: any) => ({ + ...baseOrder(), + ...data, + })) await processProviderWebhook('moonpay', { providerOrderId: 'mp_1', @@ -107,11 +126,17 @@ describe('processProviderWebhook', () => { const updateArg = mockDb.fiatOrder.update.mock.calls[0][0] expect(updateArg.data.status).toBe('FAILED') expect(updateArg.data.failureReason).toBe('card_declined') - expect(mockDispatch).toHaveBeenCalledWith('fiat.order.failed', expect.objectContaining({ status: 'FAILED' })) + expect(mockDispatch).toHaveBeenCalledWith( + 'fiat.order.failed', + expect.objectContaining({ status: 'FAILED' }) + ) }) it('rejects a webhook with no providerOrderId', async () => { - const res = await processProviderWebhook('moonpay', { providerOrderId: '', status: 'PENDING' }) + const res = await processProviderWebhook('moonpay', { + providerOrderId: '', + status: 'PENDING', + }) expect(res.handled).toBe(false) expect(mockDb.fiatOrder.findUnique).not.toHaveBeenCalled() }) @@ -120,15 +145,28 @@ describe('processProviderWebhook', () => { mockDb.fiatOrder.findUnique .mockResolvedValueOnce(baseOrder()) // webhook lookup .mockResolvedValueOnce(baseOrder({ status: 'PROCESSING' })) // reconcileSingleOrder lookup - mockDb.fiatOrder.update.mockImplementation(({ data }: any) => ({ ...baseOrder(), ...data })) + mockDb.fiatOrder.update.mockImplementation(({ data }: any) => ({ + ...baseOrder(), + ...data, + })) mockDb.transaction.findUnique.mockResolvedValue({ - id: 'tx-1', txHash: '0xabc', status: 'CONFIRMED', userId: 'user-1', amount: 98.5, + id: 'tx-1', + txHash: '0xabc', + status: 'CONFIRMED', + userId: 'user-1', + amount: 98.5, }) - await processProviderWebhook('moonpay', { providerOrderId: 'mp_1', status: 'SETTLED', txHash: '0xabc' }) + await processProviderWebhook('moonpay', { + providerOrderId: 'mp_1', + status: 'SETTLED', + txHash: '0xabc', + }) // Second update is the settlement. - const settleCall = mockDb.fiatOrder.update.mock.calls.find((c: any) => c[0].data.status === 'SETTLED') + const settleCall = mockDb.fiatOrder.update.mock.calls.find( + (c: any) => c[0].data.status === 'SETTLED' + ) expect(settleCall).toBeDefined() expect(settleCall[0].data.transactionId).toBe('tx-1') }) @@ -136,25 +174,48 @@ describe('processProviderWebhook', () => { describe('reconcileSingleOrder', () => { it('settles only when a CONFIRMED transaction exists for the same user', async () => { - mockDb.fiatOrder.findUnique.mockResolvedValue(baseOrder({ status: 'PROCESSING' })) + mockDb.fiatOrder.findUnique.mockResolvedValue( + baseOrder({ status: 'PROCESSING' }) + ) mockDb.transaction.findUnique.mockResolvedValue({ - id: 'tx-1', txHash: '0xabc', status: 'CONFIRMED', userId: 'user-1', amount: 100, + id: 'tx-1', + txHash: '0xabc', + status: 'CONFIRMED', + userId: 'user-1', + amount: 100, }) - mockDb.fiatOrder.update.mockImplementation(({ data }: any) => ({ ...baseOrder(), ...data })) + mockDb.fiatOrder.update.mockImplementation(({ data }: any) => ({ + ...baseOrder(), + ...data, + })) const ok = await reconcileSingleOrder('order-1', '0xabc') expect(ok).toBe(true) expect(mockDb.fiatOrder.update).toHaveBeenCalledWith( - expect.objectContaining({ data: expect.objectContaining({ status: 'SETTLED', transactionId: 'tx-1' }) }), + expect.objectContaining({ + data: expect.objectContaining({ + status: 'SETTLED', + transactionId: 'tx-1', + }), + }) + ) + expect(mockDispatch).toHaveBeenCalledWith( + 'fiat.order.settled', + expect.objectContaining({ txHash: '0xabc' }) ) - expect(mockDispatch).toHaveBeenCalledWith('fiat.order.settled', expect.objectContaining({ txHash: '0xabc' })) }) it('does not settle when the transaction is not yet CONFIRMED', async () => { - mockDb.fiatOrder.findUnique.mockResolvedValue(baseOrder({ status: 'PROCESSING' })) + mockDb.fiatOrder.findUnique.mockResolvedValue( + baseOrder({ status: 'PROCESSING' }) + ) mockDb.transaction.findUnique.mockResolvedValue({ - id: 'tx-1', txHash: '0xabc', status: 'PENDING', userId: 'user-1', amount: 100, + id: 'tx-1', + txHash: '0xabc', + status: 'PENDING', + userId: 'user-1', + amount: 100, }) const ok = await reconcileSingleOrder('order-1', '0xabc') @@ -164,9 +225,15 @@ describe('reconcileSingleOrder', () => { }) it('refuses to link a tx hash that belongs to a different user', async () => { - mockDb.fiatOrder.findUnique.mockResolvedValue(baseOrder({ status: 'PROCESSING' })) + mockDb.fiatOrder.findUnique.mockResolvedValue( + baseOrder({ status: 'PROCESSING' }) + ) mockDb.transaction.findUnique.mockResolvedValue({ - id: 'tx-1', txHash: '0xabc', status: 'CONFIRMED', userId: 'attacker', amount: 100, + id: 'tx-1', + txHash: '0xabc', + status: 'CONFIRMED', + userId: 'attacker', + amount: 100, }) const ok = await reconcileSingleOrder('order-1', '0xabc') @@ -176,7 +243,9 @@ describe('reconcileSingleOrder', () => { }) it('is a no-op for an already-terminal order', async () => { - mockDb.fiatOrder.findUnique.mockResolvedValue(baseOrder({ status: 'SETTLED' })) + mockDb.fiatOrder.findUnique.mockResolvedValue( + baseOrder({ status: 'SETTLED' }) + ) const ok = await reconcileSingleOrder('order-1', '0xabc') expect(ok).toBe(false) expect(mockDb.transaction.findUnique).not.toHaveBeenCalled() @@ -185,16 +254,31 @@ describe('reconcileSingleOrder', () => { describe('reconcileFiatOrders', () => { it('settles PROCESSING orders that now have a confirmed on-chain match', async () => { - mockDb.fiatOrder.findMany.mockResolvedValue([baseOrder({ status: 'PROCESSING' })]) + mockDb.fiatOrder.findMany.mockResolvedValue([ + baseOrder({ status: 'PROCESSING' }), + ]) mockDb.transaction.findFirst.mockResolvedValue({ - id: 'tx-1', txHash: '0xabc', status: 'CONFIRMED', userId: 'user-1', amount: 100, + id: 'tx-1', + txHash: '0xabc', + status: 'CONFIRMED', + userId: 'user-1', + amount: 100, }) // reconcileSingleOrder re-reads the order + tx. - mockDb.fiatOrder.findUnique.mockResolvedValue(baseOrder({ status: 'PROCESSING' })) + mockDb.fiatOrder.findUnique.mockResolvedValue( + baseOrder({ status: 'PROCESSING' }) + ) mockDb.transaction.findUnique.mockResolvedValue({ - id: 'tx-1', txHash: '0xabc', status: 'CONFIRMED', userId: 'user-1', amount: 100, + id: 'tx-1', + txHash: '0xabc', + status: 'CONFIRMED', + userId: 'user-1', + amount: 100, }) - mockDb.fiatOrder.update.mockImplementation(({ data }: any) => ({ ...baseOrder(), ...data })) + mockDb.fiatOrder.update.mockImplementation(({ data }: any) => ({ + ...baseOrder(), + ...data, + })) const res = await reconcileFiatOrders() @@ -214,15 +298,21 @@ describe('reconcileFiatOrders', () => { expect(res.settled).toBe(0) expect(mockEmit).toHaveBeenCalledWith( - expect.objectContaining({ severity: 'critical', component: 'fiat-reconciliation' }), - expect.stringContaining('fiat:stuck:'), + expect.objectContaining({ + severity: 'critical', + component: 'fiat-reconciliation', + }), + expect.stringContaining('fiat:stuck:') ) }) }) describe('ageOutStaleFiatOrders', () => { it('fails PENDING orders older than the stale threshold', async () => { - mockDb.fiatOrder.findMany.mockResolvedValue([{ id: 'order-1' }, { id: 'order-2' }]) + mockDb.fiatOrder.findMany.mockResolvedValue([ + { id: 'order-1' }, + { id: 'order-2' }, + ]) mockDb.fiatOrder.update.mockResolvedValue({}) const res = await ageOutStaleFiatOrders() diff --git a/tests/unit/jobs/job-metrics.test.ts b/tests/unit/jobs/job-metrics.test.ts index c943c1e..512a069 100644 --- a/tests/unit/jobs/job-metrics.test.ts +++ b/tests/unit/jobs/job-metrics.test.ts @@ -58,7 +58,10 @@ jest.mock('../../../src/utils/metrics-registry', () => ({ }, })) -import { recordJobSuccess, recordJobFailure } from '../../../src/utils/job-metrics' +import { + recordJobSuccess, + recordJobFailure, +} from '../../../src/utils/job-metrics' describe('job-metrics', () => { beforeEach(() => { @@ -70,19 +73,26 @@ describe('job-metrics', () => { describe('recordJobSuccess', () => { it('increments job_success_total with the correct job_name label', () => { recordJobSuccess('session_cleanup', 250) - expect(mockSuccessInc).toHaveBeenCalledWith({ job_name: 'session_cleanup' }) + expect(mockSuccessInc).toHaveBeenCalledWith({ + job_name: 'session_cleanup', + }) }) it('observes job_duration_ms with the correct job_name label and duration', () => { recordJobSuccess('retention_auth_nonces', 450) - expect(mockObserve).toHaveBeenCalledWith({ job_name: 'retention_auth_nonces' }, 450) + expect(mockObserve).toHaveBeenCalledWith( + { job_name: 'retention_auth_nonces' }, + 450 + ) }) it('does not call inc on the failure counter', () => { mockSuccessInc.mockClear() mockFailureInc.mockClear() recordJobSuccess('retention_agent_logs', 100) - expect(mockSuccessInc).toHaveBeenCalledWith({ job_name: 'retention_agent_logs' }) + expect(mockSuccessInc).toHaveBeenCalledWith({ + job_name: 'retention_agent_logs', + }) expect(mockFailureInc).not.toHaveBeenCalled() }) }) @@ -90,19 +100,26 @@ describe('job-metrics', () => { describe('recordJobFailure', () => { it('increments job_failure_total with the correct job_name label', () => { recordJobFailure('session_cleanup', 300) - expect(mockFailureInc).toHaveBeenCalledWith({ job_name: 'session_cleanup' }) + expect(mockFailureInc).toHaveBeenCalledWith({ + job_name: 'session_cleanup', + }) }) it('observes job_duration_ms with the correct job_name label and duration', () => { recordJobFailure('retention_processed_events', 750) - expect(mockObserve).toHaveBeenCalledWith({ job_name: 'retention_processed_events' }, 750) + expect(mockObserve).toHaveBeenCalledWith( + { job_name: 'retention_processed_events' }, + 750 + ) }) it('does not call inc on the success counter', () => { mockSuccessInc.mockClear() mockFailureInc.mockClear() recordJobFailure('retention_dead_letter_events', 200) - expect(mockFailureInc).toHaveBeenCalledWith({ job_name: 'retention_dead_letter_events' }) + expect(mockFailureInc).toHaveBeenCalledWith({ + job_name: 'retention_dead_letter_events', + }) expect(mockSuccessInc).not.toHaveBeenCalled() }) }) diff --git a/tests/unit/middleware/adminAuth.test.ts b/tests/unit/middleware/adminAuth.test.ts index 7c41194..a49de6f 100644 --- a/tests/unit/middleware/adminAuth.test.ts +++ b/tests/unit/middleware/adminAuth.test.ts @@ -73,7 +73,10 @@ describe('admin auth middleware', () => { await requireAdminAuth(req as Request, res as Response, next) expect((db as any).adminApiKey.findMany).toHaveBeenCalled() - expect(mockBcryptCompare).toHaveBeenCalledWith('valid-admin-token', '$2a$12$fakehash') + expect(mockBcryptCompare).toHaveBeenCalledWith( + 'valid-admin-token', + '$2a$12$fakehash' + ) expect(res.status).not.toHaveBeenCalled() expect(next).toHaveBeenCalled() expect(res.locals?.adminAuth).toEqual( @@ -82,7 +85,7 @@ describe('admin auth middleware', () => { name: 'ops-token', role: 'OPS_ADMIN', scopes: ['metrics:read'], - }), + }) ) }) @@ -107,13 +110,13 @@ describe('admin auth middleware', () => { await requireAdminAuth(req as Request, res as Response, next) // Give the fire-and-forget update a tick to run - await new Promise(resolve => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) expect(mockUpdate).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'token-1' }, data: expect.objectContaining({ lastUsedAt: expect.any(Date) }), - }), + }) ) }) @@ -183,7 +186,10 @@ describe('admin auth middleware', () => { await requireAdminAuth(req as Request, res as Response, next) - expect(mockBcryptCompare).toHaveBeenCalledWith('legacy-token', '$2a$12$fakehash') + expect(mockBcryptCompare).toHaveBeenCalledWith( + 'legacy-token', + '$2a$12$fakehash' + ) expect(next).toHaveBeenCalled() }) @@ -203,4 +209,4 @@ describe('admin auth middleware', () => { }) expect(next).not.toHaveBeenCalled() }) -}) \ No newline at end of file +}) diff --git a/tests/unit/middleware/authenticate.test.ts b/tests/unit/middleware/authenticate.test.ts index e8c50b2..a64c712 100644 --- a/tests/unit/middleware/authenticate.test.ts +++ b/tests/unit/middleware/authenticate.test.ts @@ -1,198 +1,245 @@ -import { Request, Response, NextFunction } from 'express'; -import { Network } from '@prisma/client'; -import { requireAuth, enforceUserAccess, AuthMiddleware } from '../../../src/middleware/authenticate'; -import { JwtAdapter } from '../../../src/config'; -import db from '../../../src/db'; -import { logger } from '../../../src/utils/logger'; -import { makeSession } from '../../fixtures'; - -jest.mock('../../../src/config'); -jest.mock('../../../src/db'); -jest.mock('../../../src/utils/logger'); +import { Request, Response, NextFunction } from 'express' +import { Network } from '@prisma/client' +import { + requireAuth, + enforceUserAccess, + AuthMiddleware, +} from '../../../src/middleware/authenticate' +import { JwtAdapter } from '../../../src/config' +import db from '../../../src/db' +import { logger } from '../../../src/utils/logger' +import { makeSession } from '../../fixtures' + +jest.mock('../../../src/config') +// Explicit factory: automocking src/db can't see the PrismaClient instance's +// lazily-defined model properties (db.session would be undefined). +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: { + session: { + findUnique: jest.fn(), + delete: jest.fn(), + }, + user: { + findUnique: jest.fn(), + }, + }, +})) +jest.mock('../../../src/utils/logger') type AuthPayload = { - userId: string; - sessionId: string; - walletAddress: string; - network: Network; -}; + userId: string + sessionId: string + walletAddress: string + network: Network +} type AuthenticatedRequest = Partial & { - userId?: string; - stellarPubKey?: string; - auth?: AuthPayload; - header?: any; -}; + userId?: string + stellarPubKey?: string + auth?: AuthPayload + header?: any +} describe('Authentication Middleware (Unified)', () => { - let req: AuthenticatedRequest; - let res: Partial; - let next: NextFunction; + let req: AuthenticatedRequest + let res: Partial + let next: NextFunction beforeEach(() => { req = { headers: {}, params: {}, body: {}, - }; + } + // Middleware reads headers via req.header(name); back it with req.headers + // so tests can keep seeding plain header objects. + req.header = jest.fn((name: string) => + req.headers + ? (req.headers as Record)[name.toLowerCase()] + : undefined + ) as any res = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis(), - }; + } - next = jest.fn(); - jest.clearAllMocks(); - }); + next = jest.fn() + jest.clearAllMocks() + }) function mockHeader(token: string) { req.header = jest.fn((name: string) => - name === 'Authorization' ? `Bearer ${token}` : undefined, - ) as any; + name === 'Authorization' ? `Bearer ${token}` : undefined + ) as any } describe('requireAuth - JWT + Session Validation', () => { it('should reject requests without Authorization header', async () => { - await requireAuth(req as Request, res as Response, next); + await requireAuth(req as Request, res as Response, next) - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }); - expect(next).not.toHaveBeenCalled(); - }); + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }) + expect(next).not.toHaveBeenCalled() + }) it('should reject requests with malformed Bearer token', async () => { - req.headers = { authorization: 'InvalidFormat token' }; + req.headers = { authorization: 'InvalidFormat token' } - await requireAuth(req as Request, res as Response, next); + await requireAuth(req as Request, res as Response, next) - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid Bearer token' }); - expect(next).not.toHaveBeenCalled(); - }); + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid Bearer token' }) + expect(next).not.toHaveBeenCalled() + }) it('should reject requests with missing token after Bearer', async () => { - req.headers = { authorization: 'Bearer ' }; + req.headers = { authorization: 'Bearer ' } - await requireAuth(req as Request, res as Response, next); + await requireAuth(req as Request, res as Response, next) - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid token' }); - expect(next).not.toHaveBeenCalled(); - }); + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid token' }) + expect(next).not.toHaveBeenCalled() + }) it('should reject invalid JWT signatures', async () => { - req.headers = { authorization: 'Bearer invalid.jwt.token' }; - (JwtAdapter.validateToken as jest.Mock).mockResolvedValue(null); + req.headers = { authorization: 'Bearer invalid.jwt.token' } + ;(JwtAdapter.validateToken as jest.Mock).mockResolvedValue(null) - await requireAuth(req as Request, res as Response, next); + await requireAuth(req as Request, res as Response, next) - expect(JwtAdapter.validateToken).toHaveBeenCalledWith('invalid.jwt.token'); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid token' }); - expect(next).not.toHaveBeenCalled(); - }); + expect(JwtAdapter.validateToken).toHaveBeenCalledWith('invalid.jwt.token') + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid token' }) + expect(next).not.toHaveBeenCalled() + }) it('should reject requests when session not found in database', async () => { - const token = 'valid.jwt.token'; - req.headers = { authorization: `Bearer ${token}` }; - (JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ id: 'user1' }); - (db.session.findUnique as jest.Mock).mockResolvedValue(null); + const token = 'valid.jwt.token' + req.headers = { authorization: `Bearer ${token}` } + ;(JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ + id: 'user1', + }) + ;(db.session.findUnique as jest.Mock).mockResolvedValue(null) - await requireAuth(req as Request, res as Response, next); + await requireAuth(req as Request, res as Response, next) - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'Session not found' }); - expect(next).not.toHaveBeenCalled(); - }); + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ error: 'Session not found' }) + expect(next).not.toHaveBeenCalled() + }) it('should reject expired sessions and clean them up', async () => { - const token = 'valid.jwt.token'; - req.headers = { authorization: `Bearer ${token}` }; + const token = 'valid.jwt.token' + req.headers = { authorization: `Bearer ${token}` } - (JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ id: 'user1' }); - (db.session.findUnique as jest.Mock).mockResolvedValue( - makeSession(token, { expiresAt: new Date(Date.now() - 3_600_000) }), - ); - (db.session.delete as jest.Mock).mockResolvedValue({}); + ;(JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ + id: 'user1', + }) + ;(db.session.findUnique as jest.Mock).mockResolvedValue( + makeSession(token, { expiresAt: new Date(Date.now() - 3_600_000) }) + ) + ;(db.session.delete as jest.Mock).mockResolvedValue({}) - await requireAuth(req as Request, res as Response, next); + await requireAuth(req as Request, res as Response, next) - expect(db.session.delete).toHaveBeenCalledWith({ where: { token } }); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'Session expired' }); - expect(next).not.toHaveBeenCalled(); - }); + expect(db.session.delete).toHaveBeenCalledWith({ where: { token } }) + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ error: 'Session expired' }) + expect(next).not.toHaveBeenCalled() + }) it('should reject inactive users', async () => { - const token = 'valid.jwt.token'; - req.headers = { authorization: `Bearer ${token}` }; + const token = 'valid.jwt.token' + req.headers = { authorization: `Bearer ${token}` } - (JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ id: 'user1' }); - (db.session.findUnique as jest.Mock).mockResolvedValue( - makeSession(token, { user: { id: 'user1', isActive: false } }), - ); + ;(JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ + id: 'user1', + }) + ;(db.session.findUnique as jest.Mock).mockResolvedValue( + makeSession(token, { user: { id: 'user1', isActive: false } }) + ) - await requireAuth(req as Request, res as Response, next); + await requireAuth(req as Request, res as Response, next) - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'User account is inactive' }); - expect(next).not.toHaveBeenCalled(); - }); + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ + error: 'User account is inactive', + }) + expect(next).not.toHaveBeenCalled() + }) it('should successfully authenticate valid JWT + active session + active user', async () => { - const token = 'valid.jwt.token'; - req.headers = { authorization: `Bearer ${token}` }; - mockHeader(token); - - (JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ id: 'user1' }); - (db.session.findUnique as jest.Mock).mockResolvedValue(makeSession(token)); - - await requireAuth(req as Request, res as Response, next); - - expect(req.userId).toBe('user1'); - expect(req.stellarPubKey).toBe('GDZST3XVCDTUJ76ZAV2HA72KYXM4Y5KLTMPQWLBQ3VBLGR4A5YNWHA63'); + const token = 'valid.jwt.token' + req.headers = { authorization: `Bearer ${token}` } + mockHeader(token) + + ;(JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ + id: 'user1', + }) + ;(db.session.findUnique as jest.Mock).mockResolvedValue( + makeSession(token) + ) + + await requireAuth(req as Request, res as Response, next) + + expect(req.userId).toBe('user1') + expect(req.stellarPubKey).toBe( + 'GDZST3XVCDTUJ76ZAV2HA72KYXM4Y5KLTMPQWLBQ3VBLGR4A5YNWHA63' + ) expect(req.auth).toEqual({ userId: 'user1', sessionId: 'session1', - walletAddress: 'GDZST3XVCDTUJ76ZAV2HA72KYXM4Y5KLTMPQWLBQ3VBLGR4A5YNWHA63', + walletAddress: + 'GDZST3XVCDTUJ76ZAV2HA72KYXM4Y5KLTMPQWLBQ3VBLGR4A5YNWHA63', network: Network.MAINNET, - }); - expect(next).toHaveBeenCalled(); - }); + }) + expect(next).toHaveBeenCalled() + }) it('should handle JWT validation errors gracefully', async () => { - const token = 'valid.jwt.token'; - req.headers = { authorization: `Bearer ${token}` }; - mockHeader(token); + const token = 'valid.jwt.token' + req.headers = { authorization: `Bearer ${token}` } + mockHeader(token) - const testError = new Error('JWT validation failed'); - (JwtAdapter.validateToken as jest.Mock).mockRejectedValue(testError); + const testError = new Error('JWT validation failed') + ;(JwtAdapter.validateToken as jest.Mock).mockRejectedValue(testError) - await requireAuth(req as Request, res as Response, next); + await requireAuth(req as Request, res as Response, next) - expect(logger.error).toHaveBeenCalledWith('[Auth] Middleware error:', testError); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' }); - expect(next).not.toHaveBeenCalled(); - }); + expect(logger.error).toHaveBeenCalledWith( + '[Auth] Middleware error:', + testError + ) + expect(res.status).toHaveBeenCalledWith(500) + expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' }) + expect(next).not.toHaveBeenCalled() + }) it('should handle database errors gracefully', async () => { - const token = 'valid.jwt.token'; - req.headers = { authorization: `Bearer ${token}` }; - mockHeader(token); - - (JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ id: 'user1' }); - const dbError = new Error('Database connection failed'); - (db.session.findUnique as jest.Mock).mockRejectedValue(dbError); - - await requireAuth(req as Request, res as Response, next); - - expect(logger.error).toHaveBeenCalledWith('[Auth] Middleware error:', dbError); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' }); - expect(next).not.toHaveBeenCalled(); - }); - }); + const token = 'valid.jwt.token' + req.headers = { authorization: `Bearer ${token}` } + mockHeader(token) + + ;(JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ + id: 'user1', + }) + const dbError = new Error('Database connection failed') + ;(db.session.findUnique as jest.Mock).mockRejectedValue(dbError) + + await requireAuth(req as Request, res as Response, next) + + expect(logger.error).toHaveBeenCalledWith( + '[Auth] Middleware error:', + dbError + ) + expect(res.status).toHaveBeenCalledWith(500) + expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' }) + expect(next).not.toHaveBeenCalled() + }) + }) describe('enforceUserAccess - Authorization Check', () => { const fullAuth: AuthPayload = { @@ -200,146 +247,156 @@ describe('Authentication Middleware (Unified)', () => { sessionId: 'session1', walletAddress: '0x123', network: Network.MAINNET, - }; + } it('should reject requests without authentication', () => { - req.auth = undefined; - req.params = { userId: 'user2' }; + req.auth = undefined + req.params = { userId: 'user2' } - enforceUserAccess(req as Request, res as Response, next); + enforceUserAccess(req as Request, res as Response, next) - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }); - expect(next).not.toHaveBeenCalled(); - }); + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }) + expect(next).not.toHaveBeenCalled() + }) it('should allow access to own user data (params)', () => { - req.auth = fullAuth; - req.params = { userId: 'user1' }; + req.auth = fullAuth + req.params = { userId: 'user1' } - enforceUserAccess(req as Request, res as Response, next); + enforceUserAccess(req as Request, res as Response, next) - expect(res.status).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalled(); - }); + expect(res.status).not.toHaveBeenCalled() + expect(next).toHaveBeenCalled() + }) it('should allow access to own user data (body)', () => { - req.auth = fullAuth; - req.body = { userId: 'user1' }; + req.auth = fullAuth + req.body = { userId: 'user1' } - enforceUserAccess(req as Request, res as Response, next); + enforceUserAccess(req as Request, res as Response, next) - expect(res.status).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalled(); - }); + expect(res.status).not.toHaveBeenCalled() + expect(next).toHaveBeenCalled() + }) it('should reject access to other user data (params)', () => { - req.auth = fullAuth; - req.params = { userId: 'user2' }; + req.auth = fullAuth + req.params = { userId: 'user2' } - enforceUserAccess(req as Request, res as Response, next); + enforceUserAccess(req as Request, res as Response, next) - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }); - expect(next).not.toHaveBeenCalled(); - }); + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }) + expect(next).not.toHaveBeenCalled() + }) it('should reject access to other user data (body)', () => { - req.auth = fullAuth; - req.body = { userId: 'user2' }; + req.auth = fullAuth + req.body = { userId: 'user2' } - enforceUserAccess(req as Request, res as Response, next); + enforceUserAccess(req as Request, res as Response, next) - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }); - expect(next).not.toHaveBeenCalled(); - }); + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }) + expect(next).not.toHaveBeenCalled() + }) it('should allow access without userId check when not specified', () => { - req.auth = fullAuth; - req.params = {}; - req.body = {}; + req.auth = fullAuth + req.params = {} + req.body = {} - enforceUserAccess(req as Request, res as Response, next); + enforceUserAccess(req as Request, res as Response, next) - expect(res.status).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalled(); - }); - }); + expect(res.status).not.toHaveBeenCalled() + expect(next).toHaveBeenCalled() + }) + }) describe('AuthMiddleware backward compatibility', () => { it('should have validateJwt pointing to requireAuth', () => { - expect(AuthMiddleware.validateJwt).toBe(requireAuth); - }); + expect(AuthMiddleware.validateJwt).toBe(requireAuth) + }) it('should work as a middleware when used as AuthMiddleware.validateJwt', async () => { - const token = 'valid.jwt.token'; - req.headers = { authorization: `Bearer ${token}` }; - mockHeader(token); + const token = 'valid.jwt.token' + req.headers = { authorization: `Bearer ${token}` } + mockHeader(token) - (JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ id: 'user1' }); - (db.session.findUnique as jest.Mock).mockResolvedValue(makeSession(token)); + ;(JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ + id: 'user1', + }) + ;(db.session.findUnique as jest.Mock).mockResolvedValue( + makeSession(token) + ) - await AuthMiddleware.validateJwt(req as Request, res as Response, next); + await AuthMiddleware.validateJwt(req as Request, res as Response, next) - expect(next).toHaveBeenCalled(); - expect(req.userId).toBe('user1'); - }); - }); + expect(next).toHaveBeenCalled() + expect(req.userId).toBe('user1') + }) + }) describe('Security Requirements', () => { it('should ALWAYS verify JWT signature before trusting token', async () => { - const token = 'valid.jwt.token'; - req.headers = { authorization: `Bearer ${token}` }; - mockHeader(token); - (JwtAdapter.validateToken as jest.Mock).mockResolvedValue(null); + const token = 'valid.jwt.token' + req.headers = { authorization: `Bearer ${token}` } + mockHeader(token) + ;(JwtAdapter.validateToken as jest.Mock).mockResolvedValue(null) - await requireAuth(req as Request, res as Response, next); + await requireAuth(req as Request, res as Response, next) - expect(JwtAdapter.validateToken).toHaveBeenCalled(); - expect(next).not.toHaveBeenCalled(); - }); + expect(JwtAdapter.validateToken).toHaveBeenCalled() + expect(next).not.toHaveBeenCalled() + }) it('should ALWAYS check DB session even if JWT is valid', async () => { - const token = 'valid.jwt.token'; - req.headers = { authorization: `Bearer ${token}` }; - mockHeader(token); - (JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ id: 'user1' }); - (db.session.findUnique as jest.Mock).mockResolvedValue(null); + const token = 'valid.jwt.token' + req.headers = { authorization: `Bearer ${token}` } + mockHeader(token) + ;(JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ + id: 'user1', + }) + ;(db.session.findUnique as jest.Mock).mockResolvedValue(null) - await requireAuth(req as Request, res as Response, next); + await requireAuth(req as Request, res as Response, next) - expect(db.session.findUnique).toHaveBeenCalled(); - expect(next).not.toHaveBeenCalled(); - }); + expect(db.session.findUnique).toHaveBeenCalled() + expect(next).not.toHaveBeenCalled() + }) it('should ALWAYS check session expiry', async () => { - const token = 'valid.jwt.token'; - req.headers = { authorization: `Bearer ${token}` }; - mockHeader(token); - (JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ id: 'user1' }); - (db.session.findUnique as jest.Mock).mockResolvedValue( - makeSession(token, { expiresAt: new Date(Date.now() - 1000) }), - ); - (db.session.delete as jest.Mock).mockResolvedValue({}); - - await requireAuth(req as Request, res as Response, next); - - expect(next).not.toHaveBeenCalled(); - }); + const token = 'valid.jwt.token' + req.headers = { authorization: `Bearer ${token}` } + mockHeader(token) + ;(JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ + id: 'user1', + }) + ;(db.session.findUnique as jest.Mock).mockResolvedValue( + makeSession(token, { expiresAt: new Date(Date.now() - 1000) }) + ) + ;(db.session.delete as jest.Mock).mockResolvedValue({}) + + await requireAuth(req as Request, res as Response, next) + + expect(next).not.toHaveBeenCalled() + }) it('should ALWAYS check user.isActive status', async () => { - const token = 'valid.jwt.token'; - req.headers = { authorization: `Bearer ${token}` }; - mockHeader(token); - (JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ id: 'user1' }); - (db.session.findUnique as jest.Mock).mockResolvedValue( - makeSession(token, { user: { id: 'user1', isActive: false } }), - ); - - await requireAuth(req as Request, res as Response, next); - - expect(next).not.toHaveBeenCalled(); - }); - }); -}); \ No newline at end of file + const token = 'valid.jwt.token' + req.headers = { authorization: `Bearer ${token}` } + mockHeader(token) + ;(JwtAdapter.validateToken as jest.Mock).mockResolvedValue({ + id: 'user1', + }) + ;(db.session.findUnique as jest.Mock).mockResolvedValue( + makeSession(token, { user: { id: 'user1', isActive: false } }) + ) + + await requireAuth(req as Request, res as Response, next) + + expect(next).not.toHaveBeenCalled() + }) + }) +}) diff --git a/tests/unit/middleware/correlationId.test.ts b/tests/unit/middleware/correlationId.test.ts index 37a9e40..4e154c1 100644 --- a/tests/unit/middleware/correlationId.test.ts +++ b/tests/unit/middleware/correlationId.test.ts @@ -1,10 +1,18 @@ import { Request, Response, NextFunction } from 'express' -import { correlationIdMiddleware, REQUEST_ID_HEADER } from '../../../src/middleware/correlationId' -import { isValidCorrelationId, generateCorrelationId } from '../../../src/utils/correlation' +import { + correlationIdMiddleware, + REQUEST_ID_HEADER, +} from '../../../src/middleware/correlationId' +import { + isValidCorrelationId, + generateCorrelationId, +} from '../../../src/utils/correlation' describe('correlation utilities', () => { it('accepts UUID-shaped IDs', () => { - expect(isValidCorrelationId('550e8400-e29b-41d4-a716-446655440000')).toBe(true) + expect(isValidCorrelationId('550e8400-e29b-41d4-a716-446655440000')).toBe( + true + ) }) it('accepts alphanumeric request IDs up to 128 chars', () => { @@ -66,7 +74,10 @@ describe('correlationIdMiddleware', () => { expect(req.correlationId).toBeDefined() expect(isValidCorrelationId(req.correlationId!)).toBe(true) - expect(res.setHeader).toHaveBeenCalledWith(REQUEST_ID_HEADER, req.correlationId) + expect(res.setHeader).toHaveBeenCalledWith( + REQUEST_ID_HEADER, + req.correlationId + ) }) it('generates a new ID when the client header is invalid', () => { diff --git a/tests/unit/middleware/corsandbody.test.ts b/tests/unit/middleware/corsandbody.test.ts index 748cafe..2704fc1 100644 --- a/tests/unit/middleware/corsandbody.test.ts +++ b/tests/unit/middleware/corsandbody.test.ts @@ -65,7 +65,9 @@ describe('corsandbody middleware', () => { }) it('should reject multipart/form-data', () => { - req.headers = { 'content-type': 'multipart/form-data; boundary=----WebKitFormBoundary' } + req.headers = { + 'content-type': 'multipart/form-data; boundary=----WebKitFormBoundary', + } contentTypeRestrictionMiddleware(req as Request, res as Response, next) expect(next).not.toHaveBeenCalled() expect(res.status).toHaveBeenCalledWith(415) @@ -85,7 +87,8 @@ describe('corsandbody middleware', () => { expect(res.json).toHaveBeenCalledWith({ success: false, error: 'Unsupported Media Type', - reason: 'Content type "application/x-www-form-urlencoded" is not allowed.', + reason: + 'Content type "application/x-www-form-urlencoded" is not allowed.', }) expect(recordRejectedRequest).toHaveBeenCalledWith('content_type') }) @@ -136,4 +139,4 @@ describe('corsandbody middleware', () => { expect(next).toHaveBeenCalled() }) }) -}) \ No newline at end of file +}) diff --git a/tests/unit/middleware/requestTimeout.test.ts b/tests/unit/middleware/requestTimeout.test.ts index d527454..f7b07d2 100644 --- a/tests/unit/middleware/requestTimeout.test.ts +++ b/tests/unit/middleware/requestTimeout.test.ts @@ -1,17 +1,15 @@ import express from 'express' import request from 'supertest' -import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals' -import { requestTimeoutMiddleware, resolveRequestTimeout } from '../../../src/middleware/requestTimeout' +import { afterEach, describe, expect, it, jest } from '@jest/globals' +import { + requestTimeoutMiddleware, + resolveRequestTimeout, +} from '../../../src/middleware/requestTimeout' import { register } from '../../../src/utils/metrics' +import { config } from '../../../src/config/env' describe('requestTimeout middleware', () => { - beforeEach(() => { - jest.useFakeTimers() - register.resetMetrics() - }) - afterEach(() => { - jest.useRealTimers() register.resetMetrics() jest.restoreAllMocks() }) @@ -36,6 +34,10 @@ describe('requestTimeout middleware', () => { }) it('returns 504 when a route exceeds the configured timeout', async () => { + // Fake timers can't drive a real supertest socket round-trip — shrink the + // configured window instead and let the timeout fire for real. + jest.replaceProperty(config, 'requestTimeoutMs', 100) + const app = express() app.use(requestTimeoutMiddleware) app.get('/slow', async () => { @@ -44,15 +46,15 @@ describe('requestTimeout middleware', () => { }) }) - const pending = request(app).get('/slow') - jest.advanceTimersByTime(30_000) - await Promise.resolve() - const res = await pending + const res = await request(app).get('/slow') expect(res.status).toBe(504) expect(res.body).toEqual({ error: 'Request timed out' }) const metrics = await register.metrics() - expect(metrics).toContain('request_timeouts_total{route_group="general"} 1') + // The registry appends default labels (e.g. env), so match loosely. + expect(metrics).toMatch( + /request_timeouts_total\{[^}]*route_group="general"[^}]*\} 1/ + ) }) }) diff --git a/tests/unit/routes/health.test.ts b/tests/unit/routes/health.test.ts index 07d528a..7c4b508 100644 --- a/tests/unit/routes/health.test.ts +++ b/tests/unit/routes/health.test.ts @@ -24,25 +24,25 @@ const mockQueryRaw = jest.fn() jest.mock('../../../src/db', () => ({ __esModule: true, default: { - $queryRaw: (...args: any[]) => mockQueryRaw(...args) - } + $queryRaw: (...args: any[]) => mockQueryRaw(...args), + }, })) const mockStellarExecute = jest.fn() jest.mock('../../../src/stellar/client', () => ({ __esModule: true, getResilientClient: () => ({ - execute: mockStellarExecute - }) + execute: mockStellarExecute, + }), })) const mockTwilioFetch = jest.fn() const mockTwilioClient = { api: { accounts: () => ({ - fetch: mockTwilioFetch - }) - } + fetch: mockTwilioFetch, + }), + }, } jest.mock('twilio', () => { return jest.fn().mockImplementation(() => mockTwilioClient) @@ -51,12 +51,12 @@ jest.mock('twilio', () => { const mockGetAgentStatus = jest.fn() jest.mock('../../../src/agent/loop', () => ({ __esModule: true, - getAgentStatus: () => mockGetAgentStatus() + getAgentStatus: () => mockGetAgentStatus(), })) describe('GET /health/deep', () => { const token = 'test-internal-token' - + beforeEach(() => { jest.clearAllMocks() process.env.INTERNAL_SERVICE_TOKEN = token @@ -77,7 +77,7 @@ describe('GET /health/deep', () => { isRunning: true, lastTickAt: new Date(), lastError: null, - healthStatus: 'healthy' + healthStatus: 'healthy', }) const res = await request(app) @@ -101,7 +101,7 @@ describe('GET /health/deep', () => { isRunning: true, lastTickAt: new Date(), lastError: null, - healthStatus: 'healthy' + healthStatus: 'healthy', }) const res = await request(app) @@ -122,7 +122,7 @@ describe('GET /health/deep', () => { isRunning: true, lastTickAt: new Date(), lastError: null, - healthStatus: 'healthy' + healthStatus: 'healthy', }) const res = await request(app) @@ -142,7 +142,7 @@ describe('GET /health/deep', () => { isRunning: true, lastTickAt: new Date(), lastError: null, - healthStatus: 'healthy' + healthStatus: 'healthy', }) const res = await request(app) @@ -163,7 +163,7 @@ describe('GET /health/deep', () => { isRunning: true, lastTickAt: oldDate, lastError: null, - healthStatus: 'healthy' + healthStatus: 'healthy', }) const res = await request(app) @@ -183,7 +183,7 @@ describe('GET /health/deep', () => { isRunning: true, lastTickAt: new Date(), lastError: 'Some warning', - healthStatus: 'degraded' + healthStatus: 'degraded', }) const res = await request(app) @@ -196,14 +196,16 @@ describe('GET /health/deep', () => { }) it('should timeout individual dependency checks after 3s', async () => { - mockQueryRaw.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 5000))) + mockQueryRaw.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 5000)) + ) mockStellarExecute.mockResolvedValue({ sequence: 12345 }) mockTwilioFetch.mockResolvedValue({}) mockGetAgentStatus.mockReturnValue({ isRunning: true, lastTickAt: new Date(), lastError: null, - healthStatus: 'healthy' + healthStatus: 'healthy', }) const res = await request(app) diff --git a/tests/unit/scripts/rotation-metrics.test.ts b/tests/unit/scripts/rotation-metrics.test.ts index 2b494b5..63cb009 100644 --- a/tests/unit/scripts/rotation-metrics.test.ts +++ b/tests/unit/scripts/rotation-metrics.test.ts @@ -11,14 +11,21 @@ import * as fs from 'fs' import * as path from 'path' import * as os from 'os' -function makeMetrics(overrides: Partial = {}): RotationMetrics { +function makeMetrics( + overrides: Partial = {} +): RotationMetrics { const base = initializeMetrics(true) return finalizeMetrics({ ...base, ...overrides }) } describe('generateDryRunReport', () => { it('shows READY when there are no failures', () => { - const metrics = makeMetrics({ totalWallets: 5, successfullyRotated: 5, failedRotations: 0, errors: [] }) + const metrics = makeMetrics({ + totalWallets: 5, + successfullyRotated: 5, + failedRotations: 0, + errors: [], + }) const report = generateDryRunReport(metrics) expect(report).toContain('Rotation Readiness: READY') expect(report).toContain('All 5 wallets passed validation checks') @@ -28,8 +35,17 @@ describe('generateDryRunReport', () => { it('shows NOT READY when failures exist', () => { const metrics = initializeMetrics(true) - recordError(metrics, 'wallet-abc123', 'user-xyz789', 'Decryption failed: bad tag') - const finalized = finalizeMetrics({ ...metrics, totalWallets: 3, successfullyRotated: 2 }) + recordError( + metrics, + 'wallet-abc123', + 'user-xyz789', + 'Decryption failed: bad tag' + ) + const finalized = finalizeMetrics({ + ...metrics, + totalWallets: 3, + successfullyRotated: 2, + }) const report = generateDryRunReport(finalized) expect(report).toContain('Rotation Readiness: NOT READY') expect(report).toContain('WARNING: 1 wallet(s) failed validation') @@ -39,7 +55,12 @@ describe('generateDryRunReport', () => { it('truncates wallet IDs and user IDs to 8 characters', () => { const metrics = initializeMetrics(true) - recordError(metrics, 'wallet-full-id-that-is-long', 'user-full-id-that-is-long', 'error') + recordError( + metrics, + 'wallet-full-id-that-is-long', + 'user-full-id-that-is-long', + 'error' + ) const finalized = finalizeMetrics({ ...metrics, totalWallets: 1 }) const report = generateDryRunReport(finalized) expect(report).toContain('wallet-f...') @@ -51,7 +72,12 @@ describe('generateDryRunReport', () => { it('caps displayed errors at 20 and shows overflow count', () => { const metrics = initializeMetrics(true) for (let i = 0; i < 25; i++) { - recordError(metrics, `wallet-${i.toString().padStart(8, '0')}`, `user-${i.toString().padStart(8, '0')}`, 'err') + recordError( + metrics, + `wallet-${i.toString().padStart(8, '0')}`, + `user-${i.toString().padStart(8, '0')}`, + 'err' + ) } const finalized = finalizeMetrics({ ...metrics, totalWallets: 25 }) const report = generateDryRunReport(finalized) @@ -59,7 +85,12 @@ describe('generateDryRunReport', () => { }) it('does not include any raw hex strings longer than 8 characters', () => { - const metrics = makeMetrics({ totalWallets: 2, successfullyRotated: 2, failedRotations: 0, errors: [] }) + const metrics = makeMetrics({ + totalWallets: 2, + successfullyRotated: 2, + failedRotations: 0, + errors: [], + }) const report = generateDryRunReport(metrics) // Any 64-char hex string (key-like) must not appear const hexPattern = /[0-9a-fA-F]{64}/ @@ -67,7 +98,12 @@ describe('generateDryRunReport', () => { }) it('includes all required sections', () => { - const metrics = makeMetrics({ totalWallets: 1, successfullyRotated: 1, failedRotations: 0, errors: [] }) + const metrics = makeMetrics({ + totalWallets: 1, + successfullyRotated: 1, + failedRotations: 0, + errors: [], + }) const report = generateDryRunReport(metrics) const requiredSections = [ 'WALLET ROTATION DRY-RUN REPORT', @@ -121,7 +157,12 @@ describe('saveDryRunReport', () => { }) it('writes a .txt file named after the rotationId', () => { - const metrics = makeMetrics({ totalWallets: 1, successfullyRotated: 1, failedRotations: 0, errors: [] }) + const metrics = makeMetrics({ + totalWallets: 1, + successfullyRotated: 1, + failedRotations: 0, + errors: [], + }) const reportPath = saveDryRunReport(metrics, tmpDir) expect(reportPath).toMatch(/wallet-rotation-dry-run-.+\.txt$/) expect(fs.existsSync(reportPath)).toBe(true) @@ -129,13 +170,23 @@ describe('saveDryRunReport', () => { it('creates the output directory if it does not exist', () => { const nestedDir = path.join(tmpDir, 'nested', 'output') - const metrics = makeMetrics({ totalWallets: 0, successfullyRotated: 0, failedRotations: 0, errors: [] }) + const metrics = makeMetrics({ + totalWallets: 0, + successfullyRotated: 0, + failedRotations: 0, + errors: [], + }) saveDryRunReport(metrics, nestedDir) expect(fs.existsSync(nestedDir)).toBe(true) }) it('file content matches generateDryRunReport output', () => { - const metrics = makeMetrics({ totalWallets: 3, successfullyRotated: 3, failedRotations: 0, errors: [] }) + const metrics = makeMetrics({ + totalWallets: 3, + successfullyRotated: 3, + failedRotations: 0, + errors: [], + }) const reportPath = saveDryRunReport(metrics, tmpDir) const content = fs.readFileSync(reportPath, 'utf8') expect(content).toBe(generateDryRunReport(metrics)) diff --git a/tests/unit/services/webhookDispatcher.test.ts b/tests/unit/services/webhookDispatcher.test.ts index 17d440f..f909194 100644 --- a/tests/unit/services/webhookDispatcher.test.ts +++ b/tests/unit/services/webhookDispatcher.test.ts @@ -1,121 +1,125 @@ -import { dispatchWebhookEvent } from '../../../src/services/webhookDispatcher'; -import db from '../../../src/db'; +import { dispatchWebhookEvent } from '../../../src/services/webhookDispatcher' +import db from '../../../src/db' jest.mock('../../../src/db', () => ({ __esModule: true, default: {}, -})); +})) jest.mock('../../../src/utils/logger', () => ({ logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, -})); +})) -const mockDb = db as any; +const mockDb = db as any describe('webhookDispatcher', () => { beforeEach(() => { - jest.clearAllMocks(); + jest.clearAllMocks() // Default: no subscriptions mockDb.webhookSubscription = { findMany: jest.fn().mockResolvedValue([]), - }; + } mockDb.webhookDelivery = { create: jest.fn().mockResolvedValue({ id: 'delivery-1' }), update: jest.fn().mockResolvedValue({}), - }; + } // Reset global fetch mock - global.fetch = jest.fn(); - }); + global.fetch = jest.fn() + }) describe('dispatchWebhookEvent', () => { it('does nothing when there are no matching subscriptions', async () => { - mockDb.webhookSubscription.findMany.mockResolvedValue([]); - await dispatchWebhookEvent('deposit.received', { amount: '100' }); - expect(mockDb.webhookDelivery.create).not.toHaveBeenCalled(); - }); + mockDb.webhookSubscription.findMany.mockResolvedValue([]) + await dispatchWebhookEvent('deposit.received', { amount: '100' }) + expect(mockDb.webhookDelivery.create).not.toHaveBeenCalled() + }) it('creates a delivery record and marks it SUCCESS on first attempt', async () => { mockDb.webhookSubscription.findMany.mockResolvedValue([ { id: 'sub-1', url: 'https://example.com/wh', secret: 'mysecret' }, - ]); - (global.fetch as jest.Mock).mockResolvedValue({ ok: true, status: 200 }); + ]) + ;(global.fetch as jest.Mock).mockResolvedValue({ ok: true, status: 200 }) - await dispatchWebhookEvent('deposit.received', { amount: '100' }); + await dispatchWebhookEvent('deposit.received', { amount: '100' }) - expect(mockDb.webhookDelivery.create).toHaveBeenCalledTimes(1); + expect(mockDb.webhookDelivery.create).toHaveBeenCalledTimes(1) expect(mockDb.webhookDelivery.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'SUCCESS', attempts: 1 }), - }), - ); - }); + }) + ) + }) it('retries up to 3 times and marks FAILED after all attempts fail', async () => { mockDb.webhookSubscription.findMany.mockResolvedValue([ { id: 'sub-1', url: 'https://example.com/wh', secret: 'mysecret' }, - ]); - (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + ]) + ;(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')) // Patch setTimeout to avoid real delays in tests - jest.useFakeTimers(); - const dispatchPromise = dispatchWebhookEvent('deposit.received', { amount: '100' }); + jest.useFakeTimers() + const dispatchPromise = dispatchWebhookEvent('deposit.received', { + amount: '100', + }) // Advance through all exponential back-off delays (1s, 2s) - await jest.runAllTimersAsync(); - await dispatchPromise; - jest.useRealTimers(); + await jest.runAllTimersAsync() + await dispatchPromise + jest.useRealTimers() // fetch called 3 times (MAX_ATTEMPTS) - expect(global.fetch).toHaveBeenCalledTimes(3); + expect(global.fetch).toHaveBeenCalledTimes(3) expect(mockDb.webhookDelivery.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'FAILED', attempts: 3 }), - }), - ); - }); + }) + ) + }) it('succeeds on the second attempt after a transient failure', async () => { mockDb.webhookSubscription.findMany.mockResolvedValue([ { id: 'sub-1', url: 'https://example.com/wh', secret: 'mysecret' }, - ]); - (global.fetch as jest.Mock) + ]) + ;(global.fetch as jest.Mock) .mockRejectedValueOnce(new Error('timeout')) - .mockResolvedValue({ ok: true, status: 200 }); + .mockResolvedValue({ ok: true, status: 200 }) - jest.useFakeTimers(); - const dispatchPromise = dispatchWebhookEvent('deposit.received', { amount: '100' }); - await jest.runAllTimersAsync(); - await dispatchPromise; - jest.useRealTimers(); + jest.useFakeTimers() + const dispatchPromise = dispatchWebhookEvent('deposit.received', { + amount: '100', + }) + await jest.runAllTimersAsync() + await dispatchPromise + jest.useRealTimers() - expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenCalledTimes(2) expect(mockDb.webhookDelivery.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'SUCCESS', attempts: 2 }), - }), - ); - }); + }) + ) + }) it('sends X-Neurowealth-Signature header with sha256= prefix', async () => { mockDb.webhookSubscription.findMany.mockResolvedValue([ { id: 'sub-1', url: 'https://example.com/wh', secret: 'mysecret' }, - ]); - (global.fetch as jest.Mock).mockResolvedValue({ ok: true, status: 200 }); + ]) + ;(global.fetch as jest.Mock).mockResolvedValue({ ok: true, status: 200 }) - await dispatchWebhookEvent('agent.rebalanced', { protocol: 'anchor' }); + await dispatchWebhookEvent('agent.rebalanced', { protocol: 'anchor' }) - const [, options] = (global.fetch as jest.Mock).mock.calls[0]; - expect((options.headers as Record)['X-Neurowealth-Signature']).toMatch( - /^sha256=[0-9a-f]{64}$/, - ); - }); + const [, options] = (global.fetch as jest.Mock).mock.calls[0] + expect( + (options.headers as Record)['X-Neurowealth-Signature'] + ).toMatch(/^sha256=[0-9a-f]{64}$/) + }) it('queries subscriptions filtered by event type', async () => { - mockDb.webhookSubscription.findMany.mockResolvedValue([]); + mockDb.webhookSubscription.findMany.mockResolvedValue([]) - await dispatchWebhookEvent('agent.rebalanced', {}); + await dispatchWebhookEvent('agent.rebalanced', {}) expect(mockDb.webhookSubscription.findMany).toHaveBeenCalledWith({ where: { isActive: true, events: { has: 'agent.rebalanced' } }, - }); - }); - }); -}); + }) + }) + }) +}) diff --git a/tests/unit/stellar/network-config.test.ts b/tests/unit/stellar/network-config.test.ts index 8c8e468..925a98c 100644 --- a/tests/unit/stellar/network-config.test.ts +++ b/tests/unit/stellar/network-config.test.ts @@ -24,14 +24,24 @@ describe('Stellar Network Configuration', () => { }) it('should handle case-insensitive network names', () => { - expect(resolveNetworkPassphrase('TESTNET')).toBe('Test SDF Network ; September 2015') - expect(resolveNetworkPassphrase('Mainnet')).toBe('Public Global Stellar Network ; September 2015') + expect(resolveNetworkPassphrase('TESTNET')).toBe( + 'Test SDF Network ; September 2015' + ) + expect(resolveNetworkPassphrase('Mainnet')).toBe( + 'Public Global Stellar Network ; September 2015' + ) }) it('should throw for unknown network', () => { - expect(() => resolveNetworkPassphrase('unknown')).toThrow('Unknown STELLAR_NETWORK') - expect(() => resolveNetworkPassphrase('devnet')).toThrow('Unknown STELLAR_NETWORK') - expect(() => resolveNetworkPassphrase(undefined)).toThrow('Unknown STELLAR_NETWORK') + expect(() => resolveNetworkPassphrase('unknown')).toThrow( + 'Unknown STELLAR_NETWORK' + ) + expect(() => resolveNetworkPassphrase('devnet')).toThrow( + 'Unknown STELLAR_NETWORK' + ) + expect(() => resolveNetworkPassphrase(undefined)).toThrow( + 'Unknown STELLAR_NETWORK' + ) }) }) @@ -43,7 +53,7 @@ describe('Stellar Network Configuration', () => { }) it('should have valid HTTPS URLs', () => { - Object.values(STELLAR_EXPLORER_URLS).forEach(url => { + Object.values(STELLAR_EXPLORER_URLS).forEach((url) => { expect(url).toMatch(/^https:\/\/stellar\.expert/) }) }) @@ -63,6 +73,8 @@ describe('Stellar Network Configuration', () => { it('should derive testnet RPC URL when not explicitly set', () => { process.env.STELLAR_NETWORK = 'testnet' + // CI's job env sets STELLAR_RPC_URL — clear it so derivation is exercised + delete process.env.STELLAR_RPC_URL process.env.STELLAR_AGENT_SECRET_KEY = 'S' + 'A'.repeat(55) process.env.VAULT_CONTRACT_ID = 'C' + 'B'.repeat(55) process.env.USDC_TOKEN_ADDRESS = 'C' + 'C'.repeat(55) @@ -98,7 +110,9 @@ describe('Stellar Network Configuration', () => { process.env.STELLAR_NETWORK = 'invalid' process.env.STELLAR_AGENT_SECRET_KEY = 'S' + 'A'.repeat(55) - expect(() => require('../../../src/config/env')).toThrow('Invalid STELLAR_NETWORK') + expect(() => require('../../../src/config/env')).toThrow( + 'Invalid STELLAR_NETWORK' + ) }) }) }) diff --git a/tests/unit/tax/fifo.test.ts b/tests/unit/tax/fifo.test.ts new file mode 100644 index 0000000..076e9e5 --- /dev/null +++ b/tests/unit/tax/fifo.test.ts @@ -0,0 +1,140 @@ +// Pure FIFO engine tests (#284). Pin the money invariants: strict FIFO order +// with a stable id tiebreak, all-or-nothing on shortfall (throws BEFORE any +// instruction is produced), remainingAmount never negative, and unpriced lots +// propagate null (never zero) into costBasis/realizedGain. +import { Decimal } from '@prisma/client/runtime/library' +import { + consumeLotsFifo, + InsufficientLotsError, + OpenLot, +} from '../../../src/tax/fifo' + +const d = (v: string | number) => new Decimal(v) + +function lot( + id: string, + remaining: string | number, + acquiredAt: string, + price: string | number | null = 1 +): OpenLot { + return { + id, + remainingAmount: d(remaining), + acquisitionPrice: price === null ? null : d(price), + acquiredAt: new Date(acquiredAt), + } +} + +describe('consumeLotsFifo', () => { + it('consumes a single lot exactly (lot hits zero)', () => { + const result = consumeLotsFifo([lot('a', 100, '2026-01-01')], d(100), d(1)) + + expect(result.disposals).toHaveLength(1) + expect(result.disposals[0].amount.toString()).toBe('100') + expect(result.updatedLots).toEqual([{ id: 'a', remainingAmount: d(0) }]) + }) + + it('partially consumes a single lot', () => { + const result = consumeLotsFifo([lot('a', 100, '2026-01-01')], d(30), d(1)) + + expect(result.disposals[0].amount.toString()).toBe('30') + expect(result.updatedLots[0].remainingAmount.toString()).toBe('70') + }) + + it('consumes multiple lots oldest-first regardless of input order', () => { + const result = consumeLotsFifo( + [lot('newer', 50, '2026-02-01'), lot('older', 40, '2026-01-01')], + d(60), + d(1) + ) + + expect(result.disposals.map((x) => x.lotId)).toEqual(['older', 'newer']) + expect(result.disposals[0].amount.toString()).toBe('40') + expect(result.disposals[1].amount.toString()).toBe('20') + }) + + it('breaks acquiredAt ties by id', () => { + const result = consumeLotsFifo( + [lot('b', 10, '2026-01-01'), lot('a', 10, '2026-01-01')], + d(15), + d(1) + ) + + expect(result.disposals.map((x) => x.lotId)).toEqual(['a', 'b']) + }) + + it('exact multi-lot boundary: both lots hit zero', () => { + const result = consumeLotsFifo( + [lot('a', 25, '2026-01-01'), lot('b', 75, '2026-02-01')], + d(100), + d(1) + ) + + expect(result.updatedLots.every((l) => l.remainingAmount.isZero())).toBe( + true + ) + }) + + it('throws InsufficientLotsError with fields and zero instructions', () => { + let caught: InsufficientLotsError | undefined + try { + consumeLotsFifo([lot('a', 40, '2026-01-01')], d(100), d(1)) + } catch (err) { + caught = err as InsufficientLotsError + } + + expect(caught).toBeInstanceOf(InsufficientLotsError) + expect(caught!.requested.toString()).toBe('100') + expect(caught!.available.toString()).toBe('40') + expect(caught!.shortfall.toString()).toBe('60') + }) + + it('zero amount returns an empty result', () => { + const result = consumeLotsFifo([lot('a', 100, '2026-01-01')], d(0), d(1)) + + expect(result.disposals).toEqual([]) + expect(result.updatedLots).toEqual([]) + }) + + it('unpriced lot yields null costBasis/realizedGain but priced proceeds', () => { + const result = consumeLotsFifo( + [lot('a', 50, '2026-01-01', null)], + d(50), + d(1) + ) + + expect(result.disposals[0].costBasis).toBeNull() + expect(result.disposals[0].realizedGain).toBeNull() + expect(result.disposals[0].proceeds!.toString()).toBe('50') + }) + + it('null disposalPrice yields null proceeds/realizedGain but priced costBasis', () => { + const result = consumeLotsFifo([lot('a', 50, '2026-01-01', 2)], d(50), null) + + expect(result.disposals[0].proceeds).toBeNull() + expect(result.disposals[0].realizedGain).toBeNull() + expect(result.disposals[0].costBasis!.toString()).toBe('100') + }) + + it('skips zero-remaining lots', () => { + const result = consumeLotsFifo( + [lot('empty', 0, '2026-01-01'), lot('open', 30, '2026-02-01')], + d(30), + d(1) + ) + + expect(result.disposals.map((x) => x.lotId)).toEqual(['open']) + }) + + it('computes realizedGain = proceeds - costBasis', () => { + const result = consumeLotsFifo( + [lot('a', 10, '2026-01-01', '0.5')], + d(10), + d(2) + ) + + expect(result.disposals[0].costBasis!.toString()).toBe('5') + expect(result.disposals[0].proceeds!.toString()).toBe('20') + expect(result.disposals[0].realizedGain!.toString()).toBe('15') + }) +}) diff --git a/tests/unit/tax/report.test.ts b/tests/unit/tax/report.test.ts new file mode 100644 index 0000000..37583ae --- /dev/null +++ b/tests/unit/tax/report.test.ts @@ -0,0 +1,143 @@ +// Tax report assembly tests (#284): totals include only fully priced +// disposals (unpriced flagged in caveats, never zeroed), UTC year bounds on +// disposedAt, and an empty year is still a valid report. +import db from '../../../src/db' +import { + buildTaxReport, + taxReportToCsvRows, + TAX_REPORT_CSV_HEADERS, +} from '../../../src/tax/report' + +jest.mock('../../../src/db', () => ({ __esModule: true, default: {} })) +jest.mock('../../../src/utils/logger', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})) + +const mockDb = db as any + +function disposalRow(overrides: Record = {}) { + return { + disposedAt: new Date('2026-06-15T12:00:00Z'), + assetSymbol: 'USDC', + amount: '40', + disposalPrice: '1', + costBasis: '40', + proceeds: '40', + realizedGain: '0', + transaction: { txHash: 'withdraw-hash' }, + lot: { + acquiredAt: new Date('2026-01-15T00:00:00Z'), + acquisitionPrice: '1', + transaction: { txHash: 'deposit-hash' }, + }, + ...overrides, + } +} + +beforeEach(() => { + jest.clearAllMocks() + mockDb.lotDisposal = { findMany: jest.fn() } +}) + +describe('buildTaxReport', () => { + it('returns a valid empty report for a year with no activity', async () => { + mockDb.lotDisposal.findMany.mockResolvedValue([]) + + const report = await buildTaxReport('user-1', 2026) + + expect(report).toMatchObject({ + userId: 'user-1', + year: 2026, + method: 'FIFO', + disposals: [], + totals: { + proceeds: '0', + costBasis: '0', + realizedGain: '0', + pricedDisposalCount: 0, + }, + }) + expect(report.caveats.unpricedDisposalCount).toBe(0) + }) + + it('queries with UTC year boundaries on disposedAt', async () => { + mockDb.lotDisposal.findMany.mockResolvedValue([]) + + await buildTaxReport('user-1', 2026) + + const where = mockDb.lotDisposal.findMany.mock.calls[0][0].where + expect(where.userId).toBe('user-1') + expect(where.disposedAt.gte.toISOString()).toBe('2026-01-01T00:00:00.000Z') + expect(where.disposedAt.lt.toISOString()).toBe('2027-01-01T00:00:00.000Z') + }) + + it('totals include only fully priced disposals; unpriced are flagged', async () => { + mockDb.lotDisposal.findMany.mockResolvedValue([ + disposalRow({ proceeds: '40', costBasis: '30', realizedGain: '10' }), + disposalRow({ + assetSymbol: 'XLM', + disposalPrice: null, + costBasis: null, + proceeds: null, + realizedGain: null, + lot: { + acquiredAt: new Date('2026-02-01T00:00:00Z'), + acquisitionPrice: null, + transaction: { txHash: 'xlm-deposit-hash' }, + }, + }), + ]) + + const report = await buildTaxReport('user-1', 2026) + + expect(report.totals).toEqual({ + proceeds: '40', + costBasis: '30', + realizedGain: '10', + pricedDisposalCount: 1, + }) + expect(report.caveats.unpricedDisposalCount).toBe(1) + expect(report.caveats.unpricedAssets).toEqual(['XLM']) + expect(report.disposals[0].priced).toBe(true) + expect(report.disposals[1].priced).toBe(false) + expect(report.disposals[1].realizedGain).toBeNull() + }) + + it('reports a year-spanning lot by disposal year, keeping acquisition info', async () => { + mockDb.lotDisposal.findMany.mockResolvedValue([ + disposalRow({ + disposedAt: new Date('2027-03-01T00:00:00Z'), + lot: { + acquiredAt: new Date('2026-11-01T00:00:00Z'), + acquisitionPrice: '1', + transaction: { txHash: 'deposit-hash' }, + }, + }), + ]) + + const report = await buildTaxReport('user-1', 2027) + + expect(report.disposals[0].acquiredAt).toBe('2026-11-01T00:00:00.000Z') + expect(report.disposals[0].acquisitionTxHash).toBe('deposit-hash') + expect(report.disposals[0].withdrawalTxHash).toBe('withdraw-hash') + }) +}) + +describe('taxReportToCsvRows', () => { + it('produces one row per disposal aligned with the headers', async () => { + mockDb.lotDisposal.findMany.mockResolvedValue([disposalRow()]) + + const report = await buildTaxReport('user-1', 2026) + const rows = taxReportToCsvRows(report) + + expect(rows).toHaveLength(1) + expect(rows[0]).toHaveLength(TAX_REPORT_CSV_HEADERS.length) + expect(rows[0][TAX_REPORT_CSV_HEADERS.indexOf('amount')]).toBe('40') + expect(rows[0][TAX_REPORT_CSV_HEADERS.indexOf('priced')]).toBe(true) + }) +}) diff --git a/tests/unit/tax/service.test.ts b/tests/unit/tax/service.test.ts new file mode 100644 index 0000000..21048a6 --- /dev/null +++ b/tests/unit/tax/service.test.ts @@ -0,0 +1,254 @@ +// Tax lot bookkeeping unit tests (#284). Pin the high-risk invariants: +// * lot creation is idempotent (P2002 replay is quiet) and NEVER throws — +// a tax problem must not roll back a confirmed deposit +// * disposal recording is idempotent via the exists-check +// * a shortfall writes NOTHING (all-or-nothing), alerts critically, and +// returns normally so the withdrawal is unaffected +import db from '../../../src/db' +import { alertingService } from '../../../src/services/alerting' +import { + createLotForDeposit, + recordDisposalsForWithdrawal, +} from '../../../src/tax/service' +import { logger } from '../../../src/utils/logger' + +jest.mock('../../../src/db', () => ({ __esModule: true, default: {} })) +jest.mock('../../../src/utils/logger', () => ({ + logger: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})) +jest.mock('../../../src/services/alerting', () => ({ + alertingService: { emit: jest.fn().mockResolvedValue({ sent: true }) }, +})) + +jest.mock('@prisma/client', () => { + const actual = jest.requireActual('@prisma/client') + return { + ...actual, + Prisma: { + ...actual.Prisma, + PrismaClientKnownRequestError: class extends Error { + code: string + constructor(msg: string, opts: { code: string }) { + super(msg) + this.code = opts.code + } + }, + }, + } +}) + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { Prisma } = require('@prisma/client') +function uniqueViolation(): Error { + return new Prisma.PrismaClientKnownRequestError('unique', { code: 'P2002' }) +} + +const mockDb = db as any +const mockEmit = alertingService.emit as jest.Mock +const mockError = logger.error as jest.Mock + +const acquiredAt = new Date('2026-01-15T00:00:00Z') +const disposedAt = new Date('2026-06-15T00:00:00Z') + +beforeEach(() => { + jest.clearAllMocks() + mockEmit.mockResolvedValue({ sent: true }) + mockDb.costBasisLot = { + create: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + } + mockDb.lotDisposal = { + findFirst: jest.fn(), + create: jest.fn(), + } +}) + +describe('createLotForDeposit', () => { + it('creates a priced lot for USDC with remaining = original', async () => { + mockDb.costBasisLot.create.mockResolvedValue({ id: 'lot-1' }) + + await createLotForDeposit('user-1', 'tx-1', 'USDC', '100', acquiredAt) + + const arg = mockDb.costBasisLot.create.mock.calls[0][0] + expect(arg.data.transactionId).toBe('tx-1') + expect(arg.data.originalAmount.toString()).toBe('100') + expect(arg.data.remainingAmount.toString()).toBe('100') + expect(arg.data.acquisitionPrice.toString()).toBe('1') + expect(arg.data.priceSource).toBe('STABLECOIN_ASSUMPTION') + }) + + it('creates an unpriced lot (null price, null source) for non-USDC assets', async () => { + mockDb.costBasisLot.create.mockResolvedValue({ id: 'lot-1' }) + + await createLotForDeposit('user-1', 'tx-1', 'XLM', '100', acquiredAt) + + const arg = mockDb.costBasisLot.create.mock.calls[0][0] + expect(arg.data.acquisitionPrice).toBeNull() + expect(arg.data.priceSource).toBeNull() + }) + + it('treats P2002 as a benign replay: no error log, no alert, no throw', async () => { + mockDb.costBasisLot.create.mockRejectedValue(uniqueViolation()) + + await expect( + createLotForDeposit('user-1', 'tx-1', 'USDC', '100', acquiredAt) + ).resolves.toBeUndefined() + + expect(mockError).not.toHaveBeenCalled() + expect(mockEmit).not.toHaveBeenCalled() + }) + + it('swallows generic errors but logs and alerts', async () => { + mockDb.costBasisLot.create.mockRejectedValue(new Error('db down')) + + await expect( + createLotForDeposit('user-1', 'tx-1', 'USDC', '100', acquiredAt) + ).resolves.toBeUndefined() + + expect(mockError).toHaveBeenCalled() + expect(mockEmit).toHaveBeenCalledTimes(1) + expect(mockEmit.mock.calls[0][0].severity).toBe('warning') + }) + + it('uses the provided database handle (transaction client)', async () => { + const tx = { costBasisLot: { create: jest.fn().mockResolvedValue({}) } } + + await createLotForDeposit( + 'user-1', + 'tx-1', + 'USDC', + '100', + acquiredAt, + tx as any + ) + + expect(tx.costBasisLot.create).toHaveBeenCalled() + expect(mockDb.costBasisLot.create).not.toHaveBeenCalled() + }) +}) + +describe('recordDisposalsForWithdrawal', () => { + it('records FIFO disposals and decrements lots', async () => { + mockDb.lotDisposal.findFirst.mockResolvedValue(null) + mockDb.costBasisLot.findMany.mockResolvedValue([ + { + id: 'lot-old', + remainingAmount: '40', + acquisitionPrice: '1', + acquiredAt: new Date('2026-01-01T00:00:00Z'), + }, + { + id: 'lot-new', + remainingAmount: '100', + acquisitionPrice: '1', + acquiredAt: new Date('2026-02-01T00:00:00Z'), + }, + ]) + mockDb.costBasisLot.update.mockResolvedValue({}) + mockDb.lotDisposal.create.mockResolvedValue({}) + + await recordDisposalsForWithdrawal( + 'user-1', + 'wtx-1', + 'USDC', + '60', + disposedAt + ) + + expect(mockDb.costBasisLot.update).toHaveBeenCalledTimes(2) + expect(mockDb.lotDisposal.create).toHaveBeenCalledTimes(2) + const first = mockDb.lotDisposal.create.mock.calls[0][0].data + expect(first.lotId).toBe('lot-old') + expect(first.amount.toString()).toBe('40') + expect(first.realizedGain.toString()).toBe('0') + const second = mockDb.lotDisposal.create.mock.calls[1][0].data + expect(second.lotId).toBe('lot-new') + expect(second.amount.toString()).toBe('20') + }) + + it('skips silently when disposals already exist (idempotent replay)', async () => { + mockDb.lotDisposal.findFirst.mockResolvedValue({ id: 'existing' }) + + await recordDisposalsForWithdrawal( + 'user-1', + 'wtx-1', + 'USDC', + '60', + disposedAt + ) + + expect(mockDb.costBasisLot.findMany).not.toHaveBeenCalled() + expect(mockDb.lotDisposal.create).not.toHaveBeenCalled() + }) + + it('writes NOTHING on insufficient lots, alerts critically, does not throw', async () => { + mockDb.lotDisposal.findFirst.mockResolvedValue(null) + mockDb.costBasisLot.findMany.mockResolvedValue([ + { + id: 'lot-1', + remainingAmount: '10', + acquisitionPrice: '1', + acquiredAt: new Date('2026-01-01T00:00:00Z'), + }, + ]) + + await expect( + recordDisposalsForWithdrawal('user-1', 'wtx-1', 'USDC', '60', disposedAt) + ).resolves.toBeUndefined() + + expect(mockDb.costBasisLot.update).not.toHaveBeenCalled() + expect(mockDb.lotDisposal.create).not.toHaveBeenCalled() + expect(mockError).toHaveBeenCalled() + const logMeta = mockError.mock.calls[0][1] + expect(logMeta.requested).toBe('60') + expect(logMeta.available).toBe('10') + expect(logMeta.shortfall).toBe('50') + expect(mockEmit).toHaveBeenCalledTimes(1) + expect(mockEmit.mock.calls[0][0].severity).toBe('critical') + }) + + it('swallows generic db errors with an alert (withdrawal unaffected)', async () => { + mockDb.lotDisposal.findFirst.mockRejectedValue(new Error('db down')) + + await expect( + recordDisposalsForWithdrawal('user-1', 'wtx-1', 'USDC', '60', disposedAt) + ).resolves.toBeUndefined() + + expect(mockError).toHaveBeenCalled() + expect(mockEmit).toHaveBeenCalledTimes(1) + }) + + it('propagates null cost basis for unpriced assets (never zero)', async () => { + mockDb.lotDisposal.findFirst.mockResolvedValue(null) + mockDb.costBasisLot.findMany.mockResolvedValue([ + { + id: 'lot-1', + remainingAmount: '50', + acquisitionPrice: null, + acquiredAt: new Date('2026-01-01T00:00:00Z'), + }, + ]) + mockDb.costBasisLot.update.mockResolvedValue({}) + mockDb.lotDisposal.create.mockResolvedValue({}) + + await recordDisposalsForWithdrawal( + 'user-1', + 'wtx-1', + 'XLM', + '50', + disposedAt + ) + + const data = mockDb.lotDisposal.create.mock.calls[0][0].data + expect(data.costBasis).toBeNull() + expect(data.realizedGain).toBeNull() + expect(data.disposalPrice).toBeNull() + expect(data.proceeds).toBeNull() + }) +}) diff --git a/tests/unit/utils/csv.test.ts b/tests/unit/utils/csv.test.ts new file mode 100644 index 0000000..dea37f8 --- /dev/null +++ b/tests/unit/utils/csv.test.ts @@ -0,0 +1,80 @@ +// CSV utility tests (#284): RFC 4180 quoting + spreadsheet formula-injection +// guarding. The injection guard is security-relevant — exported reports are +// opened in Excel/Sheets, so cells must never execute as formulas. +import { escapeCsvField, toCsv } from '../../../src/utils/csv' + +describe('escapeCsvField', () => { + it('returns empty string for null and undefined', () => { + expect(escapeCsvField(null)).toBe('') + expect(escapeCsvField(undefined)).toBe('') + }) + + it('passes plain strings through unchanged', () => { + expect(escapeCsvField('hello')).toBe('hello') + }) + + it('stringifies numbers and booleans', () => { + expect(escapeCsvField(42)).toBe('42') + expect(escapeCsvField(true)).toBe('true') + }) + + it('quotes fields containing commas', () => { + expect(escapeCsvField('a,b')).toBe('"a,b"') + }) + + it('doubles internal quotes and wraps', () => { + expect(escapeCsvField('say "hi"')).toBe('"say ""hi"""') + }) + + it('quotes fields containing newlines and carriage returns', () => { + expect(escapeCsvField('a\nb')).toBe('"a\nb"') + expect(escapeCsvField('a\rb')).toBe('"a\rb"') + }) + + it.each([ + ['=cmd()', "'=cmd()"], + ['+1', "'+1"], + ['-1', "'-1"], + ['@x', "'@x"], + ])('prefixes injection vector %s with a quote', (input, expected) => { + expect(escapeCsvField(input)).toBe(expected) + }) + + it('guards a leading tab', () => { + expect(escapeCsvField('\tx')).toBe("'\tx") + }) + + it('guards a leading CR and then quotes (CR also triggers quoting)', () => { + expect(escapeCsvField('\rx')).toBe('"\'\rx"') + }) + + it('quotes an injection vector that also contains a comma', () => { + expect(escapeCsvField('=1,2')).toBe('"\'=1,2"') + }) +}) + +describe('toCsv', () => { + it('joins header and rows with CRLF', () => { + const csv = toCsv( + ['a', 'b'], + [ + ['1', '2'], + ['3', '4'], + ] + ) + expect(csv).toBe('a,b\r\n1,2\r\n3,4') + }) + + it('escapes header and cell values', () => { + const csv = toCsv(['=h'], [['=v']]) + expect(csv).toBe("'=h\r\n'=v") + }) + + it('renders empty rows as just the header', () => { + expect(toCsv(['a', 'b'], [])).toBe('a,b') + }) + + it('renders null cells as empty fields', () => { + expect(toCsv(['a', 'b'], [[null, 'x']])).toBe('a,b\r\n,x') + }) +}) diff --git a/tests/unit/utils/http-client.test.ts b/tests/unit/utils/http-client.test.ts index e1097d9..f5ad1a9 100644 --- a/tests/unit/utils/http-client.test.ts +++ b/tests/unit/utils/http-client.test.ts @@ -26,7 +26,9 @@ describe('HttpClientAdapter', () => { it('should throw on a failing function', async () => { await expect( - adapter.execute(async () => { throw new Error('fail') }) + adapter.execute(async () => { + throw new Error('fail') + }) ).rejects.toThrow('fail') }) @@ -80,7 +82,7 @@ describe('HttpClientAdapter', () => { await expect( slowAdapter.execute(async () => { - await new Promise(r => setTimeout(r, 200)) + await new Promise((r) => setTimeout(r, 200)) return 'too late' }) ).rejects.toThrow(TimeoutError) @@ -196,10 +198,10 @@ describe('HttpClientAdapter', () => { }) await expect( - fastTimeoutAdapter.execute( - async () => { await new Promise(r => setTimeout(r, 100)); return 'x' }, - 'myService.myMethod', - ) + fastTimeoutAdapter.execute(async () => { + await new Promise((r) => setTimeout(r, 100)) + return 'x' + }, 'myService.myMethod') ).rejects.toThrow(/myService\.myMethod/) }) @@ -210,9 +212,9 @@ describe('HttpClientAdapter', () => { await expect(adapter.execute(fn)).rejects.toThrow() } - await expect( - adapter.execute(fn, 'myService.myMethod') - ).rejects.toThrow(/myService\.myMethod/) + await expect(adapter.execute(fn, 'myService.myMethod')).rejects.toThrow( + /myService\.myMethod/ + ) }) }) }) diff --git a/tests/unit/utils/webhookSignature.test.ts b/tests/unit/utils/webhookSignature.test.ts index 0ab703f..d9565c4 100644 --- a/tests/unit/utils/webhookSignature.test.ts +++ b/tests/unit/utils/webhookSignature.test.ts @@ -1,42 +1,52 @@ -import { createHmac } from 'crypto'; -import { generateWebhookSecret, signPayload } from '../../../src/utils/webhookSignature'; +import { createHmac } from 'crypto' +import { + generateWebhookSecret, + signPayload, +} from '../../../src/utils/webhookSignature' describe('webhookSignature', () => { describe('generateWebhookSecret', () => { it('returns a 64-character hex string', () => { - const secret = generateWebhookSecret(); - expect(secret).toMatch(/^[0-9a-f]{64}$/); - }); + const secret = generateWebhookSecret() + expect(secret).toMatch(/^[0-9a-f]{64}$/) + }) it('returns a unique value each call', () => { - expect(generateWebhookSecret()).not.toBe(generateWebhookSecret()); - }); - }); + expect(generateWebhookSecret()).not.toBe(generateWebhookSecret()) + }) + }) describe('signPayload', () => { - const secret = 'test-secret'; - const payload = JSON.stringify({ event: 'deposit.received', data: { amount: '100' } }); + const secret = 'test-secret' + const payload = JSON.stringify({ + event: 'deposit.received', + data: { amount: '100' }, + }) it('returns a sha256= prefixed hex digest', () => { - const sig = signPayload(secret, payload); - expect(sig).toMatch(/^sha256=[0-9a-f]{64}$/); - }); + const sig = signPayload(secret, payload) + expect(sig).toMatch(/^sha256=[0-9a-f]{64}$/) + }) it('matches a manually computed HMAC-SHA256', () => { - const expected = `sha256=${createHmac('sha256', secret).update(payload).digest('hex')}`; - expect(signPayload(secret, payload)).toBe(expected); - }); + const expected = `sha256=${createHmac('sha256', secret).update(payload).digest('hex')}` + expect(signPayload(secret, payload)).toBe(expected) + }) it('produces different signatures for different secrets', () => { - expect(signPayload('secret-a', payload)).not.toBe(signPayload('secret-b', payload)); - }); + expect(signPayload('secret-a', payload)).not.toBe( + signPayload('secret-b', payload) + ) + }) it('produces different signatures for different payloads', () => { - expect(signPayload(secret, 'payload-a')).not.toBe(signPayload(secret, 'payload-b')); - }); + expect(signPayload(secret, 'payload-a')).not.toBe( + signPayload(secret, 'payload-b') + ) + }) it('is deterministic for the same inputs', () => { - expect(signPayload(secret, payload)).toBe(signPayload(secret, payload)); - }); - }); -}); + expect(signPayload(secret, payload)).toBe(signPayload(secret, payload)) + }) + }) +}) diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..55a6b7c --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src" + }, + "files": ["src/types/express.d.ts"], + "include": ["src/**/*"] +}