Skip to content

fix(billing): reject requestId reuse with a different deduction - #1540

Open
tonysxn-eng wants to merge 1 commit into
CalloraOrg:mainfrom
tonysxn-eng:fix/issue-1501-reject-billing-deductions-that-reuse-a
Open

tonysxn-eng wants to merge 1 commit into
CalloraOrg:mainfrom
tonysxn-eng:fix/issue-1501-reject-billing-deductions-that-reuse-a

Conversation

@tonysxn-eng

Copy link
Copy Markdown

Overview

This PR closes a cross-tenant information leak and false-success reporting on the billing deduction path. BillingService previously treated requestId as an idempotency key by existence only, so a second deduction reusing the same requestId with a different user, amount, API or endpoint was answered with the first row's usageEventId and stellarTxHash and reported as alreadyProcessed: true. This change stores a canonical request fingerprint and returns a typed conflict on mismatch, without disclosing the existing row's ids.

Related Issue

Changes

🔐 Request Fingerprint and Conflict Detection

  • [ADD] migrations/0027_usage_events_request_fingerprint.sql

    • Adds request_fingerprint CHAR(64) to usage_events, holding the SHA-256 of the canonical userId|apiId|endpointId|apiKeyId|amountInContractUnits string.
    • Canonical form uses integer contract units, so "1.0" and "1.0000000" produce the same fingerprint.
  • [MODIFY] src/routes/billing/deduct/bulk.ts

    • Maps the new conflict result to 409 BILLING_REQUEST_ID_CONFLICT in the standard envelope.
    • Bulk items that collide with another user's row report a conflict for that item while other items proceed per existing semantics.
  • [MODIFY] docs/error-codes.yaml

    • Registers BILLING_REQUEST_ID_CONFLICT (409) so the generated error-code artifacts stay in sync.
  • [MODIFY] docs/openapi.json

    • Adds the 409 example for /api/billing/deduct describing a requestId reused for a different deduction.

🧪 Tests

  • [MODIFY] src/__tests__/billing-credits.test.ts
  • [MODIFY] src/__tests__/billing-index.test.ts
  • [MODIFY] src/__tests__/billingDeductMetrics.test.ts
    • Cover the existing-row paths so no return path skips the fingerprint comparison, including the legacy NULL-fingerprint case (compared on user_id only) and the conflict response shape (no usageEventId / stellarTxHash leaked).

Verification Results

npm run db:check-migrations
✅ passed

npm run error-codes:check
✅ passed

npm run lint && npm run typecheck && npm run build
✅ passed

npm test
✅ passed
Acceptance Criteria Status
No existing-row return path skips the fingerprint comparison ✅ Precheck, post-lock runPhase1, 23505 recovery, and bulk paths all compare the fingerprint
Every test-plan row passes ✅ Covered in the updated billing and deduct test suites
npm run db:check-migrations and npm run error-codes:check pass ✅ Both pass
npm run lint, npm run typecheck, npm run build and npm test pass, and CI is green ✅ All pass locally; CI green
At least 90% line coverage on the touched paths in billing.ts ✅ Covered by the updated suites

Fingerprint format

request_fingerprint = SHA-256("userId|apiId|endpointId|apiKeyId|amountInContractUnits")

  • Amounts are normalized to integer contract units before hashing, so equivalent decimal representations match.
  • Legacy rows with a NULL fingerprint are compared on user_id only and treated as a conflict on mismatch.
  • On conflict the response is { success: false, conflict: true, error: 'requestId already used for a different deduction' } and never includes the other row's usageEventId or stellarTxHash.

Closes #1501

@drips-wave

drips-wave Bot commented Oct 20, 2026

Copy link
Copy Markdown

@tonysxn-eng 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

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.

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

1 participant