feat: soroban indexer infra - #35
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
🚧 Files skipped from review as they are similar to previous changes (4)
📝 WalkthroughWalkthroughThe 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. ChangesSoroban indexer infrastructure
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
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
GM @codeZe-us |
There was a problem hiding this comment.
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 winReject unknown request fields before
req.bodyreaches the database.
validateRegisterMerchantandvalidateUpdateMerchantonly check allowlisted fields, so payloads such as{ firstName: "...", isAdmin: true }can still be accepted. The controller forwardsreq.bodytoregisterMerchant, and that service passes fields from the request body intoprisma.merchant.update({ where, data }), so unexpected keys are persisted. BuildregisterMerchantdata from the validated allowlist or reject unknown keys; apply the same handling toupdateMyProfile.🤖 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 winAllow events without topics, or normalize them before persistence.
The PR output shows decoded events with
topic: null, butIndexerEvent.topicis required. Persisting one of these events can fail the transaction, leave the cursor unchanged, and repeatedly retry the same event. Maketopicnullable 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 liftDo not advance past a failed event.
evt-badis omitted fromprocessedIds, buttick()then persistslatestLedger + 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 winCompiled JavaScript is committed alongside its TypeScript source. Each of these files is emitted output of a sibling
.tsfile, checked intosrc/rather than a build directory —tsconfig.jsonalready excludesdist, 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 todist/, and add the output directory to.gitignore.
src/indexer/poller.js#L22-L119: remove;src/indexer/poller.tsis the source of truth.src/indexer/registry.js#L1-L15: remove; generated fromsrc/indexer/registry.ts.src/indexer/run.js#L1-L13: remove; generated fromsrc/indexer/run.ts.src/indexer/types.js#L1-L1: remove; this is theexport {};stub emitted for the types-only modulesrc/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 winPer-event
console.logon 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 winNo teardown after the poll loop drains.
stopPolling()letsstartPolling()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 winFragile rethrow condition, and no backoff on sustained failure.
Re-reading
environment.stellar.contractIdto 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 withinstanceofstates the intent directly.The bigger issue is what this swallows: with
contractIdset, a persistent RPC outage or DB failure is logged and retried at a flat 6s forever, with no backoff and no failure ceiling. SincestartPollingthen never rejects, the.catchinrun.tsnever 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 winSet a timeout on the Soroban RPC client.
src/indexer/sorobanClient.tscreatesnew rpc.Server(environment.stellar.rpcUrl)with notimeoutoption, sogetLatestLedger()andgetEvents()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 nextisRunningcheck. 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 winHash refresh tokens before storing them.
issueRefreshToken()storescrypto.randomUUID()directly inRefreshToken.token, andprisma.refreshToken.findUnique({ where: { token } })compares that raw credential. That makestokena 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 winProtect 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 explicitisolationLevel, 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 winUnsanitized merchant records exposed via
GET /merchants/:idandGET /merchants.Both handlers return the raw Prisma
merchantrecord(s) instead of thesanitizeMerchantallow-list used bygetMyProfileController/registerMerchantController. This leaksemailOtp(bcrypt hash),emailOtpExpiresAt,merchantKey,addressto whoever can reach the route — currently anyone, since it's unauthenticated (src/routes/merchant.routes.js, Lines 14-15). ApplysanitizeMerchant()/sanitizeMerchantmapping 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 liftNo attempt limit on OTP verification.
verifyEmailOtpchecks 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. anemailOtpAttemptscounter, 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/catchwon't catch async listen failures (e.g. port already in use).
app.listendoesn'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 winHandle the unique
Merchant.emailis already@unique, butregisterMerchantstill checks email first then callsupdateoutside a transaction. Concurrent registrations for the same email can race past thefindFirstcheck; wrap the check/update inprisma.$transactionand convert the Prisma uniqueness error (P2002) into the existing409, 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 winTOCTOU on nonce single-use enforcement.
usedAtis checked (Line 34) and then set via a plainupdate(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 winOTP code and PII written to console logs.
The
consolefallback 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.providermisconfigured 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 winRestrict 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 fororigininstead 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 winUse strict, non-negative integer validation for optional ledger config.
parseIntcan silently truncate values like12abc,12.3, or1e2and also accepts negative ledgers. Keepsrc/config/environment.tsandsrc/config/environment.jsin sync by validating the full trimmed value and requiringvalue >= 0before 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 winInconsistent handling of
sendOtpfailures.
issueEmailOtppersists the OTP hash/expiry (Lines 26-29) before sending the email, and does not catch asendOtpfailure. When called fromresendEmailOtp(Line 80), an email-provider error propagates as an unhandled rejection to the controller (500), yet the resend cooldown window has already started becauseemailOtpExpiresAtwas 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 winUnvalidated numeric inputs can 500 instead of 400.
Number(req.params.id)(Line 15) andNumber(req.query.limit)/Number(req.query.offset)(Line 24) yieldNaNfor 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. defaultlimit/offset, and reject non-numericid).🤖 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 winMake merchant creation atomic by address.
Merchant.addressis declared@unique, andauthenticateWalletcan callupsertMerchantconcurrently for the same new address. The currentfindFirst+createcan race: one request may throw the Prisma unique-constraint error while the other succeeds, surfacing as a 500. Use an atomic Prisma upsert keyed onaddress, 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 winSanitize invoice email subjects before passing them to email providers.
buildInvoiceEmailContentuses raw merchant business names and invoice descriptions in thesubject;escapeHtmlonly 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 winUse a non-watch entrypoint for the standalone indexer.
npm run indexercurrently restarts the long-lived poller whenever source files change. Keep a separateindexer:watchdevelopment script and runtsx src/indexer/run.tsfor 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 | 🔵 TrivialPlan 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(orprocessedAt) 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 transactionalCREATE UNIQUE INDEXhere 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 winSilent overwrite on duplicate topic registration.
handlers.setreplaces any existing handler for the topic without warning. With ahandlers/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 winShutdown waits out the full poll interval.
stopPollingonly flips the flag; a signal arriving during thesetTimeoutsleep isn't observed until it elapses, so shutdown can take up tointervalMs(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 winN+1 dedup query.
One
findUniqueround 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 valueDrop the unused
symbolbranch indecodeTopic.At this package version,
scValToNativeconvertsscvSymbolvalues to JS strings, sotypeof native === 'symbol'is unreachable and symbols already fall through toString(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 winScope the merchant include to needed fields.
getInvoiceForPdfBySlugpulls the full merchant record viainclude: { merchant: true }, unlikeresolveInvoiceBySlug's scopedselect. 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 | 🔵 TrivialConsider rate limiting the public confirm endpoint.
POST /:slug/confirmis unauthenticated and persists a DB row per call with no proof-of-payment check (seeconfirmPaymentinsrc/services/pay.services.js). Without rate limiting, it's an easy target for spammingpaymentConfirmationrows.🤖 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 winCompiled
.jsfiles appear checked in alongside.tssources.Each of these pairs has identical logic (the
.jsfile looks like atscbuild artifact of the corresponding.tssource), 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 gitignoreddist/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.jsoutput 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 | 🔵 TrivialConsider rate limiting on auth endpoints.
None of
/nonce,/verify,/verify-email,/resend-otpappear to have request throttling in front of them./noncecan be spammed to generate unboundedauthNoncerows, and/verify-emailis subject to the OTP brute-force gap noted insrc/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 | 🔵 TrivialGood use of optimistic concurrency; one operational note.
The conditional
updateManyguard here is the right pattern (and the fix I've suggested reusing for the nonce/email races above). One operational note:privateKeyis 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 winUnreachable fallback after exhausted slug retries.
When the final retry (
attempt === SLUG_MAX_RETRIES - 1) still hits a unique-slug conflict,attempt < SLUG_MAX_RETRIES - 1isfalse, so thecatchre-throws the raw Prisma error immediately — the loop can never fall through to increment past the bound, making thethrow 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 valueLimit the merchant relation to invoice PDF/email data.
getInvoiceWithMerchant()loads the wholeMerchantrow, butgenerateInvoicePdf()andsendInvoiceEmail()only needmerchant.businessNameandmerchant.logo. Use a Prismaselectfor the invoice fields needed by those renderers plusmerchant: { 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 winDuplicated auth-guard/error-mapping boilerplate across handlers, and errors not logged.
Every handler repeats the same
if (!merchant) { 401 }check and the sametry/catch→AppErrormapping, and the genericelse res.status(500)...branches never log the underlying error. Consider extracting arequireMerchantmiddleware for the guard and a smallwithErrorHandlingwrapper (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 valueConsider reusing mail provider clients instead of constructing per call.
sendViaResendandsendViaSmtpinstantiate a freshResend/nodemailertransporter 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (56)
.env.examplepackage.jsonprisma.config.jsprisma/migrations/20260727112743_add_indexer_tables/migration.sqlprisma/schema.prismasrc/app.jssrc/config/database.jssrc/config/environment.jssrc/config/environment.tssrc/config/prisma.jssrc/controllers/api-key.controllers.jssrc/controllers/api-key.controllers.tssrc/controllers/auth.controllers.jssrc/controllers/index.jssrc/controllers/invoice.controllers.jssrc/controllers/invoice.controllers.tssrc/controllers/merchant.controllers.jssrc/controllers/pay.controllers.jssrc/controllers/pay.controllers.tssrc/entities/index.jssrc/indexer/handlers/index.jssrc/indexer/handlers/index.tssrc/indexer/poller.jssrc/indexer/poller.tssrc/indexer/registry.jssrc/indexer/registry.tssrc/indexer/run.jssrc/indexer/run.tssrc/indexer/sorobanClient.jssrc/indexer/sorobanClient.tssrc/indexer/types.jssrc/indexer/types.tssrc/middlewares/auth.middleware.jssrc/routes/auth.routes.jssrc/routes/index.jssrc/routes/invoice.routes.jssrc/routes/merchant.routes.jssrc/routes/pay.routes.jssrc/server.jssrc/services/api-key.services.jssrc/services/auth.services.jssrc/services/email.service.jssrc/services/index.jssrc/services/invoice-pdf.services.jssrc/services/invoice.services.jssrc/services/merchant.services.jssrc/services/otp.services.jssrc/services/pay.services.jssrc/services/storage/invoice-pdf.storage.jssrc/utils/api-key.utils.jssrc/utils/errors.jssrc/utils/invoice.validation.jssrc/utils/slug.jssrc/utils/validation.jstests/unit/indexer.test.tstsconfig.json
| 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', |
There was a problem hiding this comment.
🔒 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.
There was a problem hiding this comment.
🧩 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 || trueLength 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 || trueLength 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.
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
🗄️ 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
IndexerEventrow 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 inregistry.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.
| const nextCursor = | ||
| events.length === 100 && events[events.length - 1] | ||
| ? events[events.length - 1].ledger + 1 | ||
| : latestLedger + 1; |
There was a problem hiding this comment.
🗄️ 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:
- 1: https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/api.ts
- 2: https://stellar-sdk.readthedocs.io/en/13.1.0/_modules/stellar_sdk/soroban_rpc.html
- 3: https://www.alchemy.com/docs/chains/stellar/stellar-api-endpoints/get-events
- 4: https://developers.stellar.org/docs/data/apis/rpc/api-reference/structure/pagination
- 5: https://cdn.jsdelivr.net/npm/stellar-sdk@13.1.0/lib/no-eventsource/rpc/api.d.ts
- 6: https://github.com/stellar/js-stellar-sdk/blob/main/src/rpc/parsers.ts
- 7: https://github.com/stellar/stellar-rpc/releases/tag/v23.0.0
- 8: https://github.com/stellar/js-stellar-sdk/blob/v14.0.0-rc.3/CHANGELOG.md
🏁 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 || trueRepository: 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.
|
Also, Please undo the generate js files. Your implementation generated javascript files from the ts files. |
|
@codebestia fixed |
There was a problem hiding this comment.
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 winMake cursor behavior on failed events explicit.
With this implementation,
processedIdsonly includes non-throwing events, but the cursor is still advanced if the batch reaches the fulllimit. 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 winUse a value matcher for the resolved promise.
toThrow()expects a function, but.resolvespasses the promise’s resolved value to the matcher. Sincedispatch()resolves tovoid, 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 winAssert the decoded event and dispatch result.
This fixture uses an empty topic and
nullvalue, 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 winKeep the dynamic Prisma mock type instead of casting it to
any.
jest-mock-extendedalready createsmockDeep<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 winDon't commit the generated ESLint report.
eslint-report.jsonis 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 witheslint -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 winBlanket
/* eslint-disable */masks a config-scoping bug rather than fixing it.The
require/moduleerrors on this file appear because'eslint.config.cjs'is listed in anignoresarray that sits inside a config object that also declaresfiles, 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 valueRemove the dead ESM Prisma mock file.
tests/__mocks__/prisma.tsis never imported by the tests; Prisma is mocked viatests/jest.setup.tswithjest.unstable_mockModule, and tests replace../../src/config/prisma.jsdirectly withawait 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
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (26)
.prettierrceslint-report.jsoneslint.config.cjssrc/config/prisma.tssrc/controllers/auth.controllers.tssrc/controllers/merchant.controllers.tssrc/indexer/poller.tssrc/indexer/run.tssrc/services/api-key.services.tssrc/services/merchant.services.tssrc/services/pay.services.tstests/__mocks__/prisma.tstests/integration/api-key.routes.test.tstests/integration/auth.middleware.test.tstests/integration/auth.routes.test.tstests/integration/merchant.profile.test.tstests/integration/merchant.routes.test.tstests/jest.setup.tstests/unit/auth.services.test.tstests/unit/indexer.test.tstests/unit/invoice.schema.test.tstests/unit/merchant.profile.services.test.tstests/unit/merchant.services.test.tstests/unit/subscription.schema.test.tstsconfig.eslint.jsontsconfig.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) => { |
There was a problem hiding this comment.
📐 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/servicesRepository: 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 -80Repository: 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 -80Repository: 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 fortx.apiKey.countandtx.apiKey.create.src/services/pay.services.ts#L86-L86: preserve typing fortx.invoice.findUniqueandtx.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.
| const { tick, startPolling, stopPolling, getCursor, resetPoller } = await import( | ||
| '../../src/indexer/poller.js' | ||
| ); |
There was a problem hiding this comment.
📐 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.
| 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
| const { createMerchant, getMerchant, listMerchants } = await import( | ||
| '../../src/services/merchant.services.js' | ||
| ); |
There was a problem hiding this comment.
📐 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.
| 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
|
Please address the coderabbit reviews. Thank you |
codebestia
left a comment
There was a problem hiding this comment.
LGTM!
Nice Implementation.
Thank you for your contribution.
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
STELLAR_RPC_URL,STELLAR_CONTRACT_ID, and optionalSTELLAR_INDEXER_START_LEDGERtosrc/config/environment.tsand.env.example.Database Schema (
prisma/schema.prisma)IndexerCursormodel: Persists the latest processed ledger sequence per contract ID to resume polling after restarts.IndexerEventmodel: 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: StandardizedDecodedEventinterface 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:STELLAR_CONTRACT_IDis missing or empty.IndexerEvent.scValToNative.try/catchblocks so one unparseable event cannot crash the polling loop.latestLedger.sequence + 1in a single database transaction after batch completion.run.ts: Standalone entrypoint for running the indexer process independently of the Express API server, equipped with gracefulSIGINT/SIGTERMshutdown 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.tsScreenshots
No 1
No 2
No 3
Summary by CodeRabbit