Skip to content

fix: advance the cursor to the ledger actually covered - #198

Open
Richard-tobi wants to merge 29 commits into
Miracle656:mainfrom
Richard-tobi:fix-advance-cursor-covered-ledger-180
Open

Richard-tobi wants to merge 29 commits into
Miracle656:mainfrom
Richard-tobi:fix-advance-cursor-covered-ledger-180

Conversation

@Richard-tobi

Copy link
Copy Markdown

Overview

fetchEvents (src/rpc.ts) read a single getEvents page, returned the network tip (resp.latestLedger) as the highest ledger, and discarded resp.cursor — so a page truncated at limit silently dropped the rest of the range. fetchEventsSafe's happy path passed that tip straight through as highestLedger, ignoring its endLedger argument, and pollOnce committed it as the cursor. The cursor therefore jumped past target = tip - TIP_LAG, defeating the propagation buffer that exists to avoid reading un-settled ledgers.

This change drains a full page by following the RPC cursor and advances the cursor only to the ledger those pages actually covered, bounded by endLedger.

Related Issue

Miracle656/wraith#180 — Advance the cursor to the ledger actually covered, not the chain tip.

Changes

[MODIFY] src/rpc.ts

  • fetchEvents now returns a FetchEventsPage (events, latestLedger, cursor, maxLedger) instead of { events, latestLedger }. cursor is the RPC paging token; maxLedger is the highest event.ledger actually seen in the page (0 when empty). Existing fields are kept, so current destructuring callers are unaffected.
  • fetchEvents accepts an optional options.cursor and builds the cursor-shaped request ({ cursor, limit, filters }) when continuing a page, and the range-shaped request ({ startLedger, limit, filters }) otherwise — matching the SDK's mutually exclusive Api.GetEventsRequest union.
  • Added EVENTS_PAGE_BUDGET (20) and the fetchRangePaged helper, which pages with the cursor while pages return full, drops events above endLedger, and reports whether a page-budget cut the range short.
  • fetchEventsSafe's happy path now returns highestLedger = min(last ledger fully covered, endLedger): latestLedger when the range drained, otherwise the last maxLedger observed when the budget was hit. It never returns the raw tip. The XDR bisection/skip branches are unchanged.

[MODIFY] src/indexer.ts

  • pollOnce's third parameter is renamed from latestLedger to endLedger (it is the poll target, tip - TIP_LAG), and the committed cursor is clamped with Math.min(highestLedger, endLedger) so no source can advance it past the requested window. The empty-batch and normal commit paths both persist the clamped cursor.

[ADD] src/__tests__/rpcPaging.test.ts

Jest tests with a mocked RPC getEvents on the cached server (the real request/response shaping still runs):

  • a first page of exactly limit events followed by a second page proves the second page is now ingested (and that the follow-up request used the cursor);
  • highestLedger never exceeds the passed endLedger and never equals the raw tip;
  • events above endLedger are dropped instead of ingesting un-settled ledgers;
  • the page budget stops the loop and only advances to the last covered event.

Verification Results

Tests added under src/__tests__/rpcPaging.test.ts (jest, same runner as the
existing fetchEventsSafe.test.ts).
NOT EXECUTED: this fix was produced through the GitHub REST API with no local
clone, so the repo's node_modules and jest runner were unavailable. The change
was reasoned against the SDK v15.0.1 Api.GetEventsRequest union and the existing
unit tests, but no test run or tsc pass was performed here.
Acceptance Criteria Status
fetchEvents returns the paging cursor and the maximum event.ledger seen, alongside latestLedger Implemented — FetchEventsPage { events, latestLedger, cursor, maxLedger }
A full page causes the caller to page with the cursor until the range is drained or a page budget is hit Implemented — fetchRangePaged follows cursor, bounded by EVENTS_PAGE_BUDGET
highestLedger is min(last ledger fully covered, endLedger) — never the raw tip Implemented — Math.min(truncated ? maxLedger : latestLedger, endLedger)
A test with a mocked RPC returning exactly limit events proves the second page is lost on main and ingested after Test added; not executed (no local clone)
A test proves the cursor never exceeds the endLedger passed in Test added; not executed (no local clone)

Closes #180

@drips-wave

drips-wave Bot commented Sep 26, 2026

Copy link
Copy Markdown

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

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

Learn more about application limits

Miracle656 and others added 6 commits September 29, 2026 13:23
Five issues, 800 points. The headline is W094: `src/indexer.ts:491` switches
between pollOnce and pollParallel on INGEST_WORKERS, and `indexer/parallel.ts`
imports only fetchEventsSafe, parseEvents, upsertTransfers, setLastIndexedLedger
and emitTransfer — no NFT parsing, no metadata, no account summaries. Raising
the worker count for throughput silently stops indexing whole categories, with
no error to notice.

Also: /offramp/orders/:orderId serves an order with no authorization, NFT
metadata is fetched one serial round trip at a time, and every paged query pays
for a full COUNT.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
* Docs: Mainnet deployment guide (Miracle656#166)

* docs(mainnet): correct the native SAC ids, fix the backup link, add DIRECT_DATABASE_URL

Review fixes applied on top of Miracle656#211:

- The mainnet block used CDLZFC3SY…, which is the *testnet* native XLM SAC
  (Asset.native().contractId(Networks.TESTNET)). Mainnet is CAS3J7GY…
  (Networks.PUBLIC). The testnet block used CDMLFMKMM…, which is not the
  native SAC on either network. Both corrected, with the derivation inlined
  so the next reader can check rather than trust.
- Added a warning that SAC_CONTRACT_IDS must be set explicitly on mainnet,
  because the built-in fallback in src/indexer.ts is the wrong address.
- ../W076 did not resolve to anything; pointed at ./backup-restore.md.
- Added DIRECT_DATABASE_URL to both env blocks — prisma/schema.prisma
  declares directUrl, and boot-time schema sync fails without it.
- Linked ./DUAL_NETWORK.md for the NETWORKS=testnet,mainnet option.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

---------

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>
* fix(offramp): require a bearer token to read an order

GET /offramp/orders/:orderId served any order to whoever held its id: the
bank payout amount, the deposit address and the rate.

Orders now get a random public id (ofr_ + 128 bits) in place of the
provider's, and the creator is handed a bearer token (oft_ + 256 bits) once,
in the create response. Only its SHA-256 is stored. Lookups need
Authorization: Bearer; a missing header is 401, and an unknown id, a
malformed id and a wrong token are all the same 404.

Failed lookups are rate-limited separately from the app-wide limiter (10 per
15 minutes per IP by default); successful polling does not count.

Replaying a creation with the same idempotencyKey now also needs the same
walletAddress (409 otherwise) and re-issues the token, since only its hash is
kept. The lookup response no longer names the provider (source: live) or
returns its id, and a database failure returns a generic 500 instead of
reaching the global handler.

* test(offramp): use a valid strkey for the second wallet fixture

GBBB...SAM was 56 characters but failed StrKey.isValidEd25519PublicKey.
The test passes either way today, because the route does not validate the
address, but a fixture that is not a real strkey breaks the moment it is
handed to anything that decodes one.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

---------

Co-authored-by: blockchain-maxis <267648998+blockchain-maxis@users.noreply.github.com>
Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>
…xer (Miracle656#197)

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>
…allel ingest paths (Miracle656#219)

Co-authored-by: DevTobis <232918735+DevTobis@users.noreply.github.com>

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This is strong work — the diagnosis is right, the cursor-paging drain is the correct shape, and the four tests in src/__tests__/rpcPaging.test.ts all pass locally against your branch (57 passed across rpcPaging + fetchEventsSafe + indexerSources + multiNetworkIndexer; tsc --noEmit and tsc -p tsconfig.test.json both clean after npx prisma generate). I checked the drain logic in both directions and I could not make it commit a cursor past unindexed data on the pollOnce path: the maxLedger >= endLedger break at fetchRangePaged and the page-budget break can both leave the top ledger partially covered, but pollOnce re-reads fromLedger inclusively, so that ledger is picked up on the next poll. That is correct, and it is worth a comment saying so, because it is the load-bearing assumption of the whole design.

What I can't merge yet is that the invariant your own docstring states — "the highest ledger fully covered by them — never the raw network tip" — is not upheld on two sibling paths, and both of them lose ledgers permanently.

1. src/indexer/parallel.ts — pollParallel takes the max, not the min

const highestLedger = results.reduce(
  (max, r) => Math.max(max, r.highestLedger),
  fromLedger,
);
await setLastIndexedLedger(highestLedger, net);

Before your change every worker returned the identical network tip, so Math.max was meaningless. Your change is what makes the per-worker values diverge: a partition whose pages hit EVENTS_PAGE_BUDGET now returns a genuinely lower highestLedger than a partition that drained cleanly. Math.max throws that signal away and commits the higher one, so the truncated partition's ledgers are skipped and never revisited — the exact failure #180 is about, just on the INGEST_WORKERS > 1 path. The committed cursor has to be the minimum across workers (seeded from toLedger, then floored at fromLedger so an empty window still makes progress). pollParallel also never clamps to toLedger, which the new pollOnce does.

2. src/rpc.ts — the bisect path still returns the raw tip

The single-ledger branch is untouched:

if (startLedger >= endLedger) {
  const { events, latestLedger } = await _fetchFn(startLedger, contractIds, limit, network);
  return { events, highestLedger: Math.max(startLedger, latestLedger) };

and the catch returns Math.max(lower.highestLedger, upper.highestLedger) with no clamp. So one XDR decode error anywhere in the range makes fetchEventsSafe hand back the network tip. pollOnce's new Math.min(highestLedger, endLedger) saves the serial path — that defensive clamp was a good instinct — but pollParallel has no such clamp, so on the parallel path an XDR error jumps the cursor straight to the tip and everything between is gone. Clamping inside fetchEventsSafe (both the single-ledger return and the bisect return) is the fix, and it makes the function actually honour its docstring for every caller, including src/ingest/backfill.ts.

A test for each would be good: one pollParallel case where one worker is truncated and one is not, asserting the committed ledger is the truncated one; and one fetchEventsSafe case where the bisect fires, asserting highestLedger <= endLedger.

Smaller things, not blockers:

  • EVENTS_PAGE_BUDGET = 20 with BATCH_SIZE defaulting to 10 000 means a single ledger would need ~200k matching events to livelock the loop (maxLedger === fromLedger → cursor does not advance). Not reachable in practice, but a one-line comment saying why the budget is safe would stop the next reader from having to work it out.
  • fetchRangePaged takes _fetchFn but the XDR bisect only wraps the whole range, so a decode error on page 17 re-fetches pages 1–16. Fine for now; worth a note.

Thanks — this is the right fix and the paging drain is the hard part. Wire the same clamp through the parallel and bisect paths and I'll merge it.

https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

boalambo and others added 20 commits September 30, 2026 19:35
…iracle656#220)

* Bring docker-compose.yml in line with env contract (Miracle656#194)

- Remove obsolete version: "3.9" key
- Add all missing env keys from .env.example:
  - DIRECT_DATABASE_URL
  - STELLAR_NETWORK
  - SOROBAN_RPC_URL (with STELLAR_RPC_URL as backward-compat alias)
  - NETWORKS
  - SAC_CONTRACT_IDS (and per-network variants)
  - NFT_CONTRACT_IDS (and per-network variants)
  - RETENTION_DAYS
  - CACHE_ENABLED and all Redis cache config
  - TOMBSTONE_CHECK_EVERY_CYCLES
  - LP_POOL_CONTRACT_IDS variants
  - SKIP_INDEXER
- Keep CONTRACT_IDS as documented backward-compat alias
- Add optional redis service with cache profile for CACHE_ENABLED support
- Update README to use SAC_CONTRACT_IDS and add cache profile instructions
- Add test to validate compose file meets requirements

* test(compose): derive the env-contract check from .env.example

The drift test built envKeys from .env.example and then never used it,
asserting against a hand-maintained list instead — so a key added to
.env.example and forgotten in docker-compose.yml still passed, which is the
one failure Miracle656#194 is about. The assertions were also substring matches on the
whole file: toContain("SAC_CONTRACT_IDS") is satisfied by
SAC_CONTRACT_IDS_TESTNET, and toContain("CONTRACT_IDS") by either, so they
held on a file declaring none of them.

Now it parses the wraith service's own environment block (anchored on the
service, since db has an environment block too) and compares declared keys
against every uncommented key in .env.example, with an explicit, empty
exclusion list. Verified it fails on an added key and passes without one.

Also: the docker compose config case ran unconditionally and called fail(),
which is not defined under jest-circus, so on a machine or CI runner without
Docker it failed with a ReferenceError. Unit tests here do not require
Docker (the integration suite is vitest + Docker), so it now skips instead.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

---------

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>
* fix(cache): include resolved network in Redis cache key (Miracle656#182)

defaultKeyFn now prepends req.network (set by networkMiddleware) to the cache key so mainnet and testnet requests never collide, even when the network is supplied via the X-Network header rather than ?network=.

The ?network= query param is filtered from the query segment since it is already captured by req.network, ensuring ?network=mainnet and X-Network: mainnet produce identical keys.

Existing keys change shape and will expire naturally over their TTL.

* fix(cache): include resolved network in Redis cache key (Miracle656#182)

* chore: drop the unrelated package-lock.json change

The branch re-resolved fsevents and dropped its "dev": true marker. Nothing
in this PR touches dependencies, so restore the lockfile to main's.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

---------

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>
- Create shared fixture module in src/fixtures.ts with deterministic test data
  across two addresses (ALICE, BOB, CAROL) and two contracts (CONTRACT_A, CONTRACT_B)
- Add seed script at scripts/seed.ts with --network flag support (testnet/mainnet)
- Add db:seed script to package.json
- Update integration tests to use shared fixture module instead of local copy
- Update README Quick Start with seed step and real sample output
- Add fixture validation test to verify data structure and coverage

The seed script uses skipDuplicates on unique constraints, making it safe to
run multiple times without duplicating rows. Fixtures include TokenTransfer,
NftTransfer, and AccountSummary rows with deterministic eventId values.

Resolves Miracle656#193
Follow-up to Miracle656#221: this fix was pushed to the PR branch but did not make it
into the squash. The docstring advertised the space-separated form while
parseArgs matched only --network=, so 'npm run db:seed -- --network mainnet'
silently seeded testnet. Both spellings now parse; an unknown value still
exits 1.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
…opt-in includeTotal (Miracle656#218)

Co-authored-by: royalTreasure <295874283+royalTreasure@users.noreply.github.com>
…656#212)

* feat: instrument HTTP surface in Prometheus

* fix(metrics): instrument before networkMiddleware and the rate limiter

Mounted after them, the HTTP middleware never saw the requests those two
reject, so 429s and invalid-?network= 400s were absent from
http_requests_total. Moved to the top of the chain, right after cors().

Also documents the two new metrics in the README table and records why the
route label must stay req.route.path: req.baseUrl is the matched mount path,
and src/api/accounts.ts mounts a router at "/:address/transfers", so baseUrl
carries the real address and would make the label unbounded.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

---------

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>
…ycle budget (Miracle656#217)

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>
* Use token decimals for display amounts

* docs(wave): W094-W098 batch draft (published as Miracle656#203-207)

Five issues, 800 points. The headline is W094: `src/indexer.ts:491` switches
between pollOnce and pollParallel on INGEST_WORKERS, and `indexer/parallel.ts`
imports only fetchEventsSafe, parseEvents, upsertTransfers, setLastIndexedLedger
and emitTransfer — no NFT parsing, no metadata, no account summaries. Raising
the worker count for throughput silently stops indexing whole categories, with
no error to notice.

Also: /offramp/orders/:orderId serves an order with no authorization, NFT
metadata is fetched one serial round trip at a time, and every paged query pays
for a full COUNT.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* Docs: Mainnet deployment guide (Miracle656#166) (Miracle656#211)

* Docs: Mainnet deployment guide (Miracle656#166)

* docs(mainnet): correct the native SAC ids, fix the backup link, add DIRECT_DATABASE_URL

Review fixes applied on top of Miracle656#211:

- The mainnet block used CDLZFC3SY…, which is the *testnet* native XLM SAC
  (Asset.native().contractId(Networks.TESTNET)). Mainnet is CAS3J7GY…
  (Networks.PUBLIC). The testnet block used CDMLFMKMM…, which is not the
  native SAC on either network. Both corrected, with the derivation inlined
  so the next reader can check rather than trust.
- Added a warning that SAC_CONTRACT_IDS must be set explicitly on mainnet,
  because the built-in fallback in src/indexer.ts is the wrong address.
- ../W076 did not resolve to anything; pointed at ./backup-restore.md.
- Added DIRECT_DATABASE_URL to both env blocks — prisma/schema.prisma
  declares directUrl, and boot-time schema sync fails without it.
- Linked ./DUAL_NETWORK.md for the NETWORKS=testnet,mainnet option.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

---------

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>

* fix: narrow XDR error classification (Miracle656#209)

* fix(offramp): require a bearer token to read an order (Miracle656#216)

* fix(offramp): require a bearer token to read an order

GET /offramp/orders/:orderId served any order to whoever held its id: the
bank payout amount, the deposit address and the rate.

Orders now get a random public id (ofr_ + 128 bits) in place of the
provider's, and the creator is handed a bearer token (oft_ + 256 bits) once,
in the create response. Only its SHA-256 is stored. Lookups need
Authorization: Bearer; a missing header is 401, and an unknown id, a
malformed id and a wrong token are all the same 404.

Failed lookups are rate-limited separately from the app-wide limiter (10 per
15 minutes per IP by default); successful polling does not count.

Replaying a creation with the same idempotencyKey now also needs the same
walletAddress (409 otherwise) and re-issues the token, since only its hash is
kept. The lookup response no longer names the provider (source: live) or
returns its id, and a database failure returns a generic 500 instead of
reaching the global handler.

* test(offramp): use a valid strkey for the second wallet fixture

GBBB...SAM was 56 characters but failed StrKey.isValidEd25519PublicKey.
The test passes either way today, because the route does not validate the
address, but a fixture that is not a real strkey breaks the moment it is
handed to anything that decodes one.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

---------

Co-authored-by: blockchain-maxis <267648998+blockchain-maxis@users.noreply.github.com>
Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>

* fix: skip malformed events in parseEvents instead of wedging the indexer (Miracle656#197)

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>

* fix(indexer): share one per-batch pipeline between the single and parallel ingest paths (Miracle656#219)

Co-authored-by: DevTobis <232918735+DevTobis@users.noreply.github.com>

* Bring docker-compose.yml in line with env contract (Miracle656#194) (Miracle656#220)

* Bring docker-compose.yml in line with env contract (Miracle656#194)

- Remove obsolete version: "3.9" key
- Add all missing env keys from .env.example:
  - DIRECT_DATABASE_URL
  - STELLAR_NETWORK
  - SOROBAN_RPC_URL (with STELLAR_RPC_URL as backward-compat alias)
  - NETWORKS
  - SAC_CONTRACT_IDS (and per-network variants)
  - NFT_CONTRACT_IDS (and per-network variants)
  - RETENTION_DAYS
  - CACHE_ENABLED and all Redis cache config
  - TOMBSTONE_CHECK_EVERY_CYCLES
  - LP_POOL_CONTRACT_IDS variants
  - SKIP_INDEXER
- Keep CONTRACT_IDS as documented backward-compat alias
- Add optional redis service with cache profile for CACHE_ENABLED support
- Update README to use SAC_CONTRACT_IDS and add cache profile instructions
- Add test to validate compose file meets requirements

* test(compose): derive the env-contract check from .env.example

The drift test built envKeys from .env.example and then never used it,
asserting against a hand-maintained list instead — so a key added to
.env.example and forgotten in docker-compose.yml still passed, which is the
one failure Miracle656#194 is about. The assertions were also substring matches on the
whole file: toContain("SAC_CONTRACT_IDS") is satisfied by
SAC_CONTRACT_IDS_TESTNET, and toContain("CONTRACT_IDS") by either, so they
held on a file declaring none of them.

Now it parses the wraith service's own environment block (anchored on the
service, since db has an environment block too) and compares declared keys
against every uncommented key in .env.example, with an explicit, empty
exclusion list. Verified it fails on an added key and passes without one.

Also: the docker compose config case ran unconditionally and called fail(),
which is not defined under jest-circus, so on a machine or CI runner without
Docker it failed with a ReferenceError. Unit tests here do not require
Docker (the integration suite is vitest + Docker), so it now skips instead.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

---------

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>

* Fix/cache network key (Miracle656#202)

* fix(cache): include resolved network in Redis cache key (Miracle656#182)

defaultKeyFn now prepends req.network (set by networkMiddleware) to the cache key so mainnet and testnet requests never collide, even when the network is supplied via the X-Network header rather than ?network=.

The ?network= query param is filtered from the query segment since it is already captured by req.network, ensuring ?network=mainnet and X-Network: mainnet produce identical keys.

Existing keys change shape and will expire naturally over their TTL.

* fix(cache): include resolved network in Redis cache key (Miracle656#182)

* chore: drop the unrelated package-lock.json change

The branch re-resolved fsevents and dropped its "dev": true marker. Nothing
in this PR touches dependencies, so restore the lockfile to main's.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

---------

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>

* Add npm run db:seed with deterministic fixtures (Miracle656#221)

- Create shared fixture module in src/fixtures.ts with deterministic test data
  across two addresses (ALICE, BOB, CAROL) and two contracts (CONTRACT_A, CONTRACT_B)
- Add seed script at scripts/seed.ts with --network flag support (testnet/mainnet)
- Add db:seed script to package.json
- Update integration tests to use shared fixture module instead of local copy
- Update README Quick Start with seed step and real sample output
- Add fixture validation test to verify data structure and coverage

The seed script uses skipDuplicates on unique constraints, making it safe to
run multiple times without duplicating rows. Fixtures include TokenTransfer,
NftTransfer, and AccountSummary rows with deterministic eventId values.

Resolves Miracle656#193

* fix(seed): accept --network mainnet as well as --network=mainnet

Follow-up to Miracle656#221: this fix was pushed to the PR branch but did not make it
into the squash. The docstring advertised the space-separated form while
parseArgs matched only --network=, so 'npm run db:seed -- --network mainnet'
silently seeded testnet. Both spellings now parse; an unknown value still
exits 1.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

* Fix token decimals review issues

* perf(api): drop the default COUNT from list queries, add hasMore and opt-in includeTotal (Miracle656#218)

Co-authored-by: royalTreasure <295874283+royalTreasure@users.noreply.github.com>

* feat: instrument HTTP surface in Prometheus (Miracle656#189) (Miracle656#212)

* feat: instrument HTTP surface in Prometheus

* fix(metrics): instrument before networkMiddleware and the rate limiter

Mounted after them, the HTTP middleware never saw the requests those two
reject, so 429s and invalid-?network= 400s were absent from
http_requests_total. Moved to the top of the chain, right after cors().

Also documents the two new metrics in the README table and records why the
route label must stay req.route.path: req.baseUrl is the matched mount path,
and src/api/accounts.ts mounts a router at "/:address/transfers", so baseUrl
carries the real address and would make the label unbounded.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

---------

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>

* fix(indexer): bound NFT metadata lookups with a worker pool and per-cycle budget (Miracle656#217)

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>

---------

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>
Co-authored-by: Gloria <glorious217@gmail.com>
Co-authored-by: Jemimah <ekongjemimah@gmail.com>
Co-authored-by: blockchain-maxis <blockchainmaxis@gmail.com>
Co-authored-by: blockchain-maxis <267648998+blockchain-maxis@users.noreply.github.com>
Co-authored-by: Apulupie <167634780+Frun1na@users.noreply.github.com>
Co-authored-by: Tobiz <deborahayoola2000@gmail.com>
Co-authored-by: DevTobis <232918735+DevTobis@users.noreply.github.com>
Co-authored-by: BOA <97275013+boalambo@users.noreply.github.com>
Co-authored-by: Bathoul Mohammed <funds0033@gmail.com>
Co-authored-by: royaldev <chiditreasure15@gmail.com>
Co-authored-by: royalTreasure <295874283+royalTreasure@users.noreply.github.com>
Co-authored-by: Collins Ezedike-egwom <62267326+collinsezedike@users.noreply.github.com>
Co-authored-by: That guy <120946193+ezedike-evan@users.noreply.github.com>
Found while reviewing Miracle656#210, but this is a bug on `main`, not in the PR.

`POST /offramp/orders` looks an order up by `(network, idempotencyKey)` and,
once the wallet matches, returns 200 with `replayed: true` — carrying the
**first** order's amount, rate and deposit address. It never compared the
amounts. So a caller that reused a key with a changed amount was answered

    200 { amountNGN: <the OLD figure>, replayed: true }

and reads that as "my payout was accepted". The money follows the original
order. On the only money-moving router in this service, a silent success for
a request nobody made is the worst available outcome.

An idempotency key promises "this key means this request". Returning a
different request's result under a 200 breaks that promise quietly, which is
the part that matters — a 409 is recoverable, a wrong confirmed amount is not.

Now a mismatch is refused in the same class as the existing wallet-mismatch
409. Compared numerically, because both sides are decimal strings: "50" and
"50.00" are the same order and must not 409, and there is a test for that so
the guard cannot be tightened into a false refusal later.

**Partial, deliberately.** `OfframpOrder` stores the amounts but not
`bankAccount`, `bankCode`, `bankName` or `accountName`, so a replay that
changes only the *destination* still passes. Closing that needs a stored hash
of the idempotency-relevant fields — a new column and a migration, on a
database this service shares with Lens that is already near its tier's
storage limit, and whose deploy has not run since 2026-09-23. That is a
decision to take deliberately rather than a fix to slip into a review pass, so
it is called out in the code comment rather than guessed at.

Verified: reverting only the source change leaves the new test failing
(1 failed / 21 passed); with it, 22 pass. Full suite 47 suites / 564 tests,
`tsc --noEmit` clean. (`npx prisma generate` is required first or the client
is stale and `publicId` appears to not exist.)

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
First slice of the NGN onramp: naira in, crypto out. This closes the
cold-start problem no amount of wallet code can — a new Stellar account needs
1 XLM of base reserve plus 0.5 XLM per trustline before it can hold a single
USDC, and the only ways to get that first XLM were to already own crypto or
for us to fund it.

Server-side only, like the offramp client, because the API key is the ability
to spend and anything in an APK is extractable.

Covers the four B2B-native endpoints: provision a customer, verify them by
NIN, read the current rate, create and poll an order.

## The NIN rule

`submitCustomerKyc` takes a National Identification Number. It is never
stored, never logged, never returned, and there is deliberately no `nin` field
on any result type, so there is nothing to accidentally persist or put in an
error report. The tests assert that negative directly: the number appears in
exactly one request body, not in the URL, not in the response, and not in a
thrown error's message or own properties.

A NIN is Nigerian personal data under the NDPA and the cheapest way to hold it
correctly is not to hold it. Before this, Veil handled no PII at all — every
KYC touchpoint was a hand-off to an anchor's own hosted flow. That property is
now gone, which is a change in what this service is, so the constraint is
written into the module header rather than left as a convention.

`submitCustomerKyc` is also deliberately not retried: a verification attempt
is rate-limited on Linq's side and touches their identity provider, so a
timeout is reported rather than replayed. Submitting the same person's
identity twice to recover from our own timeout is not a trade worth making.

## Mainnet only, enforced

Linq has no sandbox. A Stellar `G…` address is valid on both networks, so an
onramp order placed from a testnet session would take real naira from a real
bank account and deliver real XLM on mainnet, to an address the testnet wallet
will never display — the user pays and sees nothing. `assertOnrampNetwork`
refuses anything but mainnet rather than leaving that to a caller to remember.

## Other decisions worth knowing

- **Coin→chain mapping is explicit and total.** `xlm` → `{ xlm: true }`,
  `usdc` → `{ stellar: true }`, with an exhaustiveness guard so a new coin
  fails to compile. Linq's default chain is Sui and the offramp client already
  warns that funds sent on the wrong chain are unrecoverable; a silent fallback
  to a default is the shape of that loss.
- **`/onramprate` returns a bare number**, unlike the offramp's `/b2b/rate`
  object, and for an XLM order it is NGN-per-XLM, not NGN-per-USD. It is
  parsed defensively and a zero or unparseable rate is refused rather than
  priced into an order. Fetch it immediately before creating an order; never
  cache it.
- **Nothing logs a request body.** These bodies carry a NIN plus a customer's
  name, email and phone, and an exception handler that prints the body it
  failed on is how personal data reaches a log aggregator.
- **Status requires both `customerRef` and `orderId`.** Linq returns the same
  404 for another customer's order as for one that does not exist, so a guessed
  id cannot confirm an order exists. A lookup taking `orderId` alone would
  throw that away.
- `customerRef` is the wallet address, matching what the offramp already sends,
  so the same person is the same customer on both sides of the rail.

48 suites / 588 tests, `tsc --noEmit` clean.

Not yet wired to a route or to the apps, and not to be offered to users before
the regulatory position is settled — see the licensing section of
docs/NGN_RAILS.md. Building is not arranging; offering is.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Airtime, data, electricity, cable TV and betting, paid for out of the user's
own Veil wallet.

This is the flow to lead with. The onramp makes the user leave the app — we
hand over bank details and they go to their banking app to move naira. Bills
are the reverse: the deposit is crypto, from a wallet we already hold the
signer for, so the whole thing happens in one place.

## Two traps in this API, both pinned by tests

**`xlm` is omitted, never `false`.** Linq's docs say to "drop `xlm` entirely
rather than setting it to `false`" to pay in USDC. A literal `xlm: false` is
not the documented way to ask for USDC, and this endpoint vends real airtime
against a real deposit, so `buildCoinFields` returns a spreadable object with
the key absent rather than trusting their parser to read a falsy value the way
we meant it. The test asserts the absence with `hasOwnProperty`, not
`toBeFalsy`, because those are different bugs.

**`refundAddress` is required, and it is not decorative.** It is where the
deposit goes if the biller rejects the top-up *after* the user has paid —
their XLM does not un-spend itself. A missing one is only discovered at that
moment, so it is refused here instead. It must be an address the user
controls: their own wallet, never ours.

## Other decisions

- Nothing logs a request body. These carry the customer's phone number and, for
  electricity, their meter number.
- Amount and rate guards run before the request, so a zero amount cannot reach a
  provider that moves money.
- Status needs both `customerRef` and `orderId`: another customer's order 404s
  identically to one that does not exist, so a guessed id cannot confirm an
  order exists. A lookup by id alone would throw that away.
- Only `airtime` is wired end to end for now. The other categories carry extra
  fields — a data plan, a meter type — that deserve their own validation rather
  than being passed through untyped.

Linq settles at whatever amount actually arrives ("a manual deposit by
design"), so the amount sent has to be the amount quoted: an underpayment is
not a failed order, it is a smaller bill than the user asked for.

49 suites / 600 tests, `tsc --noEmit` clean.

Server-side only and not wired to a route or the app yet. Not to be offered to
users before the regulatory position is settled — see the licensing section of
docs/NGN_RAILS.md.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Mounts the two Linq clients behind `/ngn`, so the app never holds the API key
— it creates orders that move real naira, and anything in an APK is
extractable.

## The guard the design needs

The design shows a `C…` smart-wallet contract as both the onramp delivery
destination and the bill refund destination. Neither can work: Linq pays by
classic operation, and a classic payment cannot name a contract as its
destination. `receive.tsx` already carries a comment about making exactly this
mistake — it "used to lead with the C and call it 'use this for most
senders', which was exactly backwards" — and the offramp route already rejects
a contract refund address.

So `rejectContractAddress` refuses a `C…` on both `walletAddress` and
`refundAddress`, with a message that says why rather than just "invalid". The
refund case is the worse one: it fails at the moment the user has already paid
and been told their money is coming back.

## No order table, deliberately

The offramp persists because it mints its own `publicId` and bearer token so a
provider id never reaches a client. These rails do not need that, and a table
would be the more dangerous choice. Linq's status endpoints require **both**
`customerRef` and `orderId` and return the same 404 for another customer's
order as for a missing one, and `orderId` is an unguessable UUID — so a wallet
address, which is semi-public, buys nothing alone. That is the same property
`publicId` provides, already enforced upstream.

What must never be added is a route listing orders by `customerRef` alone.
That would turn a public identifier into a way to read someone's bank details,
and it is the one change here that silently removes the protection. Said so in
the module header, and the tests assert both lookups refuse a missing
`customerRef`.

## Other decisions

- **Mainnet gate on the whole router**, not per route. Linq has no sandbox, and
  a `G…` address is valid on both networks, so a testnet order would take real
  naira and deliver real crypto to an address the testnet wallet never shows.
- **The onramp rate is never cached.** The offramp caches its rate because it
  is explicitly indicative; this one is the number locked into the order, and
  XLM floats. Every ask is a fresh read, `no-store`.
- **The NIN is shape-checked here** (11 digits) so an obvious typo does not
  spend a verification attempt, which is rate-limited on Linq's side. It is
  passed straight through, never logged, and the test asserts it is absent from
  the response body — which is what a client may log.
- **503, not 500, when the key is missing**, so a client can gate the CTA. True
  of any build without the secret.
- Failed lookups are rate-limited with `skipSuccessfulRequests`, so a wallet
  polling its own open order never spends the budget while someone trying ids
  they do not own exhausts it.

50 suites / 620 tests, `tsc --noEmit` clean.

Still server-side only. Not to be offered to users before the regulatory
position is settled — see the licensing section of docs/NGN_RAILS.md.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
`src/index.ts` ran `prisma db push --accept-data-loss` on every startup. With
that flag, any change Prisma reads as destructive — a renamed column, a
narrowed type, a dropped field — was applied silently on the next deploy,
taking the column and everything in it.

This database holds `OfframpOrder`: records of real naira paid to real bank
accounts, and soon the naira onramp too. A deploy that refuses to start is a
problem someone fixes in minutes. A column of payment records that disappeared
during a routine deploy is not recoverable, and nothing would have reported it
— `--accept-data-loss` is the flag that turns the question off.

Removing it keeps every additive change working on its own: a new table or a
new nullable column still applies without intervention, which is what the
startup sync exists for. Only a destructive diff now stops the boot, with a
message saying nothing was dropped and what to do instead.

## Why not `migrate deploy`

Because the migration history cannot support it, which is worth writing down
since this keeps coming up. `prisma/migrations` holds 7 migrations creating 5
tables; the schema defines 15. `TokenTransfer`, `NftTransfer`,
`AccountSummary`, `IndexerState`, `WebhookSubscription` and `OfframpOrder` have
no migration at all — this database was built by `db push` from the start and
the migrations directory was never the source of truth.

So `migrate deploy` on a fresh database would create five tables and leave the
app crashing on the other ten, and on the existing one it would fail outright.
Making it work needs a baseline migration plus a one-off
`prisma migrate resolve --applied` against production, which needs database
access this change does not have. That is the proper fix and it is worth doing;
it is not a prerequisite for shipping, because additive changes converge fine.

Also adds the `migration_lock.toml` that directory was missing, so the
migration tooling can read it at all when someone does baseline it.

50 suites / 620 tests, `tsc --noEmit` clean.
Three rails move money between naira and a wallet and all of them go
through Linq, but until now the only written record was the module
headers. This collects the parts a client never sees: why the API key
cannot live in the APK, why the whole /ngn surface is refused on testnet
at the router level rather than by convention, and why neither the
onramp nor bills gets an order table.

The two field traps are written down because both are provider-shaped
and neither is guessable: bills take a bare top-level `xlm: true` and
omit the key entirely for USDC rather than sending false, while the
onramp takes `{ xlm: true }` or `{ stellar: true }` under `coin`. Same
provider, same concept, two shapes.

Also records what is not built — no webhook handling for onramp or bill
orders, so their status is pull-only — and the one change that would
silently remove the protection standing in for the offramp's bearer
token: a route listing orders by customerRef alone.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Bill outcomes are off-chain, and that is the whole reason this exists.
When Linq delivers XLM or USDC for an onramp, the indexer sees the
payment arrive at the wallet's classic account and the existing address
subscription already pushes it — the onramp never needed a row to be
observable. A biller vending airtime, or refusing to after the user has
already paid, is invisible to the chain. Until now `order.failed`
arrived for an order we could not identify, and the only thing that ever
learned about a failed bill was a client that happened to poll.

So there is now an `NgnOrder` row, and the webhook tries it when an
event's orderId is not an offramp order. One endpoint carries all three
rails and the body does not say which, so reconciliation is "find the
order that id belongs to".

It grants no new read access, which was the objection to a table in the
first place. `findNgnOrder` takes both identifiers, matches the
customerRef rather than accepting it, and returns null for another
customer's row — indistinguishable from absent, exactly as Linq's own
404 is. There is deliberately no index on customerRef so that listing a
customer's orders never looks cheap, and no route may do it.

Two things are deliberately not stored. `customerId` — the phone number
for airtime, the meter number for electricity — goes to Linq and stops
there, under the same rule that keeps the NIN out of every persisted
shape. A test asserts it does not reach the record.

Recording happens after the response and never throws: the user is
holding bank details they need, and our bookkeeping must not stand
between them and that. If the write fails it is logged loudly and the
rail degrades to precisely the behaviour it had before this commit —
which the route tests demonstrate, since they run without a database.

Also adds a fallback: when Linq answers 5xx or times out, a status read
is served from the record marked `stale: true` rather than failing. A
4xx passes through untouched — that is Linq saying something true about
the request, and a last-known row would turn "this order is not yours"
into a status page.

50 suites / 625 tests green, tsc clean.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Asked and answered 2026-10-03: the NIN check is NIN only — no liveness,
no face-match against the NIMC photo — and they do not expose the
sender's bank account name on an onramp order. A NIN that has already
verified one customer cannot verify a second.

That last part changes the shape of the risk rather than removing it. A
stolen NIN still verifies, because NIN slips circulate widely and the
check proves possession of a number; uniqueness bounds it to one account
rather than a farm, but that account's KYC record still names an
innocent person.

It also kills the cheap mitigation. Matching the sender's bank account
name to the verified name would force an attacker to hold an account in
the victim's name, and Nigerian accounts are BVN-bound — but Linq does
not expose the payer, so it cannot be built on their data. Worth noting
the f3 design screen renders "From · GTBank ··4821", which this does not
support.

And it makes a drifting customerRef a lockout rather than an
inconvenience: a new reference is a stranger whose NIN is already spent.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
wraith is in a crash loop on Render, and it is my guard doing it:

  • A unique constraint covering the columns `[network,publicId]` on the
    table `OfframpOrder` will be added. If there are existing duplicate
    values, this will fail.
  Error: Use the --accept-data-loss flag ...
  [wraith] Schema convergence refused: the pending change is destructive.
  ==> Exited with status 1

It is not destructive. Adding a unique index either builds or fails on a
duplicate; no row is dropped either way. Prisma offers one flag for two
very different consents, and the guard treated them as one thing — so a
change that cannot destroy data took the service down, which is a worse
outcome than the one the guard exists to prevent.

The warnings are now read rather than counted. If every bullet is an
added unique constraint, consent and retry. If even one is a dropped
column or a narrowed type, refuse exactly as before. All-or-nothing is
what keeps it safe: a real drop hiding behind a harmless index still
refuses.

An empty warning list refuses too, deliberately — a failure with nothing
to read is a connection error or a bad DATABASE_URL, and retrying those
with --accept-data-loss would be consenting to nothing in particular.

Moved both helpers to src/schemaGuard.ts so they can be tested without
booting the server; importing index.ts runs main(). Six tests, using the
Render output verbatim, including the drop-hidden-behind-a-constraint
case.

tsc clean, 51 suites / 631 tests green.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
The deploy got past the schema guard and then died here:

  Error: [wraith] SOROBAN_RPC_URL_MAINNET is required to index mainnet.
      at startAllIndexers (indexer.js:380)
      at main (index.js:119)

startAllIndexers already documented the behaviour it wanted — "a mainnet
RPC key expiring must not stop testnet indexing" — but validated the
whole set in one call, so one unset variable threw during main() and
took the process down. The per-loop restart below it never got a chance
to matter, because nothing had started yet.

It cost more than indexing. The HTTP API had already logged "API
listening" and went down with it, and the naira rails are plain HTTP to
Linq that never touch Soroban RPC — so /ngn could not answer because of
a variable it does not read.

Each network is now validated on its own. A bad one is skipped with a
message naming it and what the consequence is; the rest start. If none
is configured the API still serves and says that nothing is being
indexed, rather than exiting.

This is the behaviour the comment claimed. Now the code does it.

51 suites / 631 tests green.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
The naira rails came up and immediately said something untrue:

  GET /ngn/onramp/rate  ->  {"rate":1437.45,"stale":true}

That rate was fetched from Linq seconds earlier. The stale-read
middleware marks any GET body when RPC health fails, and mainnet has no
indexer configured, so every mainnet response was being labelled stale —
including ones that never came from indexed data in the first place.
/ngn and /offramp proxy a payment provider; ledger indexing says nothing
about whether their answer is current.

The collision is the worse half. /ngn already uses `stale: true` for a
different thing: "Linq was unreachable, this is our last known record",
which the client renders as a warning. The middleware writing the same
key means the app would tell someone the payment provider is down while
it is answering fine — and would do it on the screen where they are
about to send money.

Both prefixes now skip the middleware. Everything that genuinely serves
indexed data is unchanged.

51 suites / 631 tests green.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
pollParallel took Math.max over the per-worker highestLedger. That was
meaningless while every worker returned the same network tip, but it now
discards a truncated partition's signal: a partition cut short by
EVENTS_PAGE_BUDGET reports a genuinely lower ledger, and committing the higher
one skipped its ledgers permanently — Miracle656#180 again, on the INGEST_WORKERS > 1
path.

- pollParallel commits Math.min across workers, seeded with toLedger (so the
  parallel path is clamped to the requested window) and floored at fromLedger
  so an empty window still advances
- fetchEventsSafe clamps highestLedger to endLedger in both the single-ledger
  and the bisect branch, so an XDR decode error can no longer hand the raw
  network tip back to the caller as the cursor
- document the load-bearing assumptions: the page-budget safety margin, the
  inclusive fromLedger re-read the drain design depends on, and why the bisect
  re-reads earlier pages
- tests: pollParallel with one truncated and one drained worker, and a
  fetchEventsSafe case where the bisect fires
…gest seams

Merges main (Miracle656#203) back in and re-lands the Miracle656#180 cursor work on the new
shape: fetchEventsSafe clamps highestLedger to the requested endLedger on
both the drained and the bisect/single-ledger paths, and pollParallel now
commits the *minimum* coverage across workers (seeded with toLedger,
floored at fromLedger) instead of the maximum, so a partition cut short by
the page budget is re-read rather than skipped.
@Richard-tobi

Copy link
Copy Markdown
Author

@Miracle656 Both blockers are fixed and the branch is rebased, so it merges again.

1. pollParallel commits the minimum — src/indexer/parallel.ts

const highestLedger = Math.max(
  fromLedger,
  results.reduce((min, r) => Math.min(min, r.highestLedger), toLedger),
);

The seed is toLedger, which also clamps the parallel path to the requested window (it had no clamp of its own), and the floor at fromLedger keeps an empty window advancing. The docstring is restated to match: the return is the cursor to commit — the lowest highestLedger across workers, clamped to [fromLedger, toLedger] — with a note on why a partition cut short by the page budget pins the whole window.

2. fetchEventsSafe clamps on every path — src/rpc.ts

  • single-ledger branch: Math.min(Math.max(startLedger, latestLedger), endLedger)
  • drained branch: truncated ? maxLedger : latestLedger, then Math.min(..., endLedger)
  • bisect return: an explicit Math.min(..., endLedger) over the merged halves

So the docstring holds for pollParallel and src/ingest/backfill.ts too, not only for pollOnce.

3. Tests

  • src/__tests__/parallelCursor.test.ts (new): two partitions, one truncated at 105 and one drained to 120 → committed cursor is 105; plus the empty-window floor case.
  • src/__tests__/fetchEventsSafe.test.ts: bisect fires on 100–101 and both single-ledger halves report the raw tip 9999 → highestLedger is 101.

4. The two smaller notes

The page-budget safety (EVENTS_PAGE_BUDGET vs a 10 000-event BATCH_SIZE, i.e. why it can't livelock the loop) is a comment above the constant, and the whole-range bisect cost is called out in the catch. The load-bearing pollOnce assumption — fromLedger is re-read inclusively, so a partially covered top ledger is picked up next poll — is a comment at the clamp.

Rebase

The min/Math.max fix had landed on the pre-#203 pollParallel signature, and main has since moved the workers onto the injected ParallelIo seams (d04392dc94), so update-branch reported a conflict in src/indexer/parallel.ts. The branch is now a merge of main at 353b5fac with the fix re-applied to the io-injected shape; nothing else from the PR was dropped or reverted, and the src/rpc.ts drain and src/indexer.ts clamp are merged with main's isXdrError narrowing in the same file.

Verification on the merge commit (00bae375), clean worktree

npx tsc --noEmit -p tsconfig.test.json   -> exit 0
npx jest --runInBand                     -> Test Suites 53 passed (53)
                                            Tests 637 passed, 1 skipped, 0 failed

mergeable_state is unstable only because the fork's CI run is action_required (awaiting approval); GitGuardian is green on this head.

@Richard-tobi

Copy link
Copy Markdown
Author

@Miracle656 Both blockers are on the head and I verified them independently at 00bae375.

  1. src/indexer/parallel.ts — pollParallel now commits Math.max(fromLedger, results.reduce((min, r) => Math.min(min, r.highestLedger), toLedger)): the lowest highestLedger across workers, clamped to [fromLedger, toLedger], so a partition cut short by the page budget pins the whole window.
  2. src/rpc.ts — fetchEventsSafe clamps on every path: single-ledger (Math.min(Math.max(startLedger, latestLedger), endLedger)), drained (Math.min(lastFullyCovered, endLedger)), and the bisect return (Math.min(Math.max(lower.highestLedger, upper.highestLedger), endLedger)).

Tests for each (src/__tests__/parallelCursor.test.ts, src/__tests__/fetchEventsSafe.test.ts) are present and pass.

Verification in a clean worktree (npm ci, npx prisma generate):

npx tsc --noEmit -p tsconfig.test.json  -> exit 0
npx jest --runInBand                    -> Test Suites 53 passed; Tests 637 passed, 1 skipped, 0 failed

Ready for re-review — thanks for the detailed pointers on where the clamp had to move.

This branch has not been deployed

No deployments
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.

Advance the cursor to the ledger actually covered, not the chain tip