Skip to content

Fix/rabbitmq schema validation graphql introspection#557

Merged
Junman140 merged 32 commits into
Pi-Defi-world:devfrom
Oluwaseyi89:fix/rabbitmq-schema-validation-graphql-introspection
Jun 27, 2026
Merged

Fix/rabbitmq schema validation graphql introspection#557
Junman140 merged 32 commits into
Pi-Defi-world:devfrom
Oluwaseyi89:fix/rabbitmq-schema-validation-graphql-introspection

Conversation

@Oluwaseyi89

@Oluwaseyi89 Oluwaseyi89 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Add schema validation for RabbitMQ messages and block GraphQL introspection

Overview

This PR addresses two security and reliability concerns:

  1. Adds Zod schema validation for all RabbitMQ messages to prevent malformed messages from blocking queues
  2. Adds defensive middleware to block GraphQL introspection attempts in production

Changes

RabbitMQ Schema Validation

  • Add Zod schemas for all message types (escrow, lending pool, savings vault, audit logs, OTP, notifications, webhooks)
  • Create validation utilities with DLQ support for invalid messages
  • Implement BaseProducer class with built-in validation
  • Update consumers to validate incoming messages before processing
  • Add message versioning with unique IDs

GraphQL Introspection Blocking

  • Add middleware to detect and block common GraphQL paths
  • Block introspection keywords in request bodies and query parameters
  • Return 404 for GraphQL paths in production
  • Log all blocked attempts for security monitoring

Files Added

  • src/types/rabbitmq-schemas.ts
  • src/utils/rabbitmq-validation.ts
  • src/jobs/producers/

Files Modified

  • src/index.ts
  • package.json
  • src/jobs/acbu_escrow_event_listener.ts
  • src/jobs/acbu_lending_pool_event_listener.ts
  • src/jobs/acbu_savings_vault_event_listener.ts
  • src/jobs/auditConsumer.ts
  • src/jobs/notificationConsumer.ts
  • src/jobs/webhookConsumer.ts

Dependencies Added

  • zod: ^3.25.76
  • uuid: ^14.0.1

Closes #399
Closes #400

Summary by CodeRabbit

  • New Features
    • Added timezone-aware business scheduling and withdrawal date handling.
    • Introduced wallet state, ETag-based updates, and scoped pagination for safer navigation.
    • Added Redis-backed caching, SMTP email support, and improved background message processing.
  • Bug Fixes
    • Tightened JWT validation and request content-length checks.
    • Improved transfer and wallet concurrency handling to reduce stale updates.
    • Added safer database startup checks and retry behavior.
  • Documentation
    • Updated setup and pull request guidance with clearer configuration examples.

Creative-Titilayo and others added 30 commits June 23, 2026 14:04
…/P99

Replace average-based latency tracking with histogram percentile buckets
so sparse high-latency outliers remain visible in monitoring.

Closes Pi-Defi-world#413
Ensure pooled SMTP connections are closed after batch and single sends,
with pool metrics and leak detection to prevent connection exhaustion.

Closes Pi-Defi-world#423
Require typ JWT on verify and sign paths so OAuth access tokens (e.g.
at+JWT) from the same issuer cannot be accepted as application JWTs.

Closes Pi-Defi-world#416
…lover

Reconnect and resend failed commands when a promoted replica briefly
returns READONLY, preventing cache write failures until failover completes.

Closes Pi-Defi-world#412
fix: patch Express 4 router with express-async-errors (Pi-Defi-world#420)
…odemailer-transport-pool-leak

fix(email): close nodemailer SMTP transport pool after batch sends
…wt-typ-header-validation

fix(auth): validate JWT typ header to prevent token confusion
…adonly-failover-retry

fix(cache): retry Redis writes on READONLY errors during Sentinel failover
…pi-response-time-histogram

feat(metrics): add per-endpoint response time histograms with P50/P95/P99
- Add PG_WAL_BACKUP_CONFIGURED and PG_WAL_BACKUP_PROVIDER env vars to env.ts
- connectWithRetry() throws in production when WAL backup is not configured
- Log backup provider at startup when configured; warn when absent
- Add walBackup guard tests (walBackup.test.ts)
- Document vars in .env.example
…t-length-validation

fix(Pi-Defi-world#449): validate Content-Length against actual body size
…icts and missing mocks

- Remove duplicate bulkTransfer key in env.ts (TS1117 compile error)
- Resolve merge conflict in src/index.ts (remove duplicate AppError import,
  add execSync import, keep express-async-errors)
- Add CORS_ORIGIN to test REQUIRED_ENV (production guard requires it)
- Add $extends mock to PrismaClient mock (needed when PRISMA_ACCELERATE_URL set)
- Extract shared mockPrismaClient factory to avoid repetition
… logger

Set independent console and file transport levels so debug output from
stellar-sdk and app code no longer floods production log aggregators.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add exchangeRateService with separate positive/negative TTLs so 5xx,
timeout, and network failures are not retried on every request.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Add dateUtils with Intl-based timezone handling and wire salary schedules,
withdrawal timing, savings lock dates, and limit windows to BUSINESS_TIMEZONE
instead of hardcoded UTC or server-local offsets.

Co-authored-by: Cursor <cursoragent@cursor.com>
…wallet ops

Track walletVersion on users, expose ETag on wallet/balance reads, and
require If-Match for wallet mutations and transfers to prevent lost updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
…-guard

fix(Pi-Defi-world#381): WAL backup guard — fix test failures from merge conflicts and missing mocks
…ransport-levels

fix(Pi-Defi-world#398): configure per-transport log levels in Winston logger
…e-negative-cache

fix(Pi-Defi-world#404): cache negative exchange rate API responses
…e-utils

fix(Pi-Defi-world#408): use IANA timezone-aware business date utilities
…concurrency

fix(Pi-Defi-world#407): add If-Match ETag optimistic concurrency for wallet ops
This commit implements schema validation for all RabbitMQ message producers
and consumers to prevent malformed messages from accumulating in queues
and blocking subsequent processing.

Changes:
- Add Zod schemas for all message types (escrow, lending pool, savings vault,
  audit logs, OTP, notifications, webhooks)
- Create validation utilities with DLQ support for invalid messages
- Implement BaseProducer class with built-in validation
- Replace direct queue publishing with validated producers
- Update consumers to validate incoming messages before processing
- Add message envelope with versioning and unique message IDs
- Add dead letter support for validation failures

Files Added:
- src/types/rabbitmq-schemas.ts - Shared Zod schemas
- src/utils/rabbitmq-validation.ts - Validation utilities
- src/jobs/producers/ - Producer classes with validation

Files Modified:
- package.json - Add uuid and update zod
- src/jobs/acbu_escrow_event_listener.ts
- src/jobs/acbu_lending_pool_event_listener.ts
- src/jobs/acbu_savings_vault_event_listener.ts
- src/jobs/auditConsumer.ts
- src/jobs/notificationConsumer.ts
- src/jobs/webhookConsumer.ts

Dependencies:
- zod: ^3.25.76 - Schema validation
- uuid: ^14.0.1 - Message ID generation

Benefits:
- Prevents malformed messages from entering queues
- Invalid messages are sent to DLQ with clear error reasons
- Consistent validation across all producers and consumers
- Message versioning enables future schema evolution
- Improved error logging and observability

Closes Pi-Defi-world#399
…world#400)

Add defensive middleware to block GraphQL paths and introspection
attempts in production. Blocks common GraphQL endpoints, __schema/__type
keywords in request bodies and query params, with logging for monitoring.

Closes Pi-Defi-world#400
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Oluwaseyi89, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 30 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d81d7d6e-b55b-4344-94b6-251d95357016

📥 Commits

Reviewing files that changed from the base of the PR and between 6a593f7 and 412f8a2.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • .env.example
  • package.json
  • prisma/schema.prisma
  • src/config/database.ts
  • src/config/env.ts
  • src/config/logger.ts
  • src/controllers/mintController.ts
  • src/index.ts
  • src/jobs/auditConsumer.ts
  • src/jobs/notificationConsumer.ts
  • src/jobs/webhookConsumer.ts
📝 Walkthrough

Walkthrough

This PR adds wallet optimistic concurrency via ETags (new walletController, walletStateService, walletVersion DB column), scoped HMAC pagination cursors across transaction/transfer/investment endpoints, JWT typ-header validation to block confusion attacks, a RabbitMQ schema validation layer with BaseProducer and typed consumers, timezone-aware business calendar utilities propagated to savings/investment/limits/salary logic, a Redis Sentinel service, SMTP pooled email, an exchange rate caching service, OpenAI retry/fail-open logic, DB connect retry with WAL backup guard, per-endpoint latency histograms, GraphQL blocking middleware, and Content-Length validation.

Changes

Full Feature Set

Layer / File(s) Summary
Config, env schema, DB retry, WAL guard, logger levels
src/config/env.ts, src/config/database.ts, src/config/logger.ts, src/config/mongodb.ts, .env.example, package.json, tsconfig.build.json, tests/walBackup.test.ts, tests/logger.transportLevels.test.ts
envSchema gains Redis, SMTP, OpenAI fail-open, DB retry, WAL backup, and BUSINESS_TIMEZONE fields; connectWithRetry adds full-jitter exponential backoff and a production WAL-configured startup gate; logger derives per-transport levels from config overrides; MongoDB retry gets structured backoff logging.
Timezone-aware business calendar
src/utils/dateUtils.ts, src/utils/dateUtils.test.ts, src/config/savings.ts, src/config/investment.ts, src/services/limits/limitsService.ts, src/services/investment/withdrawalTimingService.ts, src/services/salary/salaryService.ts, src/controllers/savingsController.ts
New dateUtils module provides IANA timezone resolution, zonedDateTimeToUtc, zoned day/month boundaries, ISO date formatting, and midnight scheduling. All downstream business-day, withdrawal lock, limit window, and salary scheduling logic now passes through timezone-aware zoned calculations instead of UTC or local time.
JWT typ-header security
src/middleware/authMiddleware.ts, src/middleware/auth.ts, src/utils/jwt.ts, src/middleware/authMiddleware.test.ts, src/middleware/auth.test.ts, tests/jwt.clockskew.test.ts
New authMiddleware exports EXPECTED_JWT_TYP, getJwtHeader, assertJwtTypHeader, and verifyJwt. auth.ts uses complete JWT decode to reject tokens with missing or mismatched typ. signChallengeToken now stamps typ: "JWT" and verifyChallengeToken uses verifyJwt.
Scoped HMAC pagination cursors
src/middleware/pagination.ts, src/middleware/pagination.test.ts, src/controllers/transactionController.ts, src/controllers/transferController.ts, src/controllers/investmentController.ts
encodeCursor/decodeCursor wrap ${scope}:${id} in a base64url body plus truncated HMAC-SHA256 signature. All three controllers decode incoming cursors with user/org scope and encode next_cursor as opaque tokens, preventing IDOR-style cursor forgery.
RabbitMQ Zod schemas, validation, BaseProducer, consumer updates
src/types/rabbitmq-schemas.ts, src/utils/rabbitmq-validation.ts, src/jobs/producers/..., src/jobs/acbu_*_event_listener.ts, src/jobs/auditConsumer.ts, src/jobs/notificationConsumer.ts, src/jobs/webhookConsumer.ts
rabbitmq-schemas.ts defines Zod schemas for all queues mapped by QUEUE_SCHEMAS. rabbitmq-validation.ts adds MessageValidationError, validateMessage, publishValidatedMessage, parseIncomingMessage, deadLetterMessage. Four concrete producers extend BaseProducer. All consumers use typed parsing; validation failures dead-letter and ACK instead of nacking.
Redis Sentinel, SMTP email, exchange rate cache, OpenAI retry
src/services/cache/redisService.ts, src/services/email/emailService.ts, src/services/rates/exchangeRateService.ts, src/services/oracle/forexClient.ts, src/services/notification/notificationService.ts, src/services/ai/openaiGuard.ts, related tests and index files
RedisService wraps ioredis with READONLY failover detection and reconnect retry. emailService provides pooled SMTP transport with batch sending and leak detection. exchangeRateService caches forex rates with positive/negative TTLs and deduplicates concurrent requests; forexClient re-exports it. openaiGuard adds timeout, exponential retry, and configurable fail-open.
Wallet ETag concurrency, controller extraction
prisma/schema.prisma, prisma/migrations/..., src/utils/walletConcurrency.ts, src/services/wallet/walletStateService.ts, src/controllers/walletController.ts, src/controllers/userController.ts, src/services/wallet/walletService.ts, src/services/transfer/..., src/routes/userRoutes.ts, related tests
Adds walletVersion DB column. walletConcurrency.ts parses/formats ETag and asserts If-Match. walletStateService fetches Horizon balance with per-version caching and exposes reserveWalletVersion/updateWalletWithConcurrency (412 on mismatch). New walletController hosts all wallet endpoints with If-Match. Same handlers removed from userController. Transfer gains ifMatch option wired to reserveWalletVersion.
App entry point, metrics, GraphQL blocking, body validation, mint currency
src/index.ts, src/middleware/metrics.ts, src/middleware/bodyParser.ts, src/controllers/mintController.ts, src/controllers/mintController.test.ts, src/controllers/schemas.ts, misc shims/docs
blockGraphQLQueries middleware blocks introspection paths. validateContentLength checks Content-Length before body parsing. metrics.ts adds in-memory percentile histograms per endpoint backed by OpenTelemetry. Startup uses connectWithRetry. Mint deposit currency schema adds FORBIDDEN_DEPOSIT_CURRENCIES refinement.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant walletController
    participant walletStateService
    participant Horizon
    participant Prisma

    Client->>walletController: GET /wallet
    walletController->>walletStateService: getWalletState(userId)
    walletStateService->>Prisma: findUnique(stellarAddress, walletVersion)
    Prisma-->>walletStateService: {stellarAddress, walletVersion: 3}
    walletStateService->>Horizon: loadAccount(stellarAddress)
    Horizon-->>walletStateService: account balances
    walletStateService-->>walletController: {balance, wallet_version: 3}
    walletController-->>Client: 200 ETag:"3" + wallet state

    Client->>walletController: PUT /wallet/address (If-Match:"3")
    walletController->>walletStateService: updateWalletWithConcurrency(userId, "3", data)
    walletStateService->>Prisma: updateMany where walletVersion=3, increment
    alt version matched
        Prisma-->>walletStateService: {count: 1}
        walletStateService-->>walletController: newVersion=4
        walletController-->>Client: 200 ETag:"4"
    else concurrent update
        Prisma-->>walletStateService: {count: 0}
        walletController-->>Client: 412 PRECONDITION_FAILED
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

  • #399 (No schema validation on RabbitMQ message bodies before publishing): Directly addressed — rabbitmq-schemas.ts + rabbitmq-validation.ts + BaseProducer add Zod validation on every outgoing message and dead-letter invalid incoming messages.
  • #400 (GraphQL playground/introspection enabled in production): Directly addressed — blockGraphQLQueries middleware in index.ts inspects path, body, and query string to block all GraphQL endpoints and introspection patterns, plus a production 404 route.

Possibly related PRs

  • Pi-Defi-world/acbu-backend#481: Overlaps directly on connectWithRetry with full-jitter retry in database.ts and scoped HMAC cursor pagination in pagination.ts plus controller integrations.
  • Pi-Defi-world/acbu-backend#555: Overlaps on database.ts connectWithRetry changes and index.ts webhook raw-body handling for signature verification.
  • Pi-Defi-world/acbu-backend#337: Overlaps directly on openaiGuard.ts timeout, retry, and configurable fail-open behavior wired through config.openai.*.

Poem

🐇 Hops through the code with a careful paw,
ETag headers enforced by law,
Cursors now signed so no one can peek,
RabbitMQ schemas make validation sleek.
Timezones resolved, no more UTC guessing—
This bunny approves, the diff is a blessing! 🌟

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also includes unrelated wallet, Redis, SMTP, timezone, and scheduling changes beyond the RabbitMQ/GraphQL objectives. Split unrelated work into separate PRs and keep this one focused on RabbitMQ validation and GraphQL introspection blocking.
Docstring Coverage ⚠️ Warning Docstring coverage is 44.87% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main changes: RabbitMQ schema validation and GraphQL introspection blocking.
Description check ✅ Passed The description covers the problem, changes, files, and linked issues, though it does not follow the template headings exactly.
Linked Issues check ✅ Passed The PR adds schema validation with DLQ-backed producers/consumers and blocks GraphQL introspection in production as requested.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/controllers/investmentController.ts (1)

119-135: 🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Require an auth scope before listing withdrawal requests.

Unlike postInvestmentWithdrawRequest, this path proceeds when both userId and organizationId are missing. That makes where become { organizationId: null }, which can expose user-scoped withdrawal rows instead of rejecting the request.

Suggested guard
     const userId = req.apiKey?.userId ?? null;
     const organizationId = req.apiKey?.organizationId ?? null;
+    if (!userId && !organizationId) {
+      throw new AppError("User or organization context required", 401);
+    }
 
     const query = getWithdrawRequestsQuerySchema.safeParse(req.query);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/investmentController.ts` around lines 119 - 135, The
withdrawal listing flow in the investmentController handler currently allows
requests with neither userId nor organizationId, causing the where filter to
fall back to organizationId: null instead of rejecting the call. Add an explicit
auth-scope guard near the existing req.apiKey checks in this handler so it fails
fast when both scopes are missing, similar to the protection used in
postInvestmentWithdrawRequest. Keep the cursor decoding and query logic in the
same path, but only proceed after confirming at least one valid scope is
present.
🟠 Major comments (27)
prisma/migrations/20260624120000_add_user_wallet_version/migration.sql-1-1 (1)

1-1: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail loudly or enforce the column contract explicitly.

Line 1 can silently preserve schema drift: if wallet_version already exists but is nullable or lacks DEFAULT 0, this migration no-ops while prisma/schema.prisma assumes a non-null Int with a default. That weakens the concurrency contract and can surface as null/version mismatches at runtime.

Suggested migration shape
-ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "wallet_version" INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE "users" ADD COLUMN "wallet_version" INTEGER NOT NULL DEFAULT 0;

If you need idempotence, keep the conditional add but follow it with explicit constraint/default enforcement:

+ALTER TABLE "users" ALTER COLUMN "wallet_version" SET DEFAULT 0;
+UPDATE "users" SET "wallet_version" = 0 WHERE "wallet_version" IS NULL;
+ALTER TABLE "users" ALTER COLUMN "wallet_version" SET NOT NULL;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@prisma/migrations/20260624120000_add_user_wallet_version/migration.sql` at
line 1, The migration for wallet_version can silently leave an existing column
in the wrong shape, so enforce the contract explicitly instead of relying on
only adding it conditionally. Update the migration so the users.wallet_version
column is guaranteed to be non-null with a default of 0 even when it already
exists, or make the migration fail loudly if the existing column does not match
the expected contract. Use the migration SQL in the add_user_wallet_version
change to align the database with the prisma/schema.prisma expectation for
wallet_version.
src/utils/walletConcurrency.ts-20-25 (1)

20-25: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Tighten ETag parsing to digits-only.

Lines 20-25 currently accept malformed validators such as "7junk" because parseInt() stops at the first non-digit. For an optimistic-concurrency precondition, that means an invalid If-Match value can still satisfy version 7.

Suggested fix
-const ETAG_PATTERN = /^W\/"(.+)"$|^"(.+)"$/;
+const ETAG_PATTERN = /^(?:W\/)?"([0-9]+)"$/;
...
-  const match = raw.match(ETAG_PATTERN);
-  const token = match?.[1] ?? match?.[2];
+  const match = raw.match(ETAG_PATTERN);
+  const token = match?.[1];
   if (!token) return null;

-  const version = Number.parseInt(token, 10);
-  return Number.isInteger(version) && version >= 0 ? version : null;
+  const version = Number(token);
+  return Number.isSafeInteger(version) ? version : null;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/walletConcurrency.ts` around lines 20 - 25, The ETag parsing in the
token-to-version logic is too permissive because Number.parseInt allows trailing
non-digit characters like "7junk". Update the parsing in the walletConcurrency
utility to accept only fully numeric validators by verifying the extracted token
is digits-only before converting it, and keep the existing non-negative integer
check in place. Use the token extraction flow around ETAG_PATTERN, match, and
the return logic to locate the fix.
src/services/transfer/transferService.ts-110-110 (1)

110-110: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not require If-Match for all transfer callers.

reserveWalletVersion throws 428 when options?.ifMatch is missing, but processSalaryBatch calls createTransfer without options, so salary transfers will now fail before recipient resolution. Gate this reservation to user-initiated HTTP flows or add a separate internal transfer path that does not require wallet ETags.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/transfer/transferService.ts` at line 110, The unconditional
reserveWalletVersion call in createTransfer is forcing every transfer path to
supply options?.ifMatch, which breaks internal callers like processSalaryBatch
before recipient resolution. Update transferService.createTransfer to only
reserve the wallet version for user-initiated HTTP flows that actually provide
an ETag, or split the logic into a separate internal transfer path that skips
the If-Match requirement while preserving the existing behavior for external
requests.
src/services/wallet/walletStateService.ts-18-21 (1)

18-21: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound or sweep the balance cache.

Expired entries remain in balanceCache indefinitely for users who do not mutate their wallet again, so a long-lived API process can accumulate one entry per queried wallet/environment. Add a small max size or prune expired entries before set.

Also applies to: 163-167, 180-184

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/wallet/walletStateService.ts` around lines 18 - 21, The balance
cache in walletStateService is unbounded and expired entries can linger forever,
so add cache hygiene around balanceCache usage. Update the balance write path in
the wallet state service (including the helper logic near the referenced cache
updates) to prune expired entries before inserting and enforce a small maximum
size/eviction policy so long-lived API processes do not accumulate one entry per
wallet/environment. Keep the fix localized to the balanceCache Map and the
functions that read/write it.
src/controllers/walletController.ts-94-100 (1)

94-100: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use the existing Stellar address validator here.

This accepts any 56-character string starting with G, so invalid public keys can be persisted. Reuse the same strict validator used by wallet service before calling updateWalletWithConcurrency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/walletController.ts` around lines 94 - 100, The
walletController validation currently uses a loose Zod string check for
stellar_address, which can let invalid public keys through. Update the request
parsing in walletController to reuse the existing strict Stellar address
validator already used by the wallet service before calling
updateWalletWithConcurrency, so the same validation logic is applied
consistently and invalid addresses are rejected.
src/services/wallet/walletStateService.ts-153-159 (1)

153-159: 🗄️ Data Integrity & Integration | 🟠 Major

Preserve the Horizon balance string instead of round-tripping through number to avoid precision loss.

Number.parseFloat(acbuBalance.balance) followed by String() strips trailing zeros and can alter the exact formatting provided by the Stellar ledger (e.g., converting "10.0000000" to "10"). Use the validated string from Horizon directly, falling back to "0" only when no matching ACBU balance exists.

Suggested fix
-    const stellarNum = acbuBalance ? Number.parseFloat(acbuBalance.balance) : 0;
-    const displayNum = Number.isFinite(stellarNum) ? stellarNum : 0;
+    const displayBalance = acbuBalance?.balance ?? "0";
     const snapshot: WalletBalanceSnapshot = {
-      balance: String(displayNum),
+      balance: displayBalance,
       currency: "ACBU",
       stellar_address: user.stellarAddress,
-      balance_stellar: String(displayNum),
+      balance_stellar: displayBalance,
       balance_source: "stellar",
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/wallet/walletStateService.ts` around lines 153 - 159, The ACBU
balance snapshot in walletStateService is round-tripping the Horizon balance
through Number.parseFloat and String, which can change the exact ledger
formatting. Update the balance handling in the WalletBalanceSnapshot निर्माण
logic to use the validated acbuBalance.balance string directly, and only fall
back to "0" when no matching ACBU balance exists; keep the existing
stellar_address and balance_stellar fields aligned with that preserved string.
src/controllers/walletController.ts-238-248 (1)

238-248: 🔒 Security & Privacy | 🟠 Major

Use a random per-wallet encryption salt and store it with the ciphertext.

The current implementation derives the KDF salt deterministically as "acbu-wallet-v1:" + userId. This is a security anti-pattern; scrypt requires a unique, random salt for each encryption operation to prevent rainbow table attacks and ensure high entropy.

Update the encryption flow to:

  1. Generate a random salt: const salt = crypto.randomBytes(32);
  2. Store the salt alongside the ciphertext (include a version byte for future migration).
  3. Update decryptUserStellarSecret to extract the random salt from the stored blob before deriving the key.
Current vulnerable pattern
const salt = WALLET_ENC_SALT_PREFIX + userId; // Deterministic!
const key = crypto.scryptSync(body.passcode, salt, WALLET_ENC_KEYLEN);

Implement a versioned envelope structure: [version][salt][iv][ciphertext][authTag].

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/walletController.ts` around lines 238 - 248, The wallet
encryption flow in walletController must stop using the deterministic
WALLET_ENC_SALT_PREFIX + userId salt and instead generate a random per-wallet
salt for each encryption. Update the encryption path that builds
encryptedStellarSecret to create a versioned envelope in the order
[version][salt][iv][ciphertext][authTag], store the salt in the blob, and derive
the key from that random salt with scryptSync. Also update
decryptUserStellarSecret to read the version byte and extract the salt before
key derivation so the decrypted payload matches the new format.
src/controllers/investmentController.ts-18-21 (1)

18-21: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject non-decimal and infinite withdrawal amounts.

Number(s) > 0 accepts values like "Infinity", "1e309", and scientific notation. For a persisted financial amount, keep the accepted wire format decimal-only and bounded to the supported precision.

Suggested validation tightening
   amount_acbu: z
     .string()
     .min(1)
-    .refine((s) => !Number.isNaN(Number(s)) && Number(s) > 0, "must be positive"),
+    .refine(
+      (s) => /^\d+(\.\d{1,7})?$/.test(s) && new Decimal(s).gt(0),
+      "must be a positive decimal with up to 7 decimal places",
+    ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/investmentController.ts` around lines 18 - 21, The withdrawal
amount validation in the investmentController schema currently relies on
Number(s) > 0, which allows Infinity and scientific notation. Tighten the
refinement on amount_acbu to accept only decimal-formatted strings within the
supported precision and reject non-decimal or infinite values. Update the zod
validation near the amount_acbu field so the wire format stays bounded and
consistent for persisted financial amounts.
src/types/rabbitmq-schemas.ts-111-113 (1)

111-113: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate notification payloads per notification type.

The schema currently accepts any payload with a known type, but processNotification reads required fields like health, status, currency, amount, channel, and amountAcbu. Malformed notification messages can still pass validation and fail later or be silently skipped.

Proposed direction
-export const NotificationSchema = z.object({
-  type: z.enum(['reserve_alert', 'withdrawal_status', 'investment_withdrawal_ready']),
-}).passthrough();
+export const NotificationSchema = z.discriminatedUnion("type", [
+  z.object({
+    type: z.literal("reserve_alert"),
+    health: z.string().min(1),
+    overcollateralizationRatio: z.number(),
+  }),
+  z.object({
+    type: z.literal("withdrawal_status"),
+    userId: z.string().min(1),
+    status: z.string().min(1),
+    currency: z.string().min(1),
+    amount: z.number(),
+    channel: z.array(z.enum(["email", "sms"])).optional(),
+  }),
+  z.object({
+    type: z.literal("investment_withdrawal_ready"),
+    userId: z.string().min(1).nullable().optional(),
+    organizationId: z.string().min(1).nullable().optional(),
+    amountAcbu: z.number(),
+  }),
+]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/types/rabbitmq-schemas.ts` around lines 111 - 113, The NotificationSchema
currently only validates the type and allows any extra payload, so malformed
messages can reach processNotification and fail later. Update NotificationSchema
in rabbitmq-schemas.ts to validate a discriminated union by notification type,
and add the required fields for each case used by processNotification such as
health, status, currency, amount, channel, and amountAcbu. Keep the existing
type values, but make the schema strict enough that only payloads matching the
expected shape for reserve_alert, withdrawal_status, and
investment_withdrawal_ready are accepted.
src/utils/rabbitmq-validation.ts-96-100 (1)

96-100: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Treat malformed JSON and envelope/queue mismatches as validation failures.

JSON.parse errors currently bypass MessageValidationError, so some consumers retry poison messages instead of dead-lettering them as invalid. Also, envelope.type is ignored, allowing a valid envelope for one queue to be processed from another queue when payload schemas overlap.

Proposed fix
-    const raw = JSON.parse(content.toString());
+    let raw: unknown;
+    try {
+      raw = JSON.parse(content.toString());
+    } catch {
+      throw new MessageValidationError(queue, [
+        { code: z.ZodIssueCode.custom, path: [], message: 'Invalid JSON' },
+      ]);
+    }
+
     const envelope = MessageEnvelopeSchema.parse(raw);
+
+    if (envelope.type !== queue) {
+      throw new MessageValidationError(queue, [
+        {
+          code: z.ZodIssueCode.custom,
+          path: ['type'],
+          message: `Envelope type ${envelope.type} does not match queue ${queue}`,
+        },
+      ]);
+    }
     
     // Validate payload against queue schema
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/rabbitmq-validation.ts` around lines 96 - 100, Malformed JSON and
envelope/queue mismatches are not being converted into MessageValidationError,
so update the validation flow in the message parsing/validation helper to catch
JSON.parse failures and wrap them as validation failures, and also verify
envelope.type matches the expected queue before calling validateMessage. Use the
existing MessageEnvelopeSchema parsing path and the queue validation logic in
rabbitmq-validation.ts so both invalid JSON and wrong-queue envelopes are
rejected consistently.
src/jobs/auditConsumer.ts-37-38 (1)

37-38: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve explicit null audit values.

AuditLogSchema allows oldValue/newValue to be null, but ?? undefined drops explicit nulls and loses audit detail for fields intentionally cleared to null.

Proposed fix
-              oldValue: validatedEntry.oldValue ?? (undefined as any),
-              newValue: validatedEntry.newValue ?? (undefined as any),
+              oldValue:
+                validatedEntry.oldValue === undefined
+                  ? undefined
+                  : (validatedEntry.oldValue as any),
+              newValue:
+                validatedEntry.newValue === undefined
+                  ? undefined
+                  : (validatedEntry.newValue as any),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jobs/auditConsumer.ts` around lines 37 - 38, The audit mapping in
auditConsumer’s entry validation is converting explicit null values into
undefined because of the nullish coalescing fallback. Update the handling of
validatedEntry.oldValue and validatedEntry.newValue so AuditLogSchema nulls are
preserved exactly as provided, while still allowing undefined when the field is
truly absent; use the existing audit normalization path in auditConsumer to keep
the distinction intact.
src/utils/rabbitmq-validation.ts-33-36 (1)

33-36: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not log message payloads on validation failure.

This path can log sensitive fields from invalid OTP, audit, notification, or webhook messages. Log schema errors and safe metadata instead.

Proposed fix
       logger.error('Message validation failed', {
         queue,
         errors: error.errors,
-        payload: JSON.stringify(payload).substring(0, 500),
+        payloadKeys:
+          payload && typeof payload === 'object'
+            ? Object.keys(payload as Record<string, unknown>)
+            : undefined,
       });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/rabbitmq-validation.ts` around lines 33 - 36, The validation
failure log in rabbitmq-validation should not include the raw message payload,
since it can expose sensitive data. Update the logging in the message validation
path to remove the payload field entirely and keep only schema error details
plus safe metadata such as queue name and any non-sensitive identifiers. Use the
existing validation/error handling in the rabbitmq-validation utility to ensure
Message validation failed still provides enough context without serializing
payload contents.
src/jobs/acbu_savings_vault_event_listener.ts-37-48 (1)

37-48: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't drop savings-vault events when publish fails.

Lines 37-48 log savingsVaultEventProducer.publish() failures and then continue. That silently drops the contract event on validation or broker errors, bypassing the DLQ/reliability goal of this change. Please dead-letter the raw event or bubble the error into the listener retry flow.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jobs/acbu_savings_vault_event_listener.ts` around lines 37 - 48, The
failure path in acbu_savings_vault_event_listener’s publish flow is swallowing
errors after savingsVaultEventProducer.publish(), which drops events instead of
triggering reliability handling. Update the catch in the listener logic to
either dead-letter the raw event explicitly or rethrow/bubble the error so the
listener retry flow can take over, and keep the existing validated publish path
and logging around the relevant event and producer symbols.
src/jobs/acbu_escrow_event_listener.ts-36-47 (1)

36-47: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't drop escrow events when publish fails.

Lines 36-47 catch escrowEventProducer.publish() failures and then return. That means validation/broker errors discard the source event with no retry or DLQ record, which leaves downstream escrow processing out of sync. Please either dead-letter the raw event here or propagate the failure into the listener's retry path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jobs/acbu_escrow_event_listener.ts` around lines 36 - 47, The
`escrowEventProducer.publish()` failure path in `acbu_escrow_event_listener` is
swallowing the error after logging, which drops the source escrow event; update
the `catch` block so failures are either sent to a dead-letter flow with the raw
event payload or rethrown into the listener retry path. Use the existing
`logger.error` and `validatedEvent`/`event` context to preserve the payload and
ensure `publish` failures are not silently discarded.
src/jobs/acbu_lending_pool_event_listener.ts-37-48 (1)

37-48: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't drop lending-pool events when publish fails.

Lines 37-48 swallow lendingPoolEventProducer.publish() failures after logging them. Any schema error or transient RabbitMQ failure now loses the originating contract event without a DLQ entry or retry signal. This path should dead-letter the raw event or rethrow so the listener can retry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jobs/acbu_lending_pool_event_listener.ts` around lines 37 - 48, The
publish failure in lendingPoolEventProducer.publish() is being swallowed after
logging, which causes raw lending-pool events to be lost. Update
acbu_lending_pool_event_listener.ts in the listener path around the
publish/try-catch so that failures either rethrow to let the consumer retry or
explicitly dead-letter the raw event before returning. Use the existing logger
and the lendingPoolEventProducer.publish flow to keep the event from
disappearing on schema or RabbitMQ errors.
src/jobs/producers/BaseProducer.ts-18-27 (1)

18-27: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Stop logging full message bodies from the shared producer path.

Lines 18-27 write the entire payload on both success and failure. Because this is the base class for queue producers, that leaks message contents into application logs and makes validation failures a privacy/compliance problem. publishValidatedMessage() already logs queue/message metadata, so this should stick to identifiers rather than bodies.

Suggested change
       logger.debug("Message published successfully", {
         queue: this.queue,
-        payload: validatedPayload,
       });
     } catch (error) {
       logger.error("Failed to publish message", {
         queue: this.queue,
         error: error instanceof Error ? error.message : String(error),
-        payload,
       });
       throw error;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jobs/producers/BaseProducer.ts` around lines 18 - 27, The shared producer
path in publishValidatedMessage() is logging full message bodies on both success
and failure, which leaks payload contents. Update BaseProducer so the
logger.debug and logger.error calls keep only queue/message metadata (for
example identifiers, counts, or validation status) and remove
validatedPayload/payload from the logged context. Use publishValidatedMessage()
and this.queue as the main anchors when adjusting the logging shape.
src/jobs/producers/BaseProducer.ts-11-16 (1)

11-16: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Cache queue declaration instead of asserting on every publish.

Lines 12-16 currently turn each publish into a topology setup call. assertQueueWithDLQ() performs multiple broker RPCs, so every message pays for re-asserting an already-existing queue/DLQ pair. On these event streams, that will become a throughput bottleneck fast. Assert once per producer (or at startup) and reuse the promise.

Suggested change
 export abstract class BaseProducer<T> {
   protected abstract queue: string;
   protected abstract validate(payload: T): T;
+  private queueReady?: Promise<void>;
+
+  protected async ensureQueue(): Promise<void> {
+    if (!this.queueReady) {
+      this.queueReady = assertQueueWithDLQ(this.queue)
+        .then(() => undefined)
+        .catch((error) => {
+          this.queueReady = undefined;
+          throw error;
+        });
+    }
+
+    return this.queueReady;
+  }
 
   async publish(payload: T, options?: { persistent?: boolean; priority?: number }): Promise<void> {
     try {
-      // Ensure queue exists with DLQ
-      await assertQueueWithDLQ(this.queue);
+      await this.ensureQueue();
 
       // Validate and publish
       const validatedPayload = this.validate(payload);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jobs/producers/BaseProducer.ts` around lines 11 - 16, Cache the queue
declaration in BaseProducer so publish does not re-run topology setup on every
message. Update the publish path around validate/publish to reuse a one-time
assertQueueWithDLQ() promise (or move the assertion to producer
initialization/startup) and have the producer methods reference that cached
setup before calling publishValidatedMessage().
src/services/notification/notificationService.ts-96-114 (1)

96-114: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

sendEmailBatch() exposes an all-or-nothing API over partial side effects.

Lines 101-114 reject the whole batch on the first provider error even though earlier recipients may already have been sent. Since src/jobs/notificationConsumer.ts:99 uses this from a queue consumer, a retry can resend the already-delivered subset.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/notification/notificationService.ts` around lines 96 - 114, The
sendEmailBatch() flow is all-or-nothing even though some messages may already be
sent before an error, which can cause duplicate deliveries on retry from
notificationConsumer. Update sendEmailBatch() to handle per-message failures
without throwing away the whole batch: keep the existing SMTP path behavior in
sendSmtpEmailBatch(), but in the non-SMTP loop catch and log failures per
sendEmail() call while continuing with the remaining messages, and only surface
or aggregate failures in a way that preserves which recipients succeeded. Use
the sendEmailBatch() and sendEmail() symbols to locate the batch and
per-recipient paths.
src/services/cache/redisService.ts-149-156 (1)

149-156: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't silently drop expirations when ttlSeconds is 0.

Line 155 uses a truthy check, so set(key, value, 0) stores the key without any TTL instead of expiring or rejecting it. For cache and rate-limit keys, that turns a short-lived entry into a permanent one.

Suggested fix
 async set(
   key: string,
   value: string,
   ttlSeconds?: number,
 ): Promise<"OK" | null> {
   return this.executeWithReadonlyRetry(async () => {
-    if (ttlSeconds) {
+    if (ttlSeconds !== undefined) {
+      if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
+        throw new Error("ttlSeconds must be a positive integer");
+      }
       return this.getClient().set(key, value, "EX", ttlSeconds);
     }

     return this.getClient().set(key, value);
   });
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/cache/redisService.ts` around lines 149 - 156, The TTL handling
in `RedisService.set` is using a truthy check, so `ttlSeconds = 0` skips
expiration and stores the key permanently. Update the `set` method to treat `0`
explicitly instead of relying on truthiness, and either apply the expiration or
reject invalid TTL values consistently. Use the `set` method and
`executeWithReadonlyRetry` path to locate the fix, and ensure cache/rate-limit
callers do not lose expiration semantics when passing zero.
src/services/email/emailService.ts-119-123 (1)

119-123: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Transport cleanup can mask the real delivery outcome.

Lines 120-123 let closeSmtpTransport() throw out of the finally. If the emails were already accepted, the caller still sees a failure and may retry the batch; if a send already failed, the close error hides the real cause. It also skips the metric cleanup on that path.

Suggested fix
 export async function sendSmtpEmailBatch(
   messages: SmtpEmailMessage[],
 ): Promise<void> {
   if (messages.length === 0) {
     return;
   }

   poolMetrics.batchesInFlight += 1;
   const transport = createSmtpTransport();
+  let sendError: unknown;

   try {
     for (const message of messages) {
       await transport.sendMail({
         from: config.notification.emailFrom,
         to: message.to,
         subject: message.subject,
         text: message.body,
       });
     }
+  } catch (error) {
+    sendError = error;
+    throw error;
   } finally {
-    await closeSmtpTransport(transport, "batch");
-    poolMetrics.batchesInFlight -= 1;
-    poolMetrics.totalBatchesCompleted += 1;
-    recordPoolLeakIfDetected("batch");
+    try {
+      await closeSmtpTransport(transport, "batch");
+    } catch (closeError) {
+      if (!sendError) {
+        throw closeError;
+      }
+      logger.error("SMTP transport cleanup failed after send failure", {
+        closeError,
+      });
+    } finally {
+      poolMetrics.batchesInFlight = Math.max(0, poolMetrics.batchesInFlight - 1);
+      if (!sendError) {
+        poolMetrics.totalBatchesCompleted += 1;
+      }
+      recordPoolLeakIfDetected("batch");
+    }
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/email/emailService.ts` around lines 119 - 123, The batch cleanup
in emailService’s finally block lets closeSmtpTransport() throw and overwrite
the real send result, which can cause false batch failures and skip metric
cleanup. Update the batch flow around closeSmtpTransport,
poolMetrics.batchesInFlight, poolMetrics.totalBatchesCompleted, and
recordPoolLeakIfDetected so transport shutdown is best-effort and never masks
the original delivery outcome. Catch and log cleanup errors separately, and
ensure the metric adjustments still run regardless of whether SMTP closing
succeeds.
src/services/ai/openaiGuard.ts-212-249 (1)

212-249: 🗄️ Data Integrity & Integration | 🟠 Major

Timed-out attempts still run to completion, causing duplicate billing.

Promise.race() stops awaiting the result but does not cancel the in-flight HTTP request. If a timeout occurs, the original OpenAI request continues processing in the background. A subsequent retry sends a new request, potentially charging for two completions while only one is used.

Update the SDK call to accept an AbortController signal passed as the signal option, aborting it on timeout or successful resolution of the race.

  const controller = new AbortController();
  const apiPromise = client.chat.completions.create({
    model,
    messages,
    max_tokens: maxTokens,
    signal: controller.signal, // <-- Pass signal
  });

  const timeoutPromise = new Promise((_, rej) =>
    setTimeout(() => {
      controller.abort(); // <-- Abort on timeout
      rej(new Error("OpenAI request timed out"));
    }, timeoutMs),
  );

Include logic to abort the controller on success after the race resolves to prevent lingering requests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/ai/openaiGuard.ts` around lines 212 - 249, The retry logic in
callWithRetries() leaves timed-out OpenAI requests running, which can duplicate
completions and billing. Update the client.chat.completions.create call to use
an AbortController signal, and abort that controller when the timeoutPromise
wins the Promise.race in openaiGuard.ts. Also ensure the controller is aborted
after a successful race resolution so no in-flight request lingers, and keep the
existing retry/error handling around isRetryableError, lastErr, and logger.warn
intact.
src/services/rates/exchangeRateService.ts-9-16 (1)

9-16: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard TTL parsing against invalid values.

If either env var is empty or non-numeric, parseInt(...) yields NaN, expiresAt becomes NaN, and getCachedExchangeRate() never evicts that entry because Date.now() >= NaN is always false. A bad negative TTL can pin a cached null indefinitely. Prefer validated config values here, or clamp to a finite positive fallback before writing the cache entry.

Suggested fix
-const POSITIVE_TTL_MS = parseInt(
-  process.env.EXCHANGE_RATE_CACHE_TTL_MS || "60000",
-  10,
-);
-const NEGATIVE_TTL_MS = parseInt(
-  process.env.EXCHANGE_RATE_NEGATIVE_CACHE_TTL_MS || "30000",
-  10,
-);
+function parsePositiveTtl(raw: string | undefined, fallbackMs: number): number {
+  const parsed = Number(raw);
+  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackMs;
+}
+
+const POSITIVE_TTL_MS = parsePositiveTtl(
+  process.env.EXCHANGE_RATE_CACHE_TTL_MS,
+  60_000,
+);
+const NEGATIVE_TTL_MS = parsePositiveTtl(
+  process.env.EXCHANGE_RATE_NEGATIVE_CACHE_TTL_MS,
+  30_000,
+);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/rates/exchangeRateService.ts` around lines 9 - 16, The TTL
constants in exchangeRateService are parsed directly from environment variables,
so empty or non-numeric values can produce NaN and break cache eviction in
getCachedExchangeRate(). Update POSITIVE_TTL_MS and NEGATIVE_TTL_MS to use
validated finite numeric values with safe fallbacks before they are used to
compute expiresAt, and ensure any invalid config is clamped to a positive
default so cached entries cannot become permanent.
src/services/rates/exchangeRateService.ts-37-46 (1)

37-46: 🩺 Stability & Availability | 🟠 Major

Don't negative-cache non-Axios exceptions.

if (!axios.isAxiosError(error)) return true; treats local code defects (e.g., TypeError, missing imports) as transient upstream failures. This causes fetchFromExternalApi() bugs to be negative-cached (null), forcing callers to silently degrade for the negative cache TTL instead of surfacing the error on subsequent requests.

Non-Axios errors should not populate the negative cache; they indicate application bugs that need fixing, not transient conditions to cache.

Suggested fix
 export function isRetryableRateApiError(error: unknown): boolean {
-  if (!axios.isAxiosError(error)) return true;
+  if (!axios.isAxiosError(error)) return false;

   const status = error.response?.status;
   if (status !== undefined && status >= 500) return true;
   if (status === 408 || status === 429) return true;
   if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") return true;
   if (!error.response) return true;

   return false;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/rates/exchangeRateService.ts` around lines 37 - 46, The
retryable error check in isRetryableRateApiError is treating non-Axios
exceptions as transient, which causes fetchFromExternalApi to negative-cache
application bugs. Update the handling so only Axios-based failures with
retryable conditions return true, and make non-Axios errors return false (or
otherwise bypass negative caching) so unexpected local defects are surfaced
instead of cached as null.
src/config/env.ts-116-148 (1)

116-148: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate the new Redis/SMTP envs in envSchema instead of parsing them ad hoc.

These settings currently bypass envSchema, so typos become late runtime failures (port: NaN, malformed sentinel entries, NOTIFICATION_EMAIL_PROVIDER="smpt", etc.) instead of failing fast during boot.

Suggested direction
+ REDIS_MAX_RETRIES_PER_REQUEST: z.coerce.number().int().min(0).default(3),
+ REDIS_READONLY_RETRY_ATTEMPTS: z.coerce.number().int().min(0).default(3),
+ REDIS_READONLY_RETRY_DELAY_MS: z.coerce.number().int().min(0).default(100),
+ NOTIFICATION_EMAIL_PROVIDER: z.enum(["sendgrid", "ses", "smtp", "log"]).default("log"),
+ SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
+ SMTP_MAX_CONNECTIONS: z.coerce.number().int().min(1).default(5),
+ SMTP_MAX_MESSAGES: z.coerce.number().int().min(1).default(100),

Then derive config.redis / config.notification.smtp from env, not process.env.

Also applies to: 334-348

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config/env.ts` around lines 116 - 148, The Redis and SMTP settings are
being parsed directly from process.env instead of being validated by envSchema,
so malformed values slip through until runtime. Move the new Redis/SMTP fields
into envSchema validation, then build config.redis and config.notification.smtp
from the validated env object rather than re-reading process.env. Use the
existing env.ts config setup and the Redis parsing block as the place to update,
so bad values like malformed sentinels or typoed provider names fail fast during
boot.
src/config/mongodb.ts-30-35 (1)

30-35: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add jitter to the MongoDB retry delay.

BASE_DELAY_MS * 2 ** (attempt - 1) makes every instance reconnect on the same schedule after a shared outage, so this loop can still herd traffic back onto MongoDB all at once.

Suggested fix
-      const delay = BASE_DELAY_MS * 2 ** (attempt - 1);
+      const cappedBackoff = BASE_DELAY_MS * 2 ** (attempt - 1);
+      const delay = Math.floor(Math.random() * cappedBackoff);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config/mongodb.ts` around lines 30 - 35, The retry backoff in the MongoDB
connection loop is deterministic, which can cause many instances to reconnect at
the same time after an outage. Update the delay calculation in the MongoDB retry
logic to add random jitter on top of BASE_DELAY_MS * 2 ** (attempt - 1), while
keeping the existing logger.warn, sanitizeMongoUri, and retry flow intact so
reconnects are spread out across instances.
src/controllers/mintController.test.ts-128-148 (1)

128-148: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Assert the flattened validation payload, not AppError.message.

For "JPY", depositFromBasketCurrency fails depositBodySchema.safeParse(...) and wraps that as new AppError("Invalid request", ...) in src/controllers/mintController.ts Lines 289-296. The expectation on Line 147 will therefore fail even when the validation is working. Check the attached field error for currency, or assert "Invalid request" here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/controllers/mintController.test.ts` around lines 128 - 148, The test for
depositFromBasketCurrency is asserting the wrong error text: the schema
validation failure is wrapped by the controller into AppError("Invalid request",
...) rather than exposing the raw field message. Update the expectation in
mintController.test to assert the wrapped message or inspect the flattened
validation payload on the AppError details for the currency field, using
depositFromBasketCurrency and AppError as the key symbols to locate the
behavior.
src/middleware/metrics.ts-114-132 (1)

114-132: 🩺 Stability & Availability | 🟠 Major

Prevent unbounded metric cardinality from unmatched paths.

The fallback logic in src/middleware/metrics.ts Line 120 uses req.path directly when req.route is missing (e.g., 404s). This allows any unique path (like /users/123, /users/xyz, /attack/long/path) to create a new metric entry in histogramsByEndpoint and the http.route OTel attribute. Since this middleware is globally registered, this causes unbounded memory growth and metric cardinality explosion.

Replace the fallback with a fixed label (e.g., UNKNOWN or /*) to collapse unmatched requests into a single metric series.

Current vulnerable logic
  if (req.route?.path) {
    const base = req.baseUrl || "";
    return `${req.method} ${base}${req.route.path}`;
  }

  return `${req.method} ${req.baseUrl}${req.path}`; // ⚠️ Raw path creates new metrics for every 404
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/middleware/metrics.ts` around lines 114 - 132, The fallback in
normalizeEndpoint currently uses req.path for unmatched routes, which creates
unbounded metric cardinality for 404s and other misses. Update normalizeEndpoint
in src/middleware/metrics.ts to return a fixed label for missing req.route?.path
(for example UNKNOWN or /*) instead of the raw path, so histogramsByEndpoint and
the http.route attribute in recordResponseTime collapse all unmatched requests
into one series. Keep the existing behavior for matched routes using
req.route.path and req.baseUrl.
🧹 Nitpick comments (2)
src/types/prisma-extensions.d.ts (1)

3-6: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid erasing the augmented Prisma surface with any.

Line 5 turns every prisma.bulkTransferJob.* call into unchecked any, which defeats the point of adding the augmentation in the first place. Please bind this property to the generated Prisma delegate/extension type so bad query shapes still fail at compile time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/types/prisma-extensions.d.ts` around lines 3 - 6, The PrismaClient
augmentation for bulkTransferJob is currently typed as any, which removes
compile-time safety for prisma.bulkTransferJob usage. Update the Prisma module
augmentation in prisma-extensions.d.ts so bulkTransferJob uses the generated
delegate/extension type from `@prisma/client` instead of any, and keep the
declaration aligned with the PrismaClient surface so invalid query shapes still
fail type-checking.
tests/asyncErrors.test.ts (1)

5-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test the async-error behavior instead of grepping the source file.

This only proves src/index.ts currently contains that exact string. It still passes if the patch is never exercised in the real bootstrap path, and it fails on harmless refactors like moving the import to another bootstrap module. A tiny Express app with an async route that throws would validate the behavior this test actually cares about.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/asyncErrors.test.ts` around lines 5 - 8, Replace the source-file grep
in asyncErrors.test.ts with a behavioral test that exercises the async error
handling path end-to-end. Update the test to spin up a minimal Express app that
uses the bootstrap path from src/index.ts (or the same async-error middleware
setup) and verify an async route throw is forwarded to the error handler instead
of checking for the literal express-async-errors import string.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1fd0fae8-76cb-4851-b200-9e56cbf8f3af

📥 Commits

Reviewing files that changed from the base of the PR and between b4467e7 and 6a593f7.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (80)
  • .env.example
  • .github/pull_request_template.md
  • README.md
  • package.json
  • prisma/migrations/20260624120000_add_user_wallet_version/migration.sql
  • prisma/schema.prisma
  • src/config/database.ts
  • src/config/env.ts
  • src/config/investment.ts
  • src/config/logger.ts
  • src/config/mongodb.ts
  • src/config/savings.ts
  • src/controllers/investmentController.ts
  • src/controllers/mintController.test.ts
  • src/controllers/mintController.ts
  • src/controllers/savingsController.ts
  • src/controllers/schemas.ts
  • src/controllers/transactionController.ts
  • src/controllers/transferController.ts
  • src/controllers/userController.ts
  • src/controllers/walletController.ts
  • src/index.ts
  • src/jobs/acbu_escrow_event_listener.ts
  • src/jobs/acbu_lending_pool_event_listener.ts
  • src/jobs/acbu_savings_vault_event_listener.ts
  • src/jobs/auditConsumer.ts
  • src/jobs/notificationConsumer.ts
  • src/jobs/producers/AuditLogProducer.ts
  • src/jobs/producers/BaseProducer.ts
  • src/jobs/producers/EscrowEventProducer.ts
  • src/jobs/producers/LendingPoolEventProducer.ts
  • src/jobs/producers/SavingsVaultEventProducer.ts
  • src/jobs/producers/index.ts
  • src/jobs/webhookConsumer.ts
  • src/middleware/auth.test.ts
  • src/middleware/auth.ts
  • src/middleware/authMiddleware.test.ts
  • src/middleware/authMiddleware.ts
  • src/middleware/bodyParser.ts
  • src/middleware/metrics.test.ts
  • src/middleware/metrics.ts
  • src/middleware/pagination.test.ts
  • src/middleware/pagination.ts
  • src/routes/userRoutes.ts
  • src/services/ai/openaiGuard.ts
  • src/services/cache/index.ts
  • src/services/cache/redisService.test.ts
  • src/services/cache/redisService.ts
  • src/services/email/emailService.test.ts
  • src/services/email/emailService.ts
  • src/services/email/index.ts
  • src/services/investment/withdrawalTimingService.ts
  • src/services/limits/limitsService.ts
  • src/services/notification/index.ts
  • src/services/notification/notificationService.ts
  • src/services/oracle/forexClient.ts
  • src/services/rates/currencyConverter.ts
  • src/services/rates/exchangeRateService.test.ts
  • src/services/rates/exchangeRateService.ts
  • src/services/rates/index.ts
  • src/services/salary/salaryService.ts
  • src/services/transfer/transferService.ts
  • src/services/transfer/types.ts
  • src/services/wallet/walletService.ts
  • src/services/wallet/walletStateService.test.ts
  • src/services/wallet/walletStateService.ts
  • src/types/prisma-extensions.d.ts
  • src/types/rabbitmq-schemas.ts
  • src/types/shims-papaparse.d.ts
  • src/utils/dateUtils.test.ts
  • src/utils/dateUtils.ts
  • src/utils/jwt.ts
  • src/utils/rabbitmq-validation.ts
  • src/utils/walletConcurrency.test.ts
  • src/utils/walletConcurrency.ts
  • tests/asyncErrors.test.ts
  • tests/jwt.clockskew.test.ts
  • tests/logger.transportLevels.test.ts
  • tests/walBackup.test.ts
  • tsconfig.build.json
💤 Files with no reviewable changes (1)
  • src/controllers/userController.ts

Comment thread src/middleware/bodyParser.ts
Comment on lines +161 to +190
// For rejected transfers, the item status write inside the map never ran,
// so collect those corrective writes and commit them atomically below (#394).
const failedItemWrites: Prisma.PrismaPromise<unknown>[] = [];
results.forEach((r, idx) => {
if (r.status === "fulfilled" && r.value === "completed") {
successCount++;
} else {
// fulfilled-but-failed or rejected
failCount++;
if (r.status === "rejected") {
logger.error("Salary item transfer failed", { batchId, error: r.reason });
// Mark the corresponding item failed; find by position in chunk
const idx = results.indexOf(r);
// Index aligns: results[idx] corresponds to chunk[idx].
const item = chunk[idx];
if (item) {
await prisma.salaryItem.update({
where: { id: item.id },
data: {
status: "failed",
errorMessage:
r.reason instanceof Error ? r.reason.message : "Unknown error",
},
});
failedItemWrites.push(
prisma.salaryItem.update({
where: { id: item.id },
data: {
status: "failed",
errorMessage: r.reason instanceof Error ? r.reason.message : "Unknown error",
},
}),
);
}
}
}
});

if (failedItemWrites.length > 0) {
await prisma.$transaction(failedItemWrites);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Don't collapse status-write failures into transfer failures.

Line 170 treats every rejected settled result as a failed payout and rewrites the item to status: "failed". But the promise can also reject after createTransfer() succeeded, if the salaryItem.update() inside the mapper fails. In that case the money may already be sent, and this fallback records it as failed, so a later retry can pay the same recipient twice.

Suggested direction
-    const results = await Promise.allSettled(
-      chunk.map(async (item) => {
-        const result = await createTransfer({
-          senderUserId: batch.userId,
-          to: item.recipientAddress,
-          amountAcbu: item.amount.toString(),
-        });
-        await prisma.salaryItem.update({
-          where: { id: item.id },
-          data: {
-            status: result.status,
-            transactionId: result.transactionId,
-            errorMessage:
-              result.status === "failed" ? "Transfer payment failed" : null,
-          },
-        });
-        return result.status;
-      }),
-    );
+    const results = await Promise.allSettled(
+      chunk.map(async (item) => {
+        try {
+          const transfer = await createTransfer({
+            senderUserId: batch.userId,
+            to: item.recipientAddress,
+            amountAcbu: item.amount.toString(),
+          });
+          return { kind: "transfer_result" as const, item, transfer };
+        } catch (error) {
+          return { kind: "transfer_error" as const, item, error };
+        }
+      }),
+    );
+
+    // Only mark failed when the transfer itself failed.
+    // If persisting the transfer result fails, surface that separately instead of
+    // converting a completed payout into a business-level failure.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/salary/salaryService.ts` around lines 161 - 190, The fallback in
salary transfer handling is conflating transfer failures with status-write
failures in salaryService’s result loop, which can incorrectly mark
already-completed payouts as failed. Update the logic around the
`results.forEach` reconciliation so only actual `createTransfer()` failures
produce a `"failed"` item write, and treat rejections from the
`salaryItem.update()` path as a separate persistence error path instead of
rewriting payout status. Use the `results`, `failedItemWrites`, and
`salaryItem.update` flow to distinguish these cases before calling
`prisma.$transaction`.

@@ -0,0 +1,151 @@
import { z } from 'zod';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Import QUEUES before using it in QUEUE_SCHEMAS.

QUEUE_SCHEMAS references QUEUES, but this file only imports z, so the new file will not compile.

Proposed fix
 import { z } from 'zod';
+import { QUEUES } from '../config/rabbitmq';

Also applies to: 139-149

🧰 Tools
🪛 ESLint

[error] 1-1: Replace 'zod' with "zod"

(prettier/prettier)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/types/rabbitmq-schemas.ts` at line 1, QUEUE_SCHEMAS references QUEUES but
the symbol is not imported in this module, so the new schema file will not
compile. Update the imports in rabbitmq-schemas.ts to bring in QUEUES from the
module that defines it, alongside z, and ensure QUEUES is available before
QUEUE_SCHEMAS is declared.

…ql-introspection

Resolve merge conflicts and integrate upstream changes:

- Package updates: Add prom-client, winston-daily-rotate-file, uuid; update zod to v3.25.76
- Environment config: Add WAL backup configuration (Pi-Defi-world#381) and memory leak detection (Pi-Defi-world#436)
- Logging: Replace static file transports with DailyRotateFile with retention policies
- Middleware: Add correlationMiddleware, userAgentFilter, and request metrics
- Security: Add GraphQL introspection blocking and content-encoding validation
- Database: Add replica URL fallback and connection pool retry improvements
- Audit consumer: Integrate queueConfig for configurable retries with schema validation

Conflicts resolved:
- package.json (dependencies and pnpm overrides)
- src/config/env.ts (WAL backup + memory detection)
- src/config/logger.ts (DailyRotateFile integration)
- src/controllers/mintController.ts (idempotency and duplicate checks)
- src/index.ts (middleware ordering and GraphQL blocking)
- src/jobs/auditConsumer.ts (validation + queue config)
- src/config/database.ts (replica URL fallback)
@drips-wave

drips-wave Bot commented Jun 26, 2026

Copy link
Copy Markdown

@Oluwaseyi89 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Oluwaseyi89

Copy link
Copy Markdown
Contributor Author

@Junman140 PR is ready

@Junman140 Junman140 merged commit e16aaec into Pi-Defi-world:dev Jun 27, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GraphQL playground or introspection enabled in production No schema validation on RabbitMQ message bodies before publishing

8 participants