Skip to content

feat: soroban indexer infra - #35

Merged
codebestia merged 8 commits into
ShadeProtocol:mainfrom
codeZe-us:soroban_indexer_infra
Jul 29, 2026
Merged

feat: soroban indexer infra#35
codebestia merged 8 commits into
ShadeProtocol:mainfrom
codeZe-us:soroban_indexer_infra

Conversation

@codeZe-us

@codeZe-us codeZe-us commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

This infrastructure connects to a Stellar RPC node, polls the configured contract for state change events, decodes XDR values, applies deduplication, and dispatches events to an in-memory registry.

Note: This PR is strictly scoped to core infrastructure. No event-specific business logic (e.g., invoice status transitions, transaction records, merchant analytics) is implemented here. Future event handlers will plug into this registry one issue at a time.

Changes

Environment Configuration

  • Added STELLAR_RPC_URL, STELLAR_CONTRACT_ID, and optional STELLAR_INDEXER_START_LEDGER to src/config/environment.ts and .env.example.
  • Added helper for parsing optional integer environment variables.

Database Schema (prisma/schema.prisma)

  • Added IndexerCursor model: Persists the latest processed ledger sequence per contract ID to resume polling after restarts.
  • Added IndexerEvent model: Acts as a replay guard by tracking processed raw event IDs.

Core Indexer Pipeline (src/indexer/)

  • sorobanClient.ts: Singleton RPC server instance utilizing @stellar/stellar-sdk.
  • types.ts: Standardized DecodedEvent interface representing decoded contract events.
  • registry.ts: In-memory handler registry (registerEventHandler / dispatch). Topics without registered handlers log and gracefully skip without throwing.
  • poller.ts: Main polling loop executing every ~6s:
    • Fail-fast boot: Throws immediately on startup if STELLAR_CONTRACT_ID is missing or empty.
    • Replay guard: Skips raw event IDs already present in IndexerEvent.
    • XDR Decoding: Converts native SCVals to readable JS types via scValToNative.
    • Error resilience: Isolates event failures with per-event try/catch blocks so one unparseable event cannot crash the polling loop.
    • Transactional persistence: Saves processed event IDs and advances the cursor to latestLedger.sequence + 1 in a single database transaction after batch completion.
  • run.ts: Standalone entrypoint for running the indexer process independently of the Express API server, equipped with graceful SIGINT/SIGTERM shutdown handling.
  • handlers/: Empty directory reserved for future event handler modules.

Verification & Testing

Automated Tests

Run the new indexer unit test suite (6 tests covering startup validation, RPC connection, cursor persistence, replay guards, unregistered topic dispatching, and error containment):

Closes #24

npm test tests/unit/indexer.test.ts

Screenshots

No 1

Screenshot 2026-07-27 115240

No 2

Screenshot 2026-07-27 115414

No 3

Screenshot 2026-07-27 122908

Summary by CodeRabbit

  • New Features
    • Added a Stellar (Soroban) indexer that polls contract events, deduplicates processing, dispatches events to registered handlers, and persists indexing progress.
    • Updated the environment template with Postgres and new Stellar settings (RPC URL, contract id, optional start ledger).
    • Added database support for indexing state (cursor + processed events) and payment confirmation records.
    • Added an indexer watch command for local development.
  • Tests
    • Added unit tests for polling behavior, cursor recovery, duplicate suppression, missing configuration, and handler failure isolation.
  • Chores
    • Improved test command setup for consistent Jest configuration and cross-platform environment handling, plus lint/TypeScript tooling updates.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9aee2136-7d42-41cb-bc36-7c78b6420674

📥 Commits

Reviewing files that changed from the base of the PR and between b2d03d0 and 0273cce.

📒 Files selected for processing (5)
  • .env.example
  • eslint.config.cjs
  • prisma/schema.prisma
  • src/config/environment.ts
  • tsconfig.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/config/environment.ts
  • prisma/schema.prisma
  • tsconfig.json
  • eslint.config.cjs

📝 Walkthrough

Walkthrough

The PR adds a standalone Soroban contract event indexer with configurable RPC settings, persisted cursors and deduplication, event dispatching, lifecycle handling, and unit tests. It also updates Prisma, Jest, ESLint, TypeScript configuration, and backend type and error-handling patterns.

Changes

Soroban indexer infrastructure

Layer / File(s) Summary
Configuration and persistence contracts
.env.example, package.json, prisma/*, src/config/environment.ts, tsconfig*.json, eslint.config.cjs
Adds Stellar settings, an indexer command, Prisma cursor/event models and migration, optional ledger parsing, and ESLint TypeScript project configuration.
Polling and lifecycle runtime
src/indexer/*
Creates the Soroban client, event registry, event-decoding and deduplication poller, cursor persistence, polling controls, and signal-aware process entrypoint.
Indexer validation
tests/unit/indexer.test.ts
Tests configuration failures, event retrieval, cursor persistence, replay prevention, missing handlers, and handler errors.
Backend controller and service compatibility
src/controllers/*, src/services/*
Adds explicit route-parameter string assertions, removes unused catch bindings and redundant rethrows, and types transaction callbacks.
Lint and Jest support
eslint-report.json, tests/*, .prettierrc, src/config/prisma.ts
Updates lint reporting and Prisma lint suppression, adjusts Jest ESM mocks, removes unused test imports, updates async mocks, and reformats test code.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant IndexerProcess
  participant SorobanRPC
  participant EventRegistry
  participant Prisma
  IndexerProcess->>SorobanRPC: Fetch latest ledger and contract events
  IndexerProcess->>Prisma: Load cursor and check processed event IDs
  IndexerProcess->>EventRegistry: Dispatch decoded events
  IndexerProcess->>Prisma: Persist events and updated cursor
Loading

Possibly related PRs

Suggested reviewers: codebestia

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning Several controller/service, test, and lint/config edits are unrelated to the indexer infrastructure scope. Move non-indexer refactors such as controller/service cleanup, test churn, and lint/config changes into a separate PR unless they are required for the indexer.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding Soroban indexer infrastructure.
Linked Issues check ✅ Passed The PR adds the requested RPC client, poller, cursor/event models, registry, runner, and tests, matching #24's acceptance criteria.
✨ 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.

@codeZe-us
codeZe-us marked this pull request as ready for review July 27, 2026 11:30
@codebestia

Copy link
Copy Markdown
Contributor

GM @codeZe-us
Please address the CI failures.
Thanks.

@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: 5

Note

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

🟠 Major comments (17)
src/utils/validation.js-11-25 (1)

11-25: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject unknown request fields before req.body reaches the database.

validateRegisterMerchant and validateUpdateMerchant only check allowlisted fields, so payloads such as { firstName: "...", isAdmin: true } can still be accepted. The controller forwards req.body to registerMerchant, and that service passes fields from the request body into prisma.merchant.update({ where, data }), so unexpected keys are persisted. Build registerMerchant data from the validated allowlist or reject unknown keys; apply the same handling to updateMyProfile.

🤖 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/validation.js` around lines 11 - 25, Update
validateRegisterMerchant and validateUpdateMerchant to reject or ignore keys
outside their defined field allowlists, then ensure registerMerchant and
updateMyProfile construct Prisma data only from validated allowlisted fields
rather than forwarding req.body. Preserve validation of required, email, and
logo fields while preventing unexpected properties such as isAdmin from reaching
merchant persistence.
prisma/schema.prisma-255-259 (1)

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

Allow events without topics, or normalize them before persistence.

The PR output shows decoded events with topic: null, but IndexerEvent.topic is required. Persisting one of these events can fail the transaction, leave the cursor unchanged, and repeatedly retry the same event. Make topic nullable or persist a documented sentinel value before writing.

🤖 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/schema.prisma` around lines 255 - 259, Update the IndexerEvent
persistence flow to handle decoded events whose topic is null: either make
IndexerEvent.topic nullable and preserve null values, or normalize them to a
documented sentinel before the database write. Ensure the schema and write path
use the same approach so these events persist successfully without blocking
cursor advancement.
tests/unit/indexer.test.ts-131-169 (1)

131-169: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not advance past a failed event.

evt-bad is omitted from processedIds, but tick() then persists latestLedger + 1; after this batch the cursor moves beyond ledger 20 and the failed event is never retried. Keep the cursor at the earliest unprocessed ledger, or durably record an explicit dead-letter acknowledgement, and make this test assert that behavior.

🤖 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/unit/indexer.test.ts` around lines 131 - 169, Update tick() so a
handler failure does not advance the persisted cursor beyond the failed event’s
ledger; retain the earliest unprocessed ledger for retry unless the event is
explicitly durably acknowledged as dead-lettered. Preserve successful processing
of evt-good and update this test to assert the cursor remains at ledger 20 (or
the corresponding retry boundary) after evt-bad fails.
src/indexer/poller.js-22-119 (1)

22-119: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Compiled JavaScript is committed alongside its TypeScript source. Each of these files is emitted output of a sibling .ts file, checked into src/ rather than a build directory — tsconfig.json already excludes dist, so these appear to have been compiled in place. They will drift from the sources the moment someone edits only the .ts, and reviewers cannot tell which copy is authoritative. Delete them, emit to dist/, and add the output directory to .gitignore.

  • src/indexer/poller.js#L22-L119: remove; src/indexer/poller.ts is the source of truth.
  • src/indexer/registry.js#L1-L15: remove; generated from src/indexer/registry.ts.
  • src/indexer/run.js#L1-L13: remove; generated from src/indexer/run.ts.
  • src/indexer/types.js#L1-L1: remove; this is the export {}; stub emitted for the types-only module src/indexer/types.ts.
🤖 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/indexer/poller.js` around lines 22 - 119, Remove the committed generated
JavaScript outputs from src/indexer: delete src/indexer/poller.js (generated
from poller.ts), src/indexer/registry.js (from registry.ts), src/indexer/run.js
(from run.ts), and src/indexer/types.js (the types-only stub). Configure
TypeScript emission to dist/ and add dist to .gitignore so these files are
generated outside the source tree.
src/indexer/poller.ts-81-81 (1)

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

Per-event console.log on the hot path. Both sites log once per event at info level with no way to turn them down; the PR screenshots show the pair dominating output during a normal run. The shared root cause is that the indexer has no leveled/structured logger — introduce one and route these through it.

  • src/indexer/poller.ts#L81: move to debug and drop the decoded payload from the message (it can carry payer addresses and amounts for a payments contract); keep the event id and topic.
  • src/indexer/registry.ts#L14: unregistered topics are an expected steady state, not news — move to debug, or count skipped topics as a metric instead of logging each occurrence.
🤖 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/indexer/poller.ts` at line 81, The indexer lacks leveled structured
logging, causing per-event info logs on the hot path. Introduce or reuse a
shared logger, update src/indexer/poller.ts lines 81-81 to log at debug level
with only the event id and decoded topic (omit decodedValue), and update
src/indexer/registry.ts lines 14-14 to log unregistered topics at debug level or
replace per-occurrence logging with a skipped-topics metric.
src/indexer/run.ts-3-16 (1)

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

No teardown after the poll loop drains.

stopPolling() lets startPolling() resolve, but nothing then disconnects Prisma or exits. The open connection pool keeps the event loop alive, so after SIGTERM the process lingers until the orchestrator escalates to SIGKILL — which can cut off an in-flight tick mid-transaction. Await the returned promise, disconnect, and exit; a second signal should force-exit.

♻️ Graceful shutdown
+import prisma from '../config/prisma.js';
 import { startPolling, stopPolling } from './poller.js';
 
+let shuttingDown = false;
+
 function shutdown(signal: string) {
+  if (shuttingDown) {
+    console.warn(`Received ${signal} again, forcing exit.`);
+    process.exit(1);
+  }
+  shuttingDown = true;
   console.log(`Received ${signal}, shutting down indexer...`);
   stopPolling();
 }
 
-process.on('SIGINT', () => { ... });
-process.on('SIGTERM', () => { ... });
+process.on('SIGINT', () => shutdown('SIGINT'));
+process.on('SIGTERM', () => shutdown('SIGTERM'));
 
-startPolling().catch((error) => {
-  console.error('Fatal error starting Soroban indexer:', error);
-  process.exit(1);
-});
+startPolling()
+  .catch((error) => {
+    console.error('Fatal error starting Soroban indexer:', error);
+    process.exitCode = 1;
+  })
+  .finally(async () => {
+    await prisma.$disconnect();
+    process.exit(process.exitCode ?? 0);
+  });
🤖 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/indexer/run.ts` around lines 3 - 16, Update the signal handlers and
startup flow around startPolling and stopPolling to await the polling loop’s
completion, then disconnect Prisma and exit cleanly. Ensure teardown runs after
either shutdown signal without leaving the connection pool open, while a second
signal forces immediate exit.
src/indexer/poller.ts-124-129 (1)

124-129: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Fragile rethrow condition, and no backoff on sustained failure.

Re-reading environment.stellar.contractId to decide whether to propagate is an indirect way of expressing "only rethrow the config error thrown at Line 26" — it breaks the moment another error path needs to surface. A typed error checked with instanceof states the intent directly.

The bigger issue is what this swallows: with contractId set, a persistent RPC outage or DB failure is logged and retried at a flat 6s forever, with no backoff and no failure ceiling. Since startPolling then never rejects, the .catch in run.ts never fires and the process stays "healthy" while indexing nothing. Consider tracking consecutive failures, applying exponential backoff, and bailing out (or surfacing to a health check) past a threshold.

♻️ Typed config error
+class IndexerConfigError extends Error {}
+
 export async function tick(): Promise<void> {
   try {
     const contractId = environment.stellar.contractId;
     if (!contractId || contractId.trim() === '') {
-      throw new Error('STELLAR_CONTRACT_ID environment variable is unset or empty');
+      throw new IndexerConfigError('STELLAR_CONTRACT_ID environment variable is unset or empty');
     }
@@
   } catch (error) {
     console.error('Error in poller tick:', error);
-    if (!environment.stellar.contractId || environment.stellar.contractId.trim() === '') {
-      throw error;
-    }
+    if (error instanceof IndexerConfigError) 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/indexer/poller.ts` around lines 124 - 129, Replace the contractId-based
rethrow check in the poller tick catch block with an explicit typed
configuration-error check, rethrowing only the config error raised during setup.
Track consecutive polling failures in startPolling, apply bounded exponential
backoff instead of the fixed retry delay, and stop or propagate the failure
after a defined threshold so persistent RPC or database errors surface to the
caller.
src/indexer/poller.ts-30-31 (1)

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

Set a timeout on the Soroban RPC client.

src/indexer/sorobanClient.ts creates new rpc.Server(environment.stellar.rpcUrl) with no timeout option, so getLatestLedger() and getEvents() use the SDK default of no request timeout. If an RPC call hangs, the poll loop can be blocked indefinitely inside the tick and won’t reach the next isRunning check. Pass a per-server timeout (for example, a few seconds) or a fallback global timeout.

🤖 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/indexer/poller.ts` around lines 30 - 31, Update the Soroban RPC client
construction in the server/client initialization code to pass a finite
per-server request timeout, preserving the existing RPC URL and environment
configuration. Ensure calls such as getLatestLedger and getEvents cannot hang
indefinitely.
src/middlewares/auth.middleware.js-14-23 (1)

14-23: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Hash refresh tokens before storing them.

issueRefreshToken() stores crypto.randomUUID() directly in RefreshToken.token, and prisma.refreshToken.findUnique({ where: { token } }) compares that raw credential. That makes token a recoverable long-lived bearer secret; store only a hash and compare against the hash on lookup, as the API-key path does.

🤖 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/middlewares/auth.middleware.js` around lines 14 - 23, Update
issueRefreshToken and authenticateRefreshToken to hash refresh tokens before
persistence and hash the presented token before the findUnique lookup, reusing
the existing API-key hashing utility and preserving the raw token only for the
client response.
src/services/api-key.services.js-16-39 (1)

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

Protect the API-key quota check with a stronger consistency model.

createApiKey() counts active keys for the merchant, then creates another key in the same interactive transaction with no explicit isolation level. Since the project uses PostgreSQL, this can race under the default Read Committed isolation and allow the active-key limit to be exceeded. Use an explicit isolationLevel, atomic DB constraint, or retryable serializable isolation around this path.

🔒 Enforce serializable isolation for the quota check
+import { Prisma } from '`@prisma/client`';
+
 export const createApiKey = async (merchantId, label) => {
     const { rawKey, prefix, keyHash } = generateApiKeyMaterial();
     const normalizedLabel = label?.trim() || null;
-    const apiKey = awaiting prisma.$transaction(async (tx) => {
+    const apiKey = await prisma.$transaction(async (tx) => {
         const activeKeys = await tx.apiKey.count({
             where: activeApiKeyWhere(merchantId),
         });
         if (activeKeys >= MAX_ACTIVE_API_KEYS) {
             throw new AppError(400, `Maximum of ${MAX_ACTIVE_API_KEYS} active API keys allowed`);
         }
         return tx.apiKey.create({
             data: {
                 merchantId,
                 keyHash,
                 prefix,
                 name: normalizedLabel,
             },
         });
-    });
+    }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
🤖 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/api-key.services.js` around lines 16 - 39, Update createApiKey
and its prisma.$transaction call to enforce Serializable isolation for the
active-key count and subsequent apiKey.create operation. Preserve the existing
quota error and key creation behavior, and ensure serialization conflicts remain
retryable through the project’s established transaction/error-handling
mechanism.
src/controllers/merchant.controllers.js-13-30 (1)

13-30: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Unsanitized merchant records exposed via GET /merchants/:id and GET /merchants.

Both handlers return the raw Prisma merchant record(s) instead of the sanitizeMerchant allow-list used by getMyProfileController/registerMerchantController. This leaks emailOtp (bcrypt hash), emailOtpExpiresAt, merchantKey, email, and address to whoever can reach the route — currently anyone, since it's unauthenticated (src/routes/merchant.routes.js, Lines 14-15). Apply sanitizeMerchant()/sanitizeMerchant mapping before responding.

🤖 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/merchant.controllers.js` around lines 13 - 30, The
getMerchantController and listMerchantsController handlers return raw merchant
records containing sensitive fields. Apply the existing sanitizeMerchant helper
to the single merchant before getMerchantController responds, and map
sanitizeMerchant across the merchants list in listMerchantsController; preserve
the existing status codes and error responses.
src/services/otp.services.js-35-57 (1)

35-57: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

No attempt limit on OTP verification.

verifyEmailOtp checks expiry then compares the code, but nothing caps failed attempts within the 10-minute window. A 6-digit code has only 1,000,000 combinations, which is brute-forceable via automated requests before expiry, and no rate limiting/lockout is visible at the controller or route layer either (src/controllers/auth.controllers.js, src/routes/auth.routes.js). Recommend tracking failed attempts (e.g. an emailOtpAttempts counter, invalidated/locked after N failures) in addition to any network-layer throttling.

🤖 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/otp.services.js` around lines 35 - 57, Update verifyEmailOtp to
enforce a bounded failed-attempt policy within the OTP validity window: track
attempts using the merchant’s persisted OTP-attempt field, increment atomically
on invalid codes, and invalidate or lock the OTP after the configured maximum.
Reset the counter when issuing or successfully consuming an OTP, and preserve
the existing expiry and successful-verification behavior.
src/server.js-3-15 (1)

3-15: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

try/catch won't catch async listen failures (e.g. port already in use).

app.listen doesn't throw synchronously on bind errors; it emits an 'error' event on the returned server object. As written, a port conflict or bind failure will not be caught here — the process will appear to have "started" (or silently hang) instead of exiting with a clear error.

🛠️ Proposed fix
 const startServer = async () => {
     try {
         // Start Express server
-        app.listen(environment.port, () => {
+        const server = app.listen(environment.port, () => {
             console.log(`Server running on port ${environment.port} in ${environment.nodeEnv} mode`);
         });
+        server.on('error', (err) => {
+            console.error('Error starting server:', err);
+            process.exit(1);
+        });
     }
     catch (error) {
         console.error('Error starting server:', error);
         process.exit(1);
     }
 };
🤖 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/server.js` around lines 3 - 15, Update startServer to retain the server
returned by app.listen and attach an error listener for asynchronous bind
failures, logging the error and exiting with status 1. Preserve the existing
successful startup message and handle synchronous failures through the existing
catch path.
src/services/merchant.services.js-75-121 (1)

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

Handle the unique email violation atomically.

Merchant.email is already @unique, but registerMerchant still checks email first then calls update outside a transaction. Concurrent registrations for the same email can race past the findFirst check; wrap the check/update in prisma.$transaction and convert the Prisma uniqueness error (P2002) into the existing 409, so duplicate emails are rejected consistently.

🤖 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/merchant.services.js` around lines 75 - 121, Update
registerMerchant to perform the email availability check and merchant update
inside prisma.$transaction, using the transaction client for both queries. Catch
Prisma’s P2002 unique-constraint error from the transaction and convert it to
AppError(409, 'Email already registered'), while preserving existing validation
and OTP behavior.
src/services/auth.services.js-26-59 (1)

26-59: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

TOCTOU on nonce single-use enforcement.

usedAt is checked (Line 34) and then set via a plain update (Lines 54-57) with no condition. Two concurrent requests replaying the same valid nonce+signature can both pass the "not used" check before either write lands, letting the same nonce mint two token pairs. Close the race by making the claim atomic and conditional:

🔒 Proposed fix
-    await prisma.authNonce.update({
-        where: { id: authNonce.id },
-        data: { usedAt: new Date() },
-    });
-    return { valid: true, reason: null };
+    const { count } = await prisma.authNonce.updateMany({
+        where: { id: authNonce.id, usedAt: null },
+        data: { usedAt: new Date() },
+    });
+    if (count !== 1) {
+        return { valid: false, reason: 'Nonce already used' };
+    }
+    return { valid: true, reason: null };

This mirrors the optimistic-concurrency pattern already used in generateMerchantSigningKey (src/services/merchant.services.js, Lines 154-160).

🤖 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/auth.services.js` around lines 26 - 59, Make nonce consumption
in verifySignature atomic: replace the unconditional prisma.authNonce.update
after signature validation with a conditional update that matches both
authNonce.id and usedAt: null. Treat a failed conditional update as an
already-consumed nonce and return the existing invalid result, while preserving
the successful valid response only when the claim succeeds.
src/services/email.service.js-58-71 (1)

58-71: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

OTP code and PII written to console logs.

The console fallback path logs the raw verification code together with the recipient's email and first name. If this path is ever reachable outside local development (e.g. environment.email.provider misconfigured or left at its default in a staging/prod environment), this leaks a live OTP into logs — anyone with log access could complete authentication. Redact the code (or gate this log behind an explicit dev-only flag) rather than emitting the sensitive value verbatim.

🔒 Proposed fix
         case 'console':
         default:
-            console.log(`[OTP] Verification code ${code} sent to ${to} for ${firstName}`);
+            console.log(`[OTP] Verification code sent to ${to}`);
🤖 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.service.js` around lines 58 - 71, Update the
console/default branch of sendOtp so it never logs the raw OTP or recipient PII;
remove those sensitive values or replace them with a safe, non-identifying
message, while preserving the resend and SMTP provider behavior.
src/app.js-9-9 (1)

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

Restrict CORS to known origins instead of the permissive default.

cors() with no options is equivalent to { origin: "*" }, so any browser origin can read responses from these protected routes. Use an environment-backed allowlist for origin instead of the wide-open default.

🤖 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/app.js` at line 9, Update the CORS configuration in the app
initialization around app.use(cors()) to use an environment-backed allowlist of
known origins instead of the permissive default. Parse and pass the configured
origins through the cors origin option, preserving access only for approved
browser origins.
🟡 Minor comments (5)
src/config/environment.ts-24-28 (1)

24-28: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use strict, non-negative integer validation for optional ledger config.

parseInt can silently truncate values like 12abc, 12.3, or 1e2 and also accepts negative ledgers. Keep src/config/environment.ts and src/config/environment.js in sync by validating the full trimmed value and requiring value >= 0 before returning it.

🤖 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/environment.ts` around lines 24 - 28, The parseOptionalInt
implementations must strictly validate optional ledger values as complete,
non-negative integers instead of using truncating parseInt behavior. Update
src/config/environment.ts lines 24-28 and src/config/environment.js lines 17-21
to validate the full trimmed input, reject decimals, exponent notation, trailing
characters, and negative values, and return the parsed number only when value >=
0; keep both files synchronized.
src/services/otp.services.js-22-31 (1)

22-31: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Inconsistent handling of sendOtp failures.

issueEmailOtp persists the OTP hash/expiry (Lines 26-29) before sending the email, and does not catch a sendOtp failure. When called from resendEmailOtp (Line 80), an email-provider error propagates as an unhandled rejection to the controller (500), yet the resend cooldown window has already started because emailOtpExpiresAt was already written — the merchant can't get another code for 60s despite never receiving one. registerMerchant (src/services/merchant.services.js, Lines 114-119) explicitly catches this same failure; consider applying the same try/catch here for consistency.

Also applies to: 61-85

🤖 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/otp.services.js` around lines 22 - 31, Update issueEmailOtp to
catch failures from sendOtp after persisting the OTP, matching the handling used
by registerMerchant. Ensure resendEmailOtp receives the established failure
result instead of an unhandled rejection, while preserving the existing OTP
generation, persistence, and successful-send behavior.
src/controllers/merchant.controllers.js-13-30 (1)

13-30: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Unvalidated numeric inputs can 500 instead of 400.

Number(req.params.id) (Line 15) and Number(req.query.limit)/Number(req.query.offset) (Line 24) yield NaN for missing/non-numeric input, which Prisma will likely reject with a runtime error surfaced as a generic 500 rather than a clear 400. Validate/default these before querying (e.g. default limit/offset, and reject non-numeric id).

🤖 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/merchant.controllers.js` around lines 13 - 30, Validate
numeric request inputs in getMerchantController and listMerchantsController
before calling getMerchant and listMerchants: reject missing or non-numeric id
values with a 400 response, and apply valid defaults for missing limit and
offset while rejecting invalid numeric values with 400. Ensure Prisma queries
receive only validated numbers and retain the existing success and 500 handling
for valid inputs and unexpected failures.
src/services/auth.services.js-60-70 (1)

60-70: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Make merchant creation atomic by address.

Merchant.address is declared @unique, and authenticateWallet can call upsertMerchant concurrently for the same new address. The current findFirst + create can race: one request may throw the Prisma unique-constraint error while the other succeeds, surfacing as a 500. Use an atomic Prisma upsert keyed on address, or catch the unique-constraint violation and re-fetch the existing merchant before issuing tokens.

🤖 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/auth.services.js` around lines 60 - 70, Update upsertMerchant to
make lookup and creation atomic for the unique address field, preferably using
Prisma’s upsert with address as the where key and returning the existing record
when present. Preserve merchantId generation only for the create path, ensuring
concurrent calls for the same address do not surface a unique-constraint error.
src/services/email.service.js-13-13 (1)

13-13: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Sanitize invoice email subjects before passing them to email providers.

buildInvoiceEmailContent uses raw merchant business names and invoice descriptions in the subject; escapeHtml only covers the HTML body. Strip or replace CR/LF in both subject fields at line 13 and line 75 before either Resend or nodemailer handles them, since normalisation is not guaranteed by the API/library surface.

🤖 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.service.js` at line 13, Sanitize the invoice email subject
values produced by buildInvoiceEmailContent before sending them through Resend
or nodemailer. Remove or replace CR/LF characters in both the merchant-name
subject at line 13 and the invoice-description subject at line 75; keep the
existing HTML escaping limited to the body.
🧹 Nitpick comments (15)
package.json (1)

11-11: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Use a non-watch entrypoint for the standalone indexer.

npm run indexer currently restarts the long-lived poller whenever source files change. Keep a separate indexer:watch development script and run tsx src/indexer/run.ts for the standalone deployment command.

🤖 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 `@package.json` at line 11, Update the package scripts so the standalone
indexer command runs src/indexer/run.ts without watch mode, and add or preserve
a separate indexer:watch script using tsx watch for development.
prisma/migrations/20260727112743_add_indexer_tables/migration.sql (1)

24-32: 🗄️ Data Integrity & Integration | 🔵 Trivial

Plan retention/pruning for IndexerEvent.

This table is a pure dedup ledger that grows one row per contract event forever. Dedup reads are PK lookups so they stay fast, but the table itself is unbounded, and there is no index to support a time- or ledger-based cleanup job. Consider adding an index on ledger (or processedAt) now and a periodic prune of rows below the persisted cursor, since anything older than the cursor can never be re-fetched.

The unique index on IndexerCursor("contractId") is created on a brand-new empty table, so the transactional CREATE UNIQUE INDEX here carries no locking risk.
[operational_advice]

🤖 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/20260727112743_add_indexer_tables/migration.sql` around
lines 24 - 32, The IndexerEvent table lacks a supporting index for retention
cleanup. Add a non-unique index on the cleanup-oriented column, preferably
ledger (or processedAt), and ensure the index is included in the migration so a
periodic prune can efficiently remove rows older than the persisted cursor.

Source: Learnings

src/indexer/registry.ts (1)

7-9: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Silent overwrite on duplicate topic registration.

handlers.set replaces any existing handler for the topic without warning. With a handlers/ directory reserved for future per-event logic, two modules registering the same topic would silently lose one. Either reject the duplicate or hold a list per topic.

♻️ Fail loudly on duplicates
 export function registerEventHandler(topic: string, handler: EventHandler): void {
+  if (handlers.has(topic)) {
+    throw new Error(`A handler is already registered for topic "${topic}"`);
+  }
   handlers.set(topic, handler);
 }
🤖 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/indexer/registry.ts` around lines 7 - 9, Update registerEventHandler to
detect whether the topic is already present before calling handlers.set, and
reject duplicate registrations with an explicit error instead of silently
replacing the existing handler. Preserve the current registration behavior for
new topics.
src/indexer/poller.ts (3)

141-145: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Shutdown waits out the full poll interval.

stopPolling only flips the flag; a signal arriving during the setTimeout sleep isn't observed until it elapses, so shutdown can take up to intervalMs (plus the in-flight tick). Make the sleep cancellable so the signal handlers take effect promptly.

♻️ Cancellable sleep
+let wake: (() => void) | undefined;
+
 export async function startPolling(intervalMs = 6000): Promise<void> {
@@
   while (isRunning) {
     await tick();
     if (!isRunning) break;
-    await new Promise((resolve) => setTimeout(resolve, intervalMs));
+    await new Promise<void>((resolve) => {
+      const t = setTimeout(resolve, intervalMs);
+      wake = () => {
+        clearTimeout(t);
+        resolve();
+      };
+    });
+    wake = undefined;
   }
 }
 
 export function stopPolling(): void {
   isRunning = false;
+  wake?.();
 }
🤖 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/indexer/poller.ts` around lines 141 - 145, The polling loop in the
isRunning flow uses an uncancellable setTimeout, delaying stopPolling during the
interval. Replace the sleep with a cancellable mechanism tied to the shutdown
signal or stopPolling state, and ensure the loop exits promptly when polling
stops while preserving the existing in-flight tick behavior.

65-67: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

N+1 dedup query.

One findUnique round trip per event, up to 100 per tick. Fetch the whole batch once before the loop.

♻️ Batch the dedup lookup
+    const known = new Set(
+      (
+        await prisma.indexerEvent.findMany({
+          where: { id: { in: events.map((e) => e.id) } },
+          select: { id: true },
+        })
+      ).map((r) => r.id),
+    );
+
     for (const event of events) {
       try {
-        const existing = await prisma.indexerEvent.findUnique({
-          where: { id: event.id },
-        });
-        if (existing) {
+        if (known.has(event.id)) {
           continue;
         }
🤖 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/indexer/poller.ts` around lines 65 - 67, Update the polling flow around
the per-event prisma.indexerEvent.findUnique call to fetch all existing event
IDs for the batch in one query before the loop, then deduplicate using an
in-memory lookup while processing events. Preserve the current handling for new
versus existing events and avoid issuing one database query per event.

13-16: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the unused symbol branch in decodeTopic.

At this package version, scValToNative converts scvSymbol values to JS strings, so typeof native === 'symbol' is unreachable and symbols already fall through to String(native).

🤖 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/indexer/poller.ts` around lines 13 - 16, Remove the unreachable typeof
native === 'symbol' branch from decodeTopic and let symbol values follow the
existing String(native) fallback path; keep all other native-value decoding
behavior unchanged.
src/services/pay.services.js (1)

59-69: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Scope the merchant include to needed fields.

getInvoiceForPdfBySlug pulls the full merchant record via include: { merchant: true }, unlike resolveInvoiceBySlug's scoped select. PDF rendering likely only needs a handful of display fields (name, logo, etc.); fetching the whole record widens exposure if the merchant model contains sensitive columns.

♻️ Scope the include to needed fields
 export const getInvoiceForPdfBySlug = async (slug) => {
     const invoice = await prisma.invoice.findUnique({
         where: { paymentSlug: slug },
-        include: { merchant: true },
+        include: {
+            merchant: {
+                select: {
+                    businessName: true,
+                    // ...other fields required by generateInvoicePdf
+                },
+            },
+        },
     });
🤖 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/pay.services.js` around lines 59 - 69, Update
getInvoiceForPdfBySlug to replace the full merchant include with a scoped
merchant select containing only the fields required by PDF rendering, matching
the established field selection used by resolveInvoiceBySlug and preserving the
invoice visibility check and return behavior.
src/routes/pay.routes.js (1)

1-8: 🩺 Stability & Availability | 🔵 Trivial

Consider rate limiting the public confirm endpoint.

POST /:slug/confirm is unauthenticated and persists a DB row per call with no proof-of-payment check (see confirmPayment in src/services/pay.services.js). Without rate limiting, it's an easy target for spamming paymentConfirmation rows.

🤖 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/routes/pay.routes.js` around lines 1 - 8, Apply rate limiting
specifically to the unauthenticated POST route registered with
confirmPaymentController in the router, using the project’s existing
rate-limiting middleware or established configuration. Keep the
resolveInvoiceController and getInvoicePdfController routes unchanged, and
ensure repeated requests to the confirmation endpoint are throttled before
reaching the controller.
src/controllers/api-key.controllers.js (1)

1-57: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Compiled .js files appear checked in alongside .ts sources.

Each of these pairs has identical logic (the .js file looks like a tsc build artifact of the corresponding .ts source), both tracked in the same directory. This pattern also recurs elsewhere in the PR stack (poller, registry, run, types, sorobanClient, handlers/index, invoice.controllers, environment). Committing both source and build output risks drift if only one file in a pair is edited later, and adds noise to reviews.

  • src/controllers/api-key.controllers.js#L1-L57: if this is a generated build artifact, exclude it from git (emit to a gitignored dist/ instead) or confirm the build/tooling reason it's tracked here.
  • src/controllers/api-key.controllers.ts#L48-L66: keep as the canonical source; verify the corresponding .js output is not required to be committed by the current build/tooling setup.
  • src/controllers/pay.controllers.js#L1-L69: same as above — confirm whether this compiled output should be gitignored.
  • src/controllers/pay.controllers.ts#L10-L70: same as above — canonical source; verify tooling expectations.
🤖 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/api-key.controllers.js` around lines 1 - 57, Remove the
generated JavaScript artifacts from tracking and configure the TypeScript build
to emit them into a gitignored dist directory, while retaining the TypeScript
controllers as canonical sources. Apply this to
src/controllers/api-key.controllers.js (lines 1-57) and
src/controllers/pay.controllers.js (lines 1-69); verify
src/controllers/api-key.controllers.ts (lines 48-66) and
src/controllers/pay.controllers.ts (lines 10-70) remain the maintained sources
and confirm the build/tooling no longer requires committed JavaScript output.
src/routes/auth.routes.js (1)

5-8: 🔒 Security & Privacy | 🔵 Trivial

Consider rate limiting on auth endpoints.

None of /nonce, /verify, /verify-email, /resend-otp appear to have request throttling in front of them. /nonce can be spammed to generate unbounded authNonce rows, and /verify-email is subject to the OTP brute-force gap noted in src/services/otp.services.js (Lines 35-57). A rate limiter (per-IP/per-address) on this router would provide useful defense-in-depth.

🤖 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/routes/auth.routes.js` around lines 5 - 8, Protect the authentication
routes in the router by applying the project’s existing rate-limiting
middleware, scoped per IP and/or address, to /nonce, /verify, /verify-email, and
/resend-otp. Ensure throttling runs before the controller and preserve the
existing authenticateMerchant middleware ordering for merchant-only routes.
src/services/merchant.services.js (1)

143-164: 🔒 Security & Privacy | 🔵 Trivial

Good use of optimistic concurrency; one operational note.

The conditional updateMany guard here is the right pattern (and the fix I've suggested reusing for the nonce/email races above). One operational note: privateKey is returned in the HTTP response body exactly once by design — worth double-checking that no request/response logging middleware downstream (src/app.js) logs response bodies for this route, since that would defeat the "never logged" guarantee documented 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/services/merchant.services.js` around lines 143 - 164, Inspect the
request/response logging middleware in app.js for the route invoking
generateMerchantSigningKey and ensure it does not log response bodies containing
the returned privateKey. Disable or exclude response-body logging for this route
while preserving existing logging behavior elsewhere.
src/services/invoice.services.js (2)

46-70: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Unreachable fallback after exhausted slug retries.

When the final retry (attempt === SLUG_MAX_RETRIES - 1) still hits a unique-slug conflict, attempt < SLUG_MAX_RETRIES - 1 is false, so the catch re-throws the raw Prisma error immediately — the loop can never fall through to increment past the bound, making the throw new AppError(500, 'Failed to generate a unique payment slug') on line 69 dead code. The caller ends up handling a raw Prisma error instead of the intended, cleaner message.

♻️ Proposed fix
         catch (error) {
-            if (isUniqueSlugError(error) && attempt < SLUG_MAX_RETRIES - 1) {
-                continue;
-            }
-            throw error;
+            if (!isUniqueSlugError(error)) {
+                throw error;
+            }
+            if (attempt < SLUG_MAX_RETRIES - 1) {
+                continue;
+            }
+            throw new AppError(500, 'Failed to generate a unique payment slug');
         }
     }
-    throw new AppError(500, 'Failed to generate a unique payment slug');
 };
🤖 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/invoice.services.js` around lines 46 - 70, Update the retry
handling around the invoice creation loop so an exhausted unique-slug conflict
is converted into the existing AppError instead of rethrowing the raw Prisma
error. Preserve retries for attempts before SLUG_MAX_RETRIES and ensure the
final failure reaches the intended “Failed to generate a unique payment slug”
fallback.

118-127: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Limit the merchant relation to invoice PDF/email data.

getInvoiceWithMerchant() loads the whole Merchant row, but generateInvoicePdf() and sendInvoiceEmail() only need merchant.businessName and merchant.logo. Use a Prisma select for the invoice fields needed by those renderers plus merchant: { select: { businessName: true, logo: true } } to avoid passing unrelated sensitive fields into the renderer paths.

🤖 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/invoice.services.js` around lines 118 - 127, The
getInvoiceWithMerchant function currently includes the entire merchant record.
Replace the broad include with a Prisma select that returns the invoice fields
required by generateInvoicePdf and sendInvoiceEmail, plus merchant.select
containing only businessName and logo; preserve the existing merchantId/id
filtering and not-found error behavior.
src/controllers/invoice.controllers.js (1)

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

Duplicated auth-guard/error-mapping boilerplate across handlers, and errors not logged.

Every handler repeats the same if (!merchant) { 401 } check and the same try/catchAppError mapping, and the generic else res.status(500)... branches never log the underlying error. Consider extracting a requireMerchant middleware for the guard and a small withErrorHandling wrapper (or centralized Express error-handling middleware) that also logs unexpected errors — this reduces duplication and keeps failures visible in logs.

🤖 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/invoice.controllers.js` around lines 6 - 127, Refactor the
invoice controllers to remove repeated merchant checks and AppError try/catch
mapping by introducing and applying shared requireMerchant and withErrorHandling
(or centralized error middleware) helpers. Ensure unexpected errors are logged
with their underlying error details before returning the existing 500 response,
while preserving current status codes and response payloads.
src/services/email.service.js (1)

23-54: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider reusing mail provider clients instead of constructing per call.

sendViaResend and sendViaSmtp instantiate a fresh Resend/nodemailer transporter on every send. Nodemailer's default timeouts (documented as 2 min connect / 30s greeting / 10 min socket) prevent indefinite hangs, so this isn't a correctness bug, but recreating clients/connections per email adds avoidable overhead under volume. A module-level singleton reused across calls would be more efficient.

🤖 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.service.js` around lines 23 - 54, Reuse mail provider
clients across sends instead of constructing them inside sendViaResend and
sendViaSmtp. Add module-level singleton instances initialized from the existing
Resend API key and SMTP configuration, then have both functions reuse those
instances while preserving their current send behavior and arguments.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f666b0a7-ab96-4ca0-aaa3-ef3954785c04

📥 Commits

Reviewing files that changed from the base of the PR and between cfb68da and e694c50.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (56)
  • .env.example
  • package.json
  • prisma.config.js
  • prisma/migrations/20260727112743_add_indexer_tables/migration.sql
  • prisma/schema.prisma
  • src/app.js
  • src/config/database.js
  • src/config/environment.js
  • src/config/environment.ts
  • src/config/prisma.js
  • src/controllers/api-key.controllers.js
  • src/controllers/api-key.controllers.ts
  • src/controllers/auth.controllers.js
  • src/controllers/index.js
  • src/controllers/invoice.controllers.js
  • src/controllers/invoice.controllers.ts
  • src/controllers/merchant.controllers.js
  • src/controllers/pay.controllers.js
  • src/controllers/pay.controllers.ts
  • src/entities/index.js
  • src/indexer/handlers/index.js
  • src/indexer/handlers/index.ts
  • src/indexer/poller.js
  • src/indexer/poller.ts
  • src/indexer/registry.js
  • src/indexer/registry.ts
  • src/indexer/run.js
  • src/indexer/run.ts
  • src/indexer/sorobanClient.js
  • src/indexer/sorobanClient.ts
  • src/indexer/types.js
  • src/indexer/types.ts
  • src/middlewares/auth.middleware.js
  • src/routes/auth.routes.js
  • src/routes/index.js
  • src/routes/invoice.routes.js
  • src/routes/merchant.routes.js
  • src/routes/pay.routes.js
  • src/server.js
  • src/services/api-key.services.js
  • src/services/auth.services.js
  • src/services/email.service.js
  • src/services/index.js
  • src/services/invoice-pdf.services.js
  • src/services/invoice.services.js
  • src/services/merchant.services.js
  • src/services/otp.services.js
  • src/services/pay.services.js
  • src/services/storage/invoice-pdf.storage.js
  • src/utils/api-key.utils.js
  • src/utils/errors.js
  • src/utils/invoice.validation.js
  • src/utils/slug.js
  • src/utils/validation.js
  • tests/unit/indexer.test.ts
  • tsconfig.json

Comment thread src/config/environment.js Outdated
Comment on lines +23 to +26
export const environment = {
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3000', 10),
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',

@coderabbitai coderabbitai Bot Jul 27, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Fail fast when JWT_SECRET is missing in production.

The known fallback dev-jwt-secret-change-in-production allows forged JWTs if a production deployment omits JWT_SECRET. Retain a development/test fallback only for non-production environments; production startup should throw instead.

🤖 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/environment.js` around lines 23 - 26, Update the jwtSecret
property in the environment object to conditionally handle the missing
JWT_SECRET. When nodeEnv is production, throw an error if process.env.JWT_SECRET
is not set instead of using a fallback value. For non-production environments,
preserve the existing dev-jwt-secret-change-in-production fallback when
JWT_SECRET is absent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

confirm fix

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/config/environment.js --items all
printf '\n--- src/config/environment.js ---\n'
nl -ba src/config/environment.js | sed -n '1,100p'
printf '\n--- JWT secret references (source and tests) ---\n'
rg -n -C 3 --glob '!*node_modules*' 'JWT_SECRET|jwtSecret|dev-jwt-secret-change-in-production' src tests 2>/dev/null || true

Length of output: 838


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate environment configuration files ---'
fd -HI -t f '^environment\.(js|ts)$' . 2>/dev/null || true

printf '%s\n' '--- JWT secret references ---'
rg -n -C 4 --glob '!node_modules/**' --glob '!dist/**' \
  'JWT_SECRET|jwtSecret|dev-jwt-secret-change-in-production' . 2>/dev/null || true

printf '%s\n' '--- source tree top-level candidates ---'
fd -HI -t d '^src$' . -d 4 2>/dev/null || true

Length of output: 7383


The PR description image 1 shows 6/6 indexer tests passing; image 2 shows 29/29 suites and 228/228 tests passing; image 3 shows successful indexer polling, decoding, and graceful SIGINT handling. These images do not cover JWT configuration.

@codebestia, I can’t confirm this fix yet. The current source is src/config/environment.ts, and Line 33 still has the unconditional production-unsafe fallback:

jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',

Therefore a production process without JWT_SECRET would still start with the known secret. Please update it to throw when NODE_ENV === 'production' and the variable is missing, while retaining the fallback only outside production.

Comment thread src/controllers/merchant.controllers.js Outdated
Comment thread src/indexer/poller.ts
Comment on lines +63 to +99
for (const event of events) {
try {
const existing = await prisma.indexerEvent.findUnique({
where: { id: event.id },
});
if (existing) {
continue;
}

const topicVal = event.topic && event.topic.length > 0 ? event.topic[0] : undefined;
const decodedTopic = decodeTopic(topicVal);
let decodedValue: any = null;
try {
decodedValue = event.value ? scValToNative(event.value) : null;
} catch {
decodedValue = null;
}

console.log(`Decoded event [${event.id}] - topic: ${decodedTopic}, value:`, decodedValue);

await dispatch({
id: event.id,
topic: decodedTopic,
ledger: event.ledger,
txHash: event.txHash,
data: decodedValue,
});

processedIds.push({
id: event.id,
topic: decodedTopic,
ledger: event.ledger,
});
} catch (err) {
console.error(`Error processing event ${event.id}:`, err);
}
}

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

Replay protection is not crash-safe: handlers run before the event is marked processed.

The dedup read happens here, but the matching IndexerEvent write only lands in the batch transaction at Line 106. If the process is killed, or that transaction fails, after dispatch has already executed, nothing is persisted and the cursor is not advanced — so the next tick re-fetches the same events, finds no dedup rows, and re-runs every handler. For payment-confirmation handlers that means duplicate side effects on every crash.

Two viable shapes:

  • Write the IndexerEvent row and invoke the handler inside a single per-event transaction, so the processed-marker and the side effect commit or roll back together.
  • Or explicitly accept at-least-once and require every registered handler to be idempotent (keyed on event.id), documenting that contract in registry.ts.

The read-then-later-write also races if more than one poller instance is ever run against the same contract.

🤖 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/indexer/poller.ts` around lines 63 - 99, Make event processing crash-safe
by changing the flow around the existing `IndexerEvent.findUnique` check and
`dispatch` call: persist the event marker and invoke the handler within one
per-event transaction, rather than deferring the marker to the later batch
write. Ensure concurrent pollers cannot both pass the deduplication check and
execute the same event, while preserving the existing decoded payload and
processed cursor behavior.

Comment thread src/indexer/poller.ts
Comment on lines +101 to +104
const nextCursor =
events.length === 100 && events[events.length - 1]
? events[events.length - 1].ledger + 1
: latestLedger + 1;

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

🧩 Analysis chain

🌐 Web query:

@stellar/stellar-sdk 14 rpc Server getEvents response cursor pagination latestLedger

💡 Result:

In @stellar/stellar-sdk version 14, the getEvents RPC method implements pagination using a cursor-based system that is mutually exclusive with ledger-range-based queries [1][2]. Key details regarding the getEvents response, cursor, pagination, and ledger state are as follows: Pagination Mechanism The method supports two mutually exclusive modes for requesting events [1][2]: 1. Ledger Range Mode: Uses startLedger and (optionally) endLedger [1][3]. In this mode, the cursor must be omitted [1]. 2. Cursor Pagination Mode: Uses a cursor string provided in the pagination object [1][4]. In this mode, startLedger and endLedger must be omitted [1][2]. Response Structure The getEvents response contains the following fields to facilitate pagination and provide context on the network state: - events: An array of the retrieved event objects [1][5]. - cursor: A top-level string returned by the server [1][6][5]. This opaque token is passed back to the server in the pagination object of a subsequent request to fetch the next page of results [4]. - latestLedger: The sequence number of the latest ledger known to the RPC node at the time of the response [6][5][2]. - oldestLedger: The oldest ledger sequence currently retained by the RPC node [6][7][2]. - latestLedgerCloseTime and oldestLedgerCloseTime: ISO-8601 strings representing the close times of the latest and oldest ledgers, respectively [8][7][2]. Important Changes In recent versions, the Stellar RPC API has consolidated its pagination identifiers [8][7]. The legacy pagingToken field has been removed from event responses; users should now rely exclusively on the top-level cursor field for pagination and the individual event id field for identifying specific events [8][7]. top_results: [1][6][8][4][7][2]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate poller.ts and package manifests =="
fd -a 'poller\.ts$|package(-lock)?\.json$|pnpm-lock\.yaml$|yarn\.lock$' . | sed 's#^\./##'

echo
echo "== poller.ts outline =="
ast-grep outline src/indexer/poller.ts --view expanded || true

echo
echo "== poller.ts relevant lines =="
cat -n src/indexer/poller.ts | sed -n '1,180p'

echo
echo "== SDK version references =="
rg -n '"`@stellar/stellar-sdk`"|stellar-sdk|getEvents|latestLedger|PaginationCursor|paging|cursor' package.json package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null || true

echo
echo "== getEvents usages =="
rg -n "getEvents\\(|startLedger|latestLedger|IndexerEvent|events.length === 100|lastEvent\\." src package.json 2>/dev/null || true

Repository: ShadeProtocol/shade-backend

Length of output: 8620


Use the getEvents pagination cursor for the next tick.

getEvents returns an opaque top-level cursor; latestLedger is only the server’s latest sequence and should not be treated as the continuation token. Store and pass that cursor on the next request instead of advancing manually; only keep manual ledger cursors as a fallback when no cursor is returned.

🤖 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/indexer/poller.ts` around lines 101 - 104, Update the poller’s
next-cursor handling around getEvents so it uses and stores the opaque top-level
cursor returned by getEvents for the next request. Pass that stored cursor into
the following tick, and retain the manual ledger-based cursor only when the
response provides no cursor; remove the current latestLedger/events-length
advancement as the primary path.

Comment thread src/routes/merchant.routes.js Outdated
@codebestia

Copy link
Copy Markdown
Contributor

Also, Please undo the generate js files. Your implementation generated javascript files from the ts files.
This is not needed for the implementation of this issue.

@codeZe-us

Copy link
Copy Markdown
Contributor Author

@codebestia fixed

@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

Caution

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

⚠️ Outside diff range comments (2)
tests/unit/indexer.test.ts (2)

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

Make cursor behavior on failed events explicit.

With this implementation, processedIds only includes non-throwing events, but the cursor is still advanced if the batch reaches the full limit. Add an assertion so the intended contract is enforced: either fail the tick/never advance, or persist the bad event so cursor advancement does not drop it.

🤖 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/unit/indexer.test.ts` around lines 145 - 190, Update the test around
tick and the registered handler to assert cursor behavior when an event handler
throws: verify the tick fails or the cursor is not advanced, unless the failed
event is persisted. Ensure the assertion explicitly prevents the bad event from
being silently skipped when the batch reaches the limit.

133-143: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use a value matcher for the resolved promise.

toThrow() expects a function, but .resolves passes the promise’s resolved value to the matcher. Since dispatch() resolves to void, assert the resolved value instead:

Proposed fix
-    ).resolves.not.toThrow();
+    ).resolves.toBeUndefined();
🤖 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/unit/indexer.test.ts` around lines 133 - 143, Update the dispatch test
to use a value matcher for the promise’s resolved result instead of combining
.resolves with toThrow. In the test case “skips dispatching on topic with no
registered handler without throwing,” assert that dispatch resolves to void
while preserving the existing unregistered-topic input.
🧹 Nitpick comments (5)
tests/unit/indexer.test.ts (1)

51-76: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the decoded event and dispatch result.

This fixture uses an empty topic and null value, and no handler is registered. The test can therefore pass while decoding produces the wrong topic/value or dispatch is skipped. Use a representative encoded event, register its handler, and assert the decoded payload and persistence.

🤖 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/unit/indexer.test.ts` around lines 51 - 76, Update the test around
tick() to use a representative encoded event with non-empty topic and value
data, register the corresponding event handler, and assert the handler receives
the correctly decoded payload. Also verify the decoded event is persisted
through prismaMock.indexerEvent, while preserving the existing ledger fetch, RPC
query, and cursor assertions.
tests/unit/merchant.services.test.ts (1)

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

Keep the dynamic Prisma mock type instead of casting it to any.

jest-mock-extended already creates mockDeep<PrismaClient>(); typing this destructured import restores compile-time checking for the Prisma calls below.

🤖 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/unit/merchant.services.test.ts` at line 4, Update the destructured
dynamic import of the Prisma mock to preserve its jest-mock-extended type
instead of casting it to any. Use the existing mockDeep<PrismaClient>() type for
prismaMock so Prisma calls in the test retain compile-time checking.
eslint-report.json (1)

1-91: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Don't commit the generated ESLint report.

eslint-report.json is build output. It's stale the moment it lands, embeds full source snapshots of ~40 files (duplicated in VCS), and leaks the author's local path layout (C:\projetcs\shade-backend-zeus\...). Delete it and add it to .gitignore; generate on demand with eslint -f json -o eslint-report.json.

🤖 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 `@eslint-report.json` around lines 1 - 91, Delete the generated
eslint-report.json artifact from version control and add eslint-report.json to
.gitignore so future lint runs do not recreate tracked build output; retain
on-demand generation via the existing ESLint command.
eslint.config.cjs (1)

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

Blanket /* eslint-disable */ masks a config-scoping bug rather than fixing it.

The require/module errors on this file appear because 'eslint.config.cjs' is listed in an ignores array that sits inside a config object that also declares files, so it only excludes the file from that block instead of globally. Prefer a standalone global ignores block (or CommonJS globals for .cjs) over disabling every rule for the file.

♻️ Suggested approach
-/* eslint-disable */
 const { FlatCompat } = require('`@eslint/eslintrc`');

Then add a top-level ignores block in the exported array:

module.exports = [
  { ignores: ['node_modules/**', 'dist/**', 'build/**', 'coverage/**', '**/*.d.ts', 'eslint.config.cjs'] },
  js.configs.recommended,
  // ...
];
🤖 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 `@eslint.config.cjs` at line 1, Remove the blanket /* eslint-disable */ from
eslint.config.cjs and fix the exported ESLint configuration so eslint.config.cjs
is excluded through a standalone top-level global ignores object, not an ignores
entry combined with files. Preserve the existing global ignore patterns and
ensure the standalone block appears in the module.exports configuration array.
tests/__mocks__/prisma.ts (1)

5-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the dead ESM Prisma mock file.

tests/__mocks__/prisma.ts is never imported by the tests; Prisma is mocked via tests/jest.setup.ts with jest.unstable_mockModule, and tests replace ../../src/config/prisma.js directly with await import.

🤖 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/__mocks__/prisma.ts` around lines 5 - 13, Remove the unused ESM Prisma
mock file containing prismaMock and its jest.mock setup; Prisma mocking is
already handled by tests/jest.setup.ts and direct module replacement in the
tests.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/services/api-key.services.ts`:
- Line 47: Remove the explicit any annotation from the Prisma transaction
callback in src/services/api-key.services.ts at lines 47-47 and
src/services/pay.services.ts at lines 86-86, allowing Prisma to infer the
transaction client type; if inference widens, use the generated
Prisma.PrismaClient type so tx.apiKey.count/create, tx.invoice.findUnique, and
tx.paymentConfirmation.upsert retain compile-time validation.

In `@tests/unit/indexer.test.ts`:
- Around line 18-20: Format the dynamic import destructuring in the indexer test
to satisfy Prettier, keeping the same imported symbols and module path while
using the formatter’s expected line-break style.

In `@tests/unit/merchant.services.test.ts`:
- Around line 5-7: Update the dynamic import destructuring for createMerchant,
getMerchant, and listMerchants to a single line, preserving the existing import
path and behavior.

---

Outside diff comments:
In `@tests/unit/indexer.test.ts`:
- Around line 145-190: Update the test around tick and the registered handler to
assert cursor behavior when an event handler throws: verify the tick fails or
the cursor is not advanced, unless the failed event is persisted. Ensure the
assertion explicitly prevents the bad event from being silently skipped when the
batch reaches the limit.
- Around line 133-143: Update the dispatch test to use a value matcher for the
promise’s resolved result instead of combining .resolves with toThrow. In the
test case “skips dispatching on topic with no registered handler without
throwing,” assert that dispatch resolves to void while preserving the existing
unregistered-topic input.

---

Nitpick comments:
In `@eslint-report.json`:
- Around line 1-91: Delete the generated eslint-report.json artifact from
version control and add eslint-report.json to .gitignore so future lint runs do
not recreate tracked build output; retain on-demand generation via the existing
ESLint command.

In `@eslint.config.cjs`:
- Line 1: Remove the blanket /* eslint-disable */ from eslint.config.cjs and fix
the exported ESLint configuration so eslint.config.cjs is excluded through a
standalone top-level global ignores object, not an ignores entry combined with
files. Preserve the existing global ignore patterns and ensure the standalone
block appears in the module.exports configuration array.

In `@tests/__mocks__/prisma.ts`:
- Around line 5-13: Remove the unused ESM Prisma mock file containing prismaMock
and its jest.mock setup; Prisma mocking is already handled by
tests/jest.setup.ts and direct module replacement in the tests.

In `@tests/unit/indexer.test.ts`:
- Around line 51-76: Update the test around tick() to use a representative
encoded event with non-empty topic and value data, register the corresponding
event handler, and assert the handler receives the correctly decoded payload.
Also verify the decoded event is persisted through prismaMock.indexerEvent,
while preserving the existing ledger fetch, RPC query, and cursor assertions.

In `@tests/unit/merchant.services.test.ts`:
- Line 4: Update the destructured dynamic import of the Prisma mock to preserve
its jest-mock-extended type instead of casting it to any. Use the existing
mockDeep<PrismaClient>() type for prismaMock so Prisma calls in the test retain
compile-time checking.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: affa42a0-e4b8-41bb-a17a-852c9292a13b

📥 Commits

Reviewing files that changed from the base of the PR and between e694c50 and b2d03d0.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (26)
  • .prettierrc
  • eslint-report.json
  • eslint.config.cjs
  • src/config/prisma.ts
  • src/controllers/auth.controllers.ts
  • src/controllers/merchant.controllers.ts
  • src/indexer/poller.ts
  • src/indexer/run.ts
  • src/services/api-key.services.ts
  • src/services/merchant.services.ts
  • src/services/pay.services.ts
  • tests/__mocks__/prisma.ts
  • tests/integration/api-key.routes.test.ts
  • tests/integration/auth.middleware.test.ts
  • tests/integration/auth.routes.test.ts
  • tests/integration/merchant.profile.test.ts
  • tests/integration/merchant.routes.test.ts
  • tests/jest.setup.ts
  • tests/unit/auth.services.test.ts
  • tests/unit/indexer.test.ts
  • tests/unit/invoice.schema.test.ts
  • tests/unit/merchant.profile.services.test.ts
  • tests/unit/merchant.services.test.ts
  • tests/unit/subscription.schema.test.ts
  • tsconfig.eslint.json
  • tsconfig.json
💤 Files with no reviewable changes (3)
  • tests/integration/auth.middleware.test.ts
  • tests/unit/merchant.profile.services.test.ts
  • tests/integration/api-key.routes.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • tsconfig.json
  • src/indexer/run.ts
  • src/indexer/poller.ts

const normalizedLabel = label?.trim() || null;

const apiKey = await prisma.$transaction(async tx => {
const apiKey = await prisma.$transaction(async (tx: any) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "TransactionClient" node_modules/@prisma/client
rg -n '\$transaction\(async \(tx' src/services

Repository: ShadeProtocol/shade-backend

Length of output: 449


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- package versions ---\n'
node - <<'JS'
const fs = require('fs')
const pkg = JSON.parse(fs.readFileSync('package.json','utf8'))
console.log({deps: pkg.dependencies?.@prisma/client, devDeps: pkg.devDependencies?.prisma})
JS

printf '\n--- relevant snippets ---\n'
sed -n '35,65p' src/services/api-key.services.ts
printf '\n'
sed -n '75,98p' src/services/pay.services.ts

printf '\n--- transaction-client declarations in installed Prisma ---\n'
rg -n "TransactionClient|PrismaClient|class PrismaClient|type PrismaClient" node_modules/@prisma/client -S | head -80

Repository: ShadeProtocol/shade-backend

Length of output: 256


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- package versions ---'
node - <<'JS'
const fs = require('fs')
const pkg = JSON.parse(fs.readFileSync('package.json','utf8'))
console.log({
  deps: pkg.dependencies?.['`@prisma/client`'],
  devDeps: pkg.devDependencies?.prisma
})
JS

echo
echo '--- relevant snippets ---'
sed -n '35,65p' src/services/api-key.services.ts
echo
sed -n '75,98p' src/services/pay.services.ts

echo
echo '--- transaction-client declarations in installed Prisma ---'
rg -n "TransactionClient|PrismaClient|class PrismaClient|type PrismaClient" node_modules/@prisma/client -S | head -80

Repository: ShadeProtocol/shade-backend

Length of output: 50386


Preserve transaction-client typing instead of using any.

Both changes erase compile-time validation for Prisma calls inside $transaction. Remove the annotation and let Prisma infer tx, or use the generated Prisma.PrismaClient type if your inferred transaction callback is being widened.

  • src/services/api-key.services.ts#L47-L47: preserve typing for tx.apiKey.count and tx.apiKey.create.
  • src/services/pay.services.ts#L86-L86: preserve typing for tx.invoice.findUnique and tx.paymentConfirmation.upsert.
📍 Affects 2 files
  • src/services/api-key.services.ts#L47-L47 (this comment)
  • src/services/pay.services.ts#L86-L86
🤖 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/api-key.services.ts` at line 47, Remove the explicit any
annotation from the Prisma transaction callback in
src/services/api-key.services.ts at lines 47-47 and src/services/pay.services.ts
at lines 86-86, allowing Prisma to infer the transaction client type; if
inference widens, use the generated Prisma.PrismaClient type so
tx.apiKey.count/create, tx.invoice.findUnique, and tx.paymentConfirmation.upsert
retain compile-time validation.

Comment on lines +18 to +20
const { tick, startPolling, stopPolling, getCursor, resetPoller } = await import(
'../../src/indexer/poller.js'
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the Prettier violation in the dynamic import.

The static analysis report flags this line break. Format the import as:

Proposed fix
-const { tick, startPolling, stopPolling, getCursor, resetPoller } = await import(
-  '../../src/indexer/poller.js'
-);
+const { tick, startPolling, stopPolling, getCursor, resetPoller } =
+  await import('../../src/indexer/poller.js');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { tick, startPolling, stopPolling, getCursor, resetPoller } = await import(
'../../src/indexer/poller.js'
);
const { tick, startPolling, stopPolling, getCursor, resetPoller } =
await import('../../src/indexer/poller.js');
🧰 Tools
🪛 ESLint

[error] 18-20: Replace ·await·import(⏎··'../../src/indexer/poller.js'⏎ with ⏎··await·import('../../src/indexer/poller.js'

(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 `@tests/unit/indexer.test.ts` around lines 18 - 20, Format the dynamic import
destructuring in the indexer test to satisfy Prettier, keeping the same imported
symbols and module path while using the formatter’s expected line-break style.

Source: Linters/SAST tools

Comment on lines +5 to +7
const { createMerchant, getMerchant, listMerchants } = await import(
'../../src/services/merchant.services.js'
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the Prettier violation in the dynamic import.

The current wrapping is reported by prettier/prettier; format this import on one line.

Proposed fix
-const { createMerchant, getMerchant, listMerchants } = await import(
-  '../../src/services/merchant.services.js'
-);
+const { createMerchant, getMerchant, listMerchants } = await import('../../src/services/merchant.services.js');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { createMerchant, getMerchant, listMerchants } = await import(
'../../src/services/merchant.services.js'
);
const { createMerchant, getMerchant, listMerchants } = await import('../../src/services/merchant.services.js');
🧰 Tools
🪛 ESLint

[error] 5-7: Replace ·await·import(⏎··'../../src/services/merchant.services.js'⏎ with ⏎··await·import('../../src/services/merchant.services.js'

(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 `@tests/unit/merchant.services.test.ts` around lines 5 - 7, Update the dynamic
import destructuring for createMerchant, getMerchant, and listMerchants to a
single line, preserving the existing import path and behavior.

Source: Linters/SAST tools

@codebestia

Copy link
Copy Markdown
Contributor

Please address the coderabbit reviews.

Thank you

@codebestia codebestia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM!
Nice Implementation.
Thank you for your contribution.

@codebestia
codebestia merged commit d7b5942 into ShadeProtocol:main Jul 29, 2026
3 checks passed
@grantfox-oss grantfox-oss Bot mentioned this pull request Jul 29, 2026
9 tasks
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.

Core Soroban Indexer Infrastructure

2 participants