Idempotency on on-chain retirement (audit C2)#133
Conversation
The audit (AUDIT.md) flagged C2-MEDIUM: executeRetirement() had no
mechanism to prevent double-execution. If called twice in quick
succession, both calls passed checkPrepaidBalance(), both passed
authorizePayment(), and both called signAndBroadcast() — resulting
in two transactions hitting the chain (and double-debiting the
prepaid balance).
This adds two layers of protection:
1. Wallet-level mutex (src/services/wallet.ts)
signAndBroadcast() now serializes through a per-address chained
promise. CosmJS auto-sequence handling races when two broadcasts
overlap (both fetch the same sequence number, one tx fails) — the
mutex prevents that, and incidentally prevents two broadcasts from
the same wallet hitting the chain simultaneously.
2. Idempotency-Key cache (src/services/retirement.ts + db.ts)
New retirement_idempotency_keys table caches completed results
keyed by an opaque client-supplied key, with a 24h TTL.
- REST /api/v1/retire reads the standard `Idempotency-Key` header
(or `idempotency_key` body field as fallback).
- executeRetirement checks the cache before doing any work and
returns the cached result if the key was used within the TTL.
- After successful broadcast, the result is persisted with
INSERT OR IGNORE so a racing duplicate cannot clobber the first.
- getCachedRetirementResult tolerates malformed JSON; cache write
failures are logged but never fail the retirement (the on-chain
side already succeeded).
The MCP tool path is unaffected — it doesn't pass a key or db, and
runs through the same code path it always has.
OpenAPI spec updated to document the Idempotency-Key header and
the idempotency_key body field.
Test: 8 new vitest cases covering cache round-trip, INSERT OR
IGNORE semantics under duplicates, TTL expiry, malformed-JSON
robustness, prune behavior, and the per-address mutex serializing
overlapping calls without blocking different addresses.
Full suite: 57/57 passing.
Refs: AUDIT.md C2-MEDIUM
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements idempotency for credit retirements and introduces a per-address mutex for wallet broadcasts to prevent transaction sequence collisions. It adds a database-backed cache for retirement results, updates API routes to handle idempotency keys from headers or request bodies, and modifies the retirement service to utilize this cache. Feedback identifies a security risk where idempotency keys should be scoped to individual users to prevent cross-user collisions and notes a remaining race condition where concurrent requests with the same key could bypass the cache check and lead to double retirements.
| const rawKey = | ||
| (Array.isArray(headerKey) ? headerKey[0] : headerKey) ?? | ||
| (req.body && typeof req.body.idempotency_key === "string" ? req.body.idempotency_key : undefined); | ||
| const idempotencyKey = typeof rawKey === "string" && rawKey.trim().length > 0 ? rawKey.trim() : undefined; |
There was a problem hiding this comment.
The idempotency key should be scoped to the authenticated user to prevent collisions. In a multi-tenant system, different users might provide the same Idempotency-Key (e.g., a simple integer or a common string). Without scoping, one user could receive the cached retirement result of another user, which is a security risk.
| const idempotencyKey = typeof rawKey === "string" && rawKey.trim().length > 0 ? rawKey.trim() : undefined; | |
| const idempotencyKey = typeof rawKey === "string" && rawKey.trim().length > 0 ? `u${user.id}:${rawKey.trim()}` : undefined; |
| if (idempotencyKey && db) { | ||
| const cached = getCachedRetirementResult(db, idempotencyKey); | ||
| if (cached) return cached.result as RetirementResult; | ||
| } |
There was a problem hiding this comment.
This implementation contains a race condition for concurrent requests using the same idempotency key. If two requests arrive simultaneously, both will pass this initial cache check because the first hasn't finished and persisted its result yet. While the wallet mutex in signAndBroadcast prevents sequence number collisions, it will still allow both transactions to be broadcast sequentially, resulting in a double retirement. This fails to fully address the audit finding C2 for concurrent cases.
To fix this, you should use a mutex based on the idempotencyKey and re-verify the cache immediately after acquiring the lock.
Summary
Closes the audit's C2-MEDIUM finding:
executeRetirement()had no protection against double-execution. If called twice in quick succession, both calls passedcheckPrepaidBalance(), both passedauthorizePayment(), and both calledsignAndBroadcast()— two transactions on chain, prepaid balance debited twice.What this PR does
Two layers of protection:
1. Wallet-level mutex (
src/services/wallet.ts)signAndBroadcast()now serializes through a per-address chained promise. CosmJS auto-sequence handling races when two broadcasts overlap (both fetch the same sequence number, one tx fails) — the mutex prevents that, and as a side effect prevents two broadcasts from the same wallet hitting the chain simultaneously.2.
Idempotency-Keycache (src/services/retirement.ts+db.ts)retirement_idempotency_keystable — keyed by client-supplied opaque key, 24h TTL./api/v1/retirereads the standardIdempotency-Keyheader (oridempotency_keybody field as fallback for clients that can't easily set headers).executeRetirementchecks the cache before any work and returns the cached result on hit.INSERT OR IGNOREso a racing duplicate cannot clobber the first.getCachedRetirementResulttolerates malformed JSON; cache write failures are logged but never fail the retirement (on-chain side already succeeded).The MCP tool path is unaffected — it doesn't pass a key or db, and runs through the same code path it always has. New behavior is purely opt-in via the REST API.
OpenAPI spec updated to document the new header + body field.
Files changed
src/services/wallet.ts— per-address mutex aroundsignAndBroadcastsrc/server/db.ts—retirement_idempotency_keystable +getCachedRetirementResult/cacheRetirementResult/pruneExpiredIdempotencyKeyssrc/services/retirement.ts—idempotencyKeyanddbadded toRetirementParams; cache check at entry, cache write after successsrc/server/api-routes.ts— readIdempotency-Keyheader /idempotency_keybody field on/api/v1/retiresrc/server/openapi.json— document the new parametersrc/__tests__/retirement-idempotency.test.ts— 8 new testsTest plan
npm run typecheck— cleannpm test— 57/57 passing (49 prior + 8 new)npm run build— cleansignAndBroadcastdoesn't conflict with the existing_subscriberLocksinroutes.ts(it shouldn't — that lock is per-subscriber, this is per-wallet, and they nest cleanly)Why narrow
Deliberately scoped to the audit finding. Not included here:
Refs:
AUDIT.mdC2-MEDIUM🤖 Generated with Claude Code