Skip to content

Reject billing deductions that reuse a requestId with a different user, amount or endpoint instead of replaying them #1501

Description

@greatest0fallt1me

Summary

BillingService treats requestId as an idempotency key, but it only checks that the key exists. It never checks that the stored row matches the new request. A second deduction with the same requestId and a different user, amount, API or endpoint is answered with the first row's usageEventId and stellarTxHash, reported as alreadyProcessed: true. The caller learns another tenant's event and transaction ids and believes a charge was made that was not. This issue stores a request fingerprint and returns a typed conflict on mismatch.

Background and evidence

In src/services/billing.ts:

  • Existence-only precheck. deductInternal calls this.getByRequestId(request.requestId) (about lines 547-553) and returns { ...existing, alreadyProcessed: true } for any match.
  • Unscoped lookup. getByRequestId (about lines 922-947) selects id, stellar_tx_hash FROM usage_events WHERE request_id = $1, with no user, amount or endpoint predicate.
  • Same pattern after the lock. runPhase1 (about lines 293-311) and the 23505 recovery path (about lines 600-622) return the existing row without comparing fields.
  • Bulk does the same. deductBulkInternal and runPhase1Bulk (about lines 728-852) return existing rows for any matching request id.
  • Any user can collide. src/routes/billing/deduct.ts accepts any caller-chosen requestId string, so collisions across users are trivially possible.

Scope

In scope

  1. Fingerprint.
    • Add request_fingerprint CHAR(64) (migration) holding the SHA-256 of a canonical userId|apiId|endpointId|apiKeyId|amountInContractUnits. Write it in runPhase1 and runPhase1Bulk.
    • On every existing-row path, compare the fingerprint.
    • On mismatch, return a new result { success: false, conflict: true, error: 'requestId already used for a different deduction' } without exposing the existing row's ids.
  2. Route mapping. src/routes/billing/deduct.ts and src/routes/billing/deduct/bulk.ts map conflict to 409 BILLING_REQUEST_ID_CONFLICT in the standard envelope. Add the code to docs/error-codes.yaml and run npm run error-codes:generate.
  3. Legacy rows. Rows with a NULL fingerprint are compared on user_id (the only field reliably present) and treated as a conflict on mismatch.
  4. Docs. Update docs/sdk/billing-deduct.md and the OpenAPI 409 example for /api/billing/deduct.

Out of scope

  • Scoping GET /api/billing/request/:requestId (tracked separately).
  • Global uniqueness on the partitioned table (a separate issue).

Design constraints

  • No disclosure. A conflict response must not include the other row's usageEventId or stellarTxHash.
  • Exact amounts. The canonical fingerprint uses integer contract units, so "1.0" and "1.0000000" match.
  • No extra round trip. The fingerprint is selected alongside the existing columns.

Test plan

Extend src/services/billing.test.ts, src/services/billing.bulk.test.ts and src/routes/billing/deduct.test.ts.

# Scenario Expected
1 Same requestId, same fields alreadyProcessed: true, same usageEventId
2 Same requestId, different user 409 BILLING_REQUEST_ID_CONFLICT; no ids leaked
3 Same requestId, different amount 409
4 "1.0" vs "1.0000000" treated as the same request
5 Concurrent mismatch hitting the 23505 path 409
6 Bulk item colliding with another user's row that item reports conflict; others proceed per existing semantics
7 Legacy row without fingerprint, same user replay
8 Legacy row, different user 409

Regression proof. Row 2 returns the other user's usageEventId on current main.

Acceptance criteria

  • No existing-row return path skips the fingerprint comparison.
  • Every test-plan row passes.
  • npm run db:check-migrations and npm run error-codes:check pass.
  • npm run lint, npm run typecheck, npm run build and npm test pass, and CI is green.
  • At least 90% line coverage on the touched paths in billing.ts.

Getting started

git checkout -b fix/billing-requestid-conflict
npm ci && npx prisma generate
npm run test:unit -- src/services/billing src/routes/billing/deduct

Read first: deductInternal, runPhase1, getByRequestId and deductBulkInternal in src/services/billing.ts, and src/routes/billing/deduct.ts.

PR requirements

  • Link this issue (Closes #<n>).
  • Explain the canonical fingerprint format.
  • Paste test output and coverage.
  • Example commit: fix(billing): reject requestId reuse with a different deduction.

Priority rationale

Medium. This is a contained change in one service, but it closes a cross-tenant information leak and false-success reporting on the deduction path.

Activity

  1. tonysxn-eng commented on Oct 18, 2026

    @tonysxn-eng
    Contributor

    @tonysxn-eng has applied to work on this issue as part of the Stellar Wave Program's 10th wave.

    I've gone through the issue and I'm ready to implement. I'll make sure the PR description is thorough. Can I be assigned?

    ℹ️ Repo Maintainers: To accept this application, review their application or assign @tonysxn-eng to this issue.

  2. heisnwaozor commented on Oct 19, 2026

    @heisnwaozor
    Contributor

    @eischideraa-unn has applied to work on this issue as part of the Stellar Wave Program's 10th wave.

    Hello Maintainer,

    I'd like to take on this issue. I've worked on a similar fix recently and have a clear approach to implementing and validating the solution.

    Thank you.

    ℹ️ Repo Maintainers: To accept this application, review their application or assign @eischideraa-unn to this issue.

  3. drips-wave commented on Oct 19, 2026

    @drips-wave

    Congratulations, @tonysxn-eng! 🎉 Your application was accepted by the repo's maintainers, and the issue is due on October 22, 2026.

    🧑‍💻 @tonysxn-eng: Please resolve the issue such that the repo's maintainers have enough time to review your contribution before the due date. You'll earn Points for completing the issue on-time, which will make you eligible for a share of the Stellar Wave Program's reward pool.

    Warning

    When opening a PR, please link it to this issue to ensure it gets tracked accurately. Points are awarded when this issue is marked as completed by the maintainer.

    🤠 Repo maintainers: Please keep an eye on the contributor's progress and review their work before the due date. You can manage this issue, including adjusting its complexity and points, here.

    🌊 Happy Wave 🌊

  4. drips-wave commented on Oct 22, 2026

    @drips-wave

    This issue has been completed by @tonysxn-eng as part of the Stellar Wave Program's 10th Wave 🥳

    😎 @tonysxn-eng: You earned 100 Points for completing this issue! Once the Wave's rewards are finalized, you'll be eligible for a percentage of the Wave's reward pool based on the percentage of total points you've earned. Learn more here. You can also Leave a review to share your experience working on this issue.

    🧑‍💻 Repo maintainers: How'd the contributor do? Leave a review to share your experience working with them.

  5. greatest0fallt1me commented on Oct 24, 2026

    @greatest0fallt1me
    ContributorAuthor

    Reopened: the fix from #1540 was reverted in #1550 because it broke the test suite on main. Please open a new PR rebased on the current main with tests passing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions