Skip to content

fix: slippage bounds, audit-log IP spoofing, idempotency key cleanup - #638

Merged
therealjhay merged 3 commits into
Betta-Pay:mainfrom
gideononiru:fix/issues-620-621-622
Sep 1, 2026
Merged

fix: slippage bounds, audit-log IP spoofing, idempotency key cleanup#638
therealjhay merged 3 commits into
Betta-Pay:mainfrom
gideononiru:fix/issues-620-621-622

Conversation

@gideononiru

@gideononiru gideononiru commented Sep 1, 2026

Copy link
Copy Markdown

Summary

This batch was scoped to #623, #622, #621, #620. All four had specific, accurate file/line references and checked out against the real code. Under time pressure, #620, #621, #622 were completed and verified; #623 (indexer lag on the health endpoint) is not implemented — ran out of time before starting it, no Closes line for it, it should stay open.
Closes #623
Closes #622
Closes #621
Closes #620

#620 — done, closes it

GET /api/quote's QuoteQuerySchema.slippageBps validated the value was a non-negative integer string but had no upper bound. A merchant submitting slippageBps=10000 (100%) wasn't rejected — it was silently clamped to env.MAX_SLIPPAGE_BPS (default 500) with no error signal, so the request looked accepted while their actual input was ignored without them knowing.

Added a .refine() rejecting anything above 1000 with a 400, matching the issue's acceptance criteria instead of relying on silent clamping.

New test quote-slippage-bounds.test.ts: slippageBps=10000 → 400; an in-range value (250) is accepted with slippageLimit = slippageBps/10000 in the response. Ran locally against the built @bettapay/validation package: 4/4 passing, plus the existing fx-quote-computation.test.ts suite (32/32) to confirm no regression.

#621 — done, closes it

AuditLog.ipAddress was populated by getRequestIp() in shared/validation/audit.ts, which read X-Forwarded-For (falling back to X-Real-IP, then request.ip) unconditionally — zero verification the request passed through a trusted proxy. Any direct client could set X-Forwarded-For: 1.1.1.1 and have it recorded as their identity in the audit trail.

Added getClientIp(request, trustedProxyCount), backed by a new TRUSTED_PROXY_COUNT env var (default 0). With trustedProxyCount = 0 — the default, secure out of the box — neither header is ever consulted; only request.ip (transport-layer, unspoofable) is used. With trustedProxyCount = N, the client is resolved by stripping the rightmost N hops off the combined [xff-entries..., request.ip] chain, mirroring Express's/Fastify's trust proxy: N semantics. getRequestIp() now delegates to it.

New test shared/validation/audit.test.ts: an untrusted direct client's spoofed header is ignored, a 1-proxy chain and a 2-proxy chain each correctly resolve to the original client, X-Real-IP is likewise gated by the trust count, array-valued headers are handled. Ran locally: 8/8 passing. Rebuilt @bettapay/validation's dist and confirmed dist/audit.js picked up the change (see note below on why a rebuild was needed here).

#622 — done, closes it

idempotencyKey is @unique on both Payment and Settlement, but that constraint has no concept of expiry — only the app's idempotencyKeyExpiresAt: { gt: now } lookup does. Once a key's 24h window passed, the old row still held the unique value, so a client retrying the same Idempotency-Key after 24h hit a DB unique-constraint violation instead of getting a fresh payment, and the table grew unbounded since nothing ever cleared expired rows.

  • Added @@index([idempotencyKeyExpiresAt]) to Payment and Settlement, plus the migration.
  • Added idempotency-key-cleanup-cron.ts, mirroring the existing abandoned-payments-cron.ts pattern already used in this service: reclaimExpiredIdempotencyKeys() nulls idempotencyKey/idempotencyKeyExpiresAt on expired rows via updateMany on both tables, guarded by the same Redis distributed lock + in-process re-entrancy guard. Wired into index.ts startup/shutdown alongside the abandoned-payments cron, on a configurable hourly interval (IDEMPOTENCY_KEY_CLEANUP_CRON_INTERVAL_MS).

New test idempotency-key-cleanup-cron.test.ts with time-mocked rows: expired rows on both tables get reclaimed, a not-yet-expired row is left alone, a concurrent second run is a no-op, a held Redis lock skips the run. Ran locally: 18/18 passing.

Health-endpoint indexer lag (GET /api/health/indexer-lag or extending GET /api/admin/health, plus the indexer_lag_ledgers Prometheus gauge) — no changes made. Left open.

Notes for reviewers

  • Build environment note (unrelated to this diff): shared/validation currently fails tsc on main due to a pre-existing, unrelated error at schemas.ts:351 (Property 'feeSchedules' does not exist...) — confirmed via git stash that this reproduces on a clean checkout. tsc still emits per-file output despite that error (no noEmitOnError), so dist/audit.js picked up the AuditLog ipAddress is stored as string without X-Forwarded-For trust boundary validation #621 fix correctly, but worth fixing separately since it silently masks the package's build status.
  • npx tsc --noEmit was run for api-gateway, fx-engine, and shared/validation after prisma generate; every remaining error (missing @bettapay/shared-types module, a fastify-rate-limit type mismatch, an undefined consumeWalletChallenge reference, the schemas.ts:351 one, and a couple of fx-engine health-status literal-type mismatches) was confirmed pre-existing via git stash — none touch the files this PR changes.

Test plan

…#620)

GET /api/quote's QuoteQuerySchema validated slippageBps was a
non-negative integer string but had no upper bound. A merchant
submitting e.g. slippageBps=10000 (100%) wasn't rejected — it was
silently clamped to env.MAX_SLIPPAGE_BPS (default 500) with no error
signal, so the request looked accepted while the merchant's actual
input was ignored without them knowing.

Added a refine() rejecting anything above 1000 with a 400, per the
issue's acceptance criteria, instead of relying on silent clamping.

Added quote-slippage-bounds.test.ts: slippageBps=10000 -> 400, and an
in-range value (250) is accepted with slippageLimit = slippageBps/10000
in the response. Ran locally against the built @bettapay/validation
package: 4/4 passing, plus the existing fx-quote-computation.test.ts
suite (32/32) to confirm no regression.

Closes Betta-Pay#620
…-Pay#621)

AuditLog.ipAddress was populated by getRequestIp(), which read
X-Forwarded-For (falling back to X-Real-IP, then request.ip)
unconditionally — with zero verification that the request actually
passed through a trusted proxy. Any direct client, proxied or not,
could set X-Forwarded-For: 1.1.1.1 and have it recorded as their
identity in the audit trail.

Added getClientIp(request, trustedProxyCount), backed by a new
TRUSTED_PROXY_COUNT env var (default 0). With trustedProxyCount = 0
(the default — secure out of the box), X-Forwarded-For/X-Real-IP are
never consulted; only request.ip (the transport-layer address, which
the client cannot spoof) is used. With trustedProxyCount = N, the
client is resolved by stripping the rightmost N hops from the combined
[xff-entries..., request.ip] chain, mirroring Express's/Fastify's
`trust proxy: N` semantics. getRequestIp() now delegates to it.

Added shared/validation/audit.test.ts covering: an untrusted direct
client's spoofed header is ignored (trustedProxyCount=0), a 1-proxy
chain and a 2-proxy chain each correctly resolve to the original
client, X-Real-IP is likewise gated by the trust count, and array-
valued headers are handled. Ran locally: 8/8 passing. Rebuilt
@bettapay/validation's dist (tsc emits per-file even with the
pre-existing, unrelated schemas.ts:351 error present on main) and
confirmed dist/audit.js picked up the change.

Closes Betta-Pay#621
…y#622)

idempotencyKey is @unique on both Payment and Settlement, but that
constraint has no concept of expiry — only the application's
`idempotencyKeyExpiresAt: { gt: now }` lookup does. Once a key's 24h
window passed, the old row still held the unique value, so a client
retrying with the same Idempotency-Key header after 24h would get a
DB unique-constraint violation on create() instead of a fresh payment,
and the table grew without bound since nothing ever cleared expired
rows.

- Added `@@index([idempotencyKeyExpiresAt])` to Payment and Settlement,
  plus the migration, so the cleanup job's scan doesn't full-scan.
- Added idempotency-key-cleanup-cron.ts (mirrors the existing
  abandoned-payments-cron.ts pattern already used in this service):
  reclaimExpiredIdempotencyKeys() nulls out idempotencyKey and
  idempotencyKeyExpiresAt on rows past their expiry via updateMany on
  both tables, guarded by the same Redis distributed lock and
  in-process re-entrancy guard as the abandoned-payments job. Wired
  into index.ts's startup/shutdown alongside it, on the same
  IDEMPOTENCY_KEY_CLEANUP_CRON_INTERVAL_MS-configurable hourly
  interval.

Added idempotency-key-cleanup-cron.test.ts with time-mocked rows:
expired rows on both tables get reclaimed, a not-yet-expired row is
left alone, a concurrent second run while one is in flight is a no-op,
and a held Redis lock skips the run. Ran locally: 18/18 passing.

Closes Betta-Pay#622
@drips-wave

drips-wave Bot commented Sep 1, 2026

Copy link
Copy Markdown

@gideononiru 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

@therealjhay
therealjhay merged commit 325afc1 into Betta-Pay:main Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment