Skip to content

Commit e1e248e

Browse files
authored
Merge pull request #349 from faith3310/fix/replay-nonce-idempotency-328
feat(payments): replay-protected nonce + idempotency-key submission
2 parents 23bcc84 + 5b2c2e0 commit e1e248e

6 files changed

Lines changed: 394 additions & 4 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { idempotency, MemoryIdempotencyStore } from '../middleware/idempotency';
2+
import { Request, Response } from 'express';
3+
4+
type FakeRes = Response & {
5+
captured: any;
6+
statusCode: number;
7+
jsoned: boolean;
8+
headers: Record<string, string>;
9+
};
10+
11+
function mkRes(): FakeRes {
12+
const r: any = {
13+
statusCode: 200,
14+
jsoned: false,
15+
captured: undefined,
16+
headers: {},
17+
set(k: string, v: string) { this.headers[k.toLowerCase()] = v; return this; },
18+
status(code: number) { this.statusCode = code; return this; },
19+
json(body: any) { this.captured = body; this.jsoned = true; return this; },
20+
};
21+
return r as FakeRes;
22+
}
23+
24+
function mkReq(headers: Record<string, string> = {}, path = '/api/v1/payment', method = 'POST'): Request {
25+
return { headers, path, method, route: { path } } as unknown as Request;
26+
}
27+
28+
/** Run the middleware and resolve once it either calls next() or replies. */
29+
function run(mw: any, req: Request, res: FakeRes): Promise<{ next: boolean }> {
30+
return new Promise<{ next: boolean }>((resolve) => {
31+
let done = false;
32+
const finish = (nextCalled: boolean) => {
33+
if (!done) { done = true; resolve({ next: nextCalled }); }
34+
};
35+
// resolve on next()
36+
const next = () => finish(true);
37+
// resolve when the middleware writes a response
38+
const origJson = res.json.bind(res);
39+
res.json = ((body: any) => {
40+
const out = origJson(body);
41+
finish(false);
42+
return out;
43+
}) as Response['json'];
44+
mw(req, res as unknown as Response, next);
45+
});
46+
}
47+
48+
describe('idempotency middleware', () => {
49+
it('passes through when no Idempotency-Key header is present', async () => {
50+
const store = new MemoryIdempotencyStore();
51+
const mw = idempotency({ store });
52+
const res = mkRes();
53+
const { next } = await run(mw, mkReq({}), res);
54+
expect(next).toBe(true);
55+
expect(res.jsoned).toBe(false);
56+
});
57+
58+
it('first request processes; identical retry replays the cached response', async () => {
59+
const store = new MemoryIdempotencyStore();
60+
const mw = idempotency({ store, ttlSeconds: 60 });
61+
const key = 'abc-123';
62+
63+
const res1 = mkRes();
64+
const r1 = await run(mw, mkReq({ 'idempotency-key': key }), res1);
65+
expect(r1.next).toBe(true);
66+
// simulate the route handler writing the response (captured by our patch)
67+
res1.status(200).json({ success: true, transactionId: 'tx-1' });
68+
69+
const res2 = mkRes();
70+
const r2 = await run(mw, mkReq({ 'idempotency-key': key }), res2);
71+
expect(r2.next).toBe(false); // replayed from cache, handler not invoked
72+
expect(res2.statusCode).toBe(200);
73+
expect(res2.captured).toEqual({ success: true, transactionId: 'tx-1' });
74+
expect(res2.headers['x-idempotent-replay']).toBe('true');
75+
});
76+
77+
it('returns 409 when a request with the same key is already in flight', async () => {
78+
const store = new MemoryIdempotencyStore();
79+
const mw = idempotency({ store, ttlSeconds: 60 });
80+
const key = 'inflight-key';
81+
82+
const res1 = mkRes();
83+
await run(mw, mkReq({ 'idempotency-key': key }), res1); // acquired, "in flight"
84+
85+
const res2 = mkRes();
86+
const r2 = await run(mw, mkReq({ 'idempotency-key': key }), res2);
87+
expect(r2.next).toBe(false);
88+
expect(res2.statusCode).toBe(409);
89+
expect(res2.captured.error).toBe('IDEMPOTENCY_IN_FLIGHT');
90+
});
91+
92+
it('does not cache 5xx so the client can retry', async () => {
93+
const store = new MemoryIdempotencyStore();
94+
const mw = idempotency({ store, ttlSeconds: 60 });
95+
const key = 'err-key';
96+
97+
const res1 = mkRes();
98+
await run(mw, mkReq({ 'idempotency-key': key }), res1);
99+
res1.status(500).json({ error: 'boom' });
100+
101+
const res2 = mkRes();
102+
const r2 = await run(mw, mkReq({ 'idempotency-key': key }), res2);
103+
expect(r2.next).toBe(true); // not replayed — handler runs again
104+
});
105+
106+
it('scopes keys per route (same client key, different route => independent)', async () => {
107+
const store = new MemoryIdempotencyStore();
108+
const mw = idempotency({ store, ttlSeconds: 60 });
109+
const key = 'shared-key';
110+
111+
const resA = mkRes();
112+
await run(mw, mkReq({ 'idempotency-key': key }, '/api/v1/payment'), resA);
113+
resA.status(200).json({ route: 'a' });
114+
115+
const resB = mkRes();
116+
const rB = await run(mw, mkReq({ 'idempotency-key': key }, '/api/v2/payment'), resB);
117+
expect(rB.next).toBe(true); // different route -> not a replay
118+
});
119+
});
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* Idempotency middleware.
3+
*
4+
* Prevents duplicate payment submission to Stellar on network retries: a
5+
* client sends an `Idempotency-Key` header; the first request is processed and
6+
* its response cached; identical retries within the TTL return the original
7+
* response without re-submitting to the chain. Pairs with the contract's
8+
* nonce-uniqueness guard for defence in depth.
9+
*
10+
* Storage is pluggable: a Redis-backed store for production (atomic SET NX),
11+
* and an in-memory TTL store for dev/tests when Redis is unavailable.
12+
*/
13+
14+
import { Request, Response, NextFunction } from 'express';
15+
16+
export interface CachedResponse {
17+
status: number;
18+
body: unknown;
19+
/** Headers worth replaying (content-type only) */
20+
headers?: Record<string, string>;
21+
}
22+
23+
export interface IdempotencyStore {
24+
/** Returns 'acquired' if we won the lock, 'processing' if a request is in
25+
* flight, 'exists' if a final result is already cached. */
26+
tryAcquire(key: string, ttlSeconds: number): Promise<'acquired' | 'processing' | 'exists'>;
27+
getResult(key: string): Promise<CachedResponse | undefined>;
28+
setResult(key: string, value: CachedResponse, ttlSeconds: number): Promise<void>;
29+
/** Release the in-flight lock without caching a result (e.g. on 5xx). */
30+
releaseLock(key: string): Promise<void>;
31+
}
32+
33+
const OK = 'OK';
34+
35+
/** Redis-backed store using atomic SET NX EX for the lock. */
36+
export class RedisIdempotencyStore implements IdempotencyStore {
37+
constructor(private client: { set: (k: string, v: string, mode: string, ttl: string, seconds: number) => Promise<string | null>; get: (k: string) => Promise<string | null>; setex: (k: string, ttl: number, v: string) => Promise<string | null>; del: (k: string) => Promise<number>; }) {}
38+
39+
async tryAcquire(key: string, ttlSeconds: number): Promise<'acquired' | 'processing' | 'exists'> {
40+
const resultKey = this.resultKey(key);
41+
// If a final result already exists, replay it.
42+
const existing = await this.client.get(resultKey);
43+
if (existing) return 'exists';
44+
// Try to acquire the processing lock.
45+
const acquired = await this.client.set(key, 'processing', 'NX', 'EX', ttlSeconds);
46+
return acquired === OK ? 'acquired' : 'processing';
47+
}
48+
49+
async getResult(key: string): Promise<CachedResponse | undefined> {
50+
const raw = await this.client.get(this.resultKey(key));
51+
if (!raw) return undefined;
52+
try {
53+
return JSON.parse(raw) as CachedResponse;
54+
} catch {
55+
return undefined;
56+
}
57+
}
58+
59+
async setResult(key: string, value: CachedResponse, ttlSeconds: number): Promise<void> {
60+
await this.client.setex(this.resultKey(key), ttlSeconds, JSON.stringify(value));
61+
await this.client.del(key); // release the processing lock
62+
}
63+
64+
async releaseLock(key: string): Promise<void> {
65+
await this.client.del(key);
66+
}
67+
68+
private resultKey = (key: string) => `${key}:result`;
69+
}
70+
71+
interface MemEntry { value: 'processing' | CachedResponse; expiresAt: number; }
72+
73+
/** In-memory TTL store (dev/tests/fallback). */
74+
export class MemoryIdempotencyStore implements IdempotencyStore {
75+
private store = new Map<string, MemEntry>();
76+
private now: () => number;
77+
constructor(now: () => number = Date.now) { this.now = now; }
78+
79+
private prune(key: string): void {
80+
const e = this.store.get(key);
81+
if (e && e.expiresAt < this.now()) this.store.delete(key);
82+
}
83+
84+
async tryAcquire(key: string, ttlSeconds: number): Promise<'acquired' | 'processing' | 'exists'> {
85+
this.prune(key);
86+
const e = this.store.get(key);
87+
if (e && e.value !== 'processing') return 'exists';
88+
if (e && e.value === 'processing') return 'processing';
89+
this.store.set(key, { value: 'processing', expiresAt: this.now() + ttlSeconds * 1000 });
90+
return 'acquired';
91+
}
92+
93+
async getResult(key: string): Promise<CachedResponse | undefined> {
94+
this.prune(key);
95+
const e = this.store.get(key);
96+
return e && e.value !== 'processing' ? (e.value as CachedResponse) : undefined;
97+
}
98+
99+
async setResult(key: string, value: CachedResponse, ttlSeconds: number): Promise<void> {
100+
this.store.set(key, { value, expiresAt: this.now() + ttlSeconds * 1000 });
101+
}
102+
103+
async releaseLock(key: string): Promise<void> {
104+
this.store.delete(key);
105+
}
106+
}
107+
108+
export interface IdempotencyOptions {
109+
headerName?: string;
110+
ttlSeconds?: number;
111+
store?: IdempotencyStore;
112+
}
113+
114+
/** Build a scoped cache key from method + route + client key. */
115+
function buildKey(req: Request, clientKey: string): string {
116+
const route = (req.route?.path || req.path || '').toString().slice(0, 64);
117+
return `idem:${req.method}:${route}:${clientKey}`;
118+
}
119+
120+
/**
121+
* Express middleware. If no `Idempotency-Key` header is present the request
122+
* passes through unchanged (the contract nonce still guards on-chain replay).
123+
*/
124+
export function idempotency(opts: IdempotencyOptions = {}) {
125+
const headerName = (opts.headerName || 'idempotency-key').toLowerCase();
126+
const ttlSeconds = opts.ttlSeconds ?? 24 * 60 * 60; // 24h >= Stellar finality windows
127+
const store = opts.store || new MemoryIdempotencyStore();
128+
129+
return async (req: Request, res: Response, next: NextFunction) => {
130+
const clientKey = (req.headers[headerName] as string | undefined)?.trim();
131+
if (!clientKey) return next(); // optional; contract nonce still protects
132+
133+
const key = buildKey(req, clientKey);
134+
const state = await store.tryAcquire(key, ttlSeconds);
135+
136+
if (state === 'exists') {
137+
const cached = await store.getResult(key);
138+
if (cached) {
139+
res.status(cached.status);
140+
if (cached.headers) for (const [k, v] of Object.entries(cached.headers)) res.set(k, v);
141+
res.set('X-Idempotent-Replay', 'true');
142+
return res.json(cached.body);
143+
}
144+
}
145+
if (state === 'processing') {
146+
return res.status(409).json({ error: 'IDEMPOTENCY_IN_FLIGHT', message: 'A request with this Idempotency-Key is already being processed' });
147+
}
148+
149+
// state === 'acquired': capture the response and cache it.
150+
const originalJson = res.json.bind(res);
151+
res.json = ((body: unknown) => {
152+
const captured: CachedResponse = { status: res.statusCode, body };
153+
// Only cache successful + client-error responses; 5xx should be retryable,
154+
// so release the in-flight lock to let the client retry with the same key.
155+
if (res.statusCode < 500) {
156+
store.setResult(key, captured, ttlSeconds).catch(() => { /* non-fatal */ });
157+
} else {
158+
store.releaseLock(key).catch(() => { /* non-fatal */ });
159+
}
160+
return originalJson(body);
161+
}) as Response['json'];
162+
163+
next();
164+
};
165+
}

backend/src/server.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { ProviderService } from './services/providerService';
2424
import { MultiProviderPaymentService } from './services/multiProviderPaymentService';
2525
import { ProviderPaymentRequest } from './types/provider';
2626
import { kycService } from './services/kyc-service';
27+
import { idempotency } from './middleware/idempotency';
2728
import analyticsRoutes from './routes/analytics';
2829
import notificationRoutes from './routes/notifications';
2930
import configRoutes from './routes/config';
@@ -204,7 +205,7 @@ app.get('/health/full', asyncRoute(async (_req, res) => {
204205
}));
205206

206207
// Versioned payment endpoints
207-
app.post('/api/v1/payment', asyncRoute(async (req, res) => {
208+
app.post('/api/v1/payment', idempotency(), asyncRoute(async (req, res) => {
208209
const raw = req.body;
209210
const errors: ValidationError[] = [];
210211
const meter_id = sanitizeAlphanumeric(raw.meter_id, 50);
@@ -231,7 +232,7 @@ app.post('/api/v1/payment', asyncRoute(async (req, res) => {
231232
}
232233
}));
233234

234-
app.post('/api/v2/payment', asyncRoute(async (req, res) => {
235+
app.post('/api/v2/payment', idempotency(), asyncRoute(async (req, res) => {
235236
const raw = req.body;
236237
const errors: ValidationError[] = [];
237238
const meter_id = sanitizeAlphanumeric(raw.meter_id, 50);
@@ -293,7 +294,7 @@ app.post('/api/v1/payment/multi-provider', asyncRoute(async (req, res) => {
293294
}
294295
}));
295296

296-
app.post('/api/v2/payment/multi-provider', asyncRoute(async (req, res) => {
297+
app.post('/api/v2/payment/multi-provider', idempotency(), asyncRoute(async (req, res) => {
297298
const raw = req.body;
298299
const errors: ValidationError[] = [];
299300
const meter_id = sanitizeAlphanumeric(raw.meter_id, 50);

contract/src/lib.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,14 @@ impl NepaBillingContract {
229229
// 7. Store payment record
230230
env.storage().persistent().set(&payment_id, &payment_record);
231231

232-
// 8. Mark nonce as used
232+
// 8. Mark nonce as used (replay guard)
233233
env.storage().persistent().set(&nonce_key, &true);
234234

235+
// 8b. Store nonce -> payment_id mapping for idempotent lookups
236+
// (additive key; the NONCE->bool guard above stays for backward compat).
237+
let nonce_pid_key = (Symbol::short("NONCE_PID"), from.clone(), nonce.clone());
238+
env.storage().persistent().set(&nonce_pid_key, &payment_id);
239+
235240
// 9. Update the meter total (backward compatibility)
236241
let current_total: i128 = env.storage().persistent().get(&meter_id).unwrap_or(0);
237242
env.storage().persistent().set(&meter_id, &(current_total + amount));
@@ -247,6 +252,23 @@ impl NepaBillingContract {
247252
new_id
248253
}
249254

255+
/// Look up a payment by (payer, nonce). Returns the payment_id of the
256+
/// payment submitted with that nonce, or panics if no such payment exists.
257+
/// Used to idempotently resolve a retried submission to the same on-chain
258+
/// payment instead of re-submitting.
259+
pub fn payment_by_nonce(env: Env, payer: Address, nonce: String) -> u64 {
260+
let nonce_pid_key = (Symbol::short("NONCE_PID"), payer, nonce);
261+
env.storage().persistent()
262+
.get::<u64>(&nonce_pid_key)
263+
.unwrap_or_else(|| panic!("No payment found for the given (payer, nonce) pair"))
264+
}
265+
266+
/// Returns true if a payment has already been recorded for (payer, nonce).
267+
pub fn nonce_exists(env: Env, payer: Address, nonce: String) -> bool {
268+
let nonce_pid_key = (Symbol::short("NONCE_PID"), payer, nonce);
269+
env.storage().persistent().has(&nonce_pid_key)
270+
}
271+
250272
pub fn get_total_paid(env: Env, meter_id: String) -> i128 {
251273
env.storage().persistent().get(&meter_id).unwrap_or(0)
252274
}

0 commit comments

Comments
 (0)