Repository navigation
Description
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.
deductInternalcallsthis.getByRequestId(request.requestId)(about lines 547-553) and returns{ ...existing, alreadyProcessed: true }for any match. - Unscoped lookup.
getByRequestId(about lines 922-947) selectsid, 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 the23505recovery path (about lines 600-622) return the existing row without comparing fields. - Bulk does the same.
deductBulkInternalandrunPhase1Bulk(about lines 728-852) return existing rows for any matching request id. - Any user can collide.
src/routes/billing/deduct.tsaccepts any caller-chosenrequestIdstring, so collisions across users are trivially possible.
Scope
In scope
- Fingerprint.
- Add
request_fingerprint CHAR(64)(migration) holding the SHA-256 of a canonicaluserId|apiId|endpointId|apiKeyId|amountInContractUnits. Write it inrunPhase1andrunPhase1Bulk. - 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.
- Add
- Route mapping.
src/routes/billing/deduct.tsandsrc/routes/billing/deduct/bulk.tsmapconflictto409 BILLING_REQUEST_ID_CONFLICTin the standard envelope. Add the code todocs/error-codes.yamland runnpm run error-codes:generate. - Legacy rows. Rows with a NULL fingerprint are compared on
user_id(the only field reliably present) and treated as a conflict on mismatch. - Docs. Update
docs/sdk/billing-deduct.mdand 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
usageEventIdorstellarTxHash. - 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-migrationsandnpm run error-codes:checkpass. -
npm run lint,npm run typecheck,npm run buildandnpm testpass, 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/deductRead 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
tonysxn-eng commentedon Oct 18, 2026
@tonysxn-eng has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @tonysxn-eng to this issue.
heisnwaozor commentedon Oct 19, 2026
@eischideraa-unn has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @eischideraa-unn to this issue.
drips-wave commentedon Oct 19, 2026
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 🌊
fix: Reject billing deductions that reuse a requestId with a diff (Ca…
fix: Reject billing deductions that reuse a requestId with a diff (#1501
drips-wave commentedon Oct 22, 2026
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.
greatest0fallt1me commentedon Oct 24, 2026
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.