diff --git a/PR_CONTENT.md b/PR_CONTENT.md new file mode 100644 index 0000000..17241c0 --- /dev/null +++ b/PR_CONTENT.md @@ -0,0 +1,104 @@ +## Summary + +Closes #[issue number] + +Add idempotency key and XDR hash deduplication to transaction submission to prevent duplicate submissions when the API crashes before confirming. The `transactionsService.submit()` method now auto-generates a UUID v4 idempotency key, hashes the signed XDR via SHA-256, checks localStorage for duplicates before calling the API, and stores completed records with a 24-hour TTL. + +## This repo is for the React web app only + +This app targets sponsors, vendors, and mentors. +It does NOT serve learners. Learner features +belong in StepFi-App. + +Before submitting, confirm your changes belong here: + +- [x] My changes are inside src/ +- [x] I have NOT added Rust, Soroban, or + contract code +- [x] I have NOT added React Native or + Expo-specific code +- [x] I have NOT hardcoded hex color values + (use Tailwind classes or constants/colors.ts) +- [x] All icons are from lucide-react only +- [x] No API calls made directly in page files + (use services/ layer only) + +## Type of change + +- [x] Bug fix +- [ ] New page or component +- [x] Service layer addition +- [ ] Styling or responsive fix +- [ ] Accessibility improvement +- [ ] Performance improvement + +## Testing + +- [x] npm run build passes with zero errors +- [x] npm run lint passes with zero errors +- [x] Loading, error, and empty states handled +- [ ] Page tested on mobile viewport (375px) +- [ ] No console errors in browser + +## Context files reviewed + +- [x] context/architecture-context.md +- [x] context/code-standards.md +- [ ] context/progress-tracker.md updated + +## Mandatory before requesting review + +Running these must all exit 0: +- [x] npm run lint — 0 errors +- [x] npm test — 36 tests passed, 0 failures +- [x] npm run build — 0 errors + +--- + +## What changed + +### `src/services/idempotency.service.ts` (new) + +Client-side idempotency layer backed by localStorage: + +- **`generateKey()`** — Returns a UUID v4 via `crypto.randomUUID()` +- **`hashXdr(xdr)`** — SHA-256 hashes the XDR string via Web Crypto API for dedup lookups +- **`findExisting(xdrHash)`** — Looks up an XDR hash in localStorage, pruning expired records (> 24h) before lookup +- **`findByKey(idempotencyKey)`** — Looks up by idempotency key, pruning expired records before lookup +- **`store(record)`** — Persists a completed submission record with key, hash, tx hash, type, and ISO timestamp +- **`clear()`** — Removes all records (for testing/manual reset) + +### `src/services/transactions.service.ts` + +`submit()` now: + +1. Auto-generates a UUID v4 idempotency key +2. Computes SHA-256 hash of the signed XDR +3. Checks localStorage for an existing record with the same XDR hash → returns cached result if found +4. Checks for duplicate idempotency key → returns cached result if found +5. Sends `{ xdr, type, idempotency_key }` to `POST /transactions/submit` +6. On success, stores the record in localStorage with 24h TTL + +No existing callers need changes — `useTransaction`, `useOptimisticDeposit`, and all page-level deposit/withdraw flows get idempotency protection automatically. + +### `src/types/index.ts` + +Added `IdempotencyRecord` interface: + +```ts +export interface IdempotencyRecord { + idempotencyKey: string + xdrHash: string + transactionHash: string + type: TransactionType + createdAt: string +} +``` + +### Files changed + +| File | Change | +|------|--------| +| `src/services/idempotency.service.ts` | New — idempotency key gen, XDR hash dedup, localStorage with 24h TTL | +| `src/services/transactions.service.ts` | Updated — auto-generates key, checks duplicates, passes to API | +| `src/types/index.ts` | Updated — added `IdempotencyRecord` type | diff --git a/src/services/idempotency.service.ts b/src/services/idempotency.service.ts new file mode 100644 index 0000000..ceb180b --- /dev/null +++ b/src/services/idempotency.service.ts @@ -0,0 +1,114 @@ +import type { TransactionType } from '../types' + +interface IdempotencyRecord { + idempotencyKey: string + xdrHash: string + transactionHash: string + type: TransactionType + createdAt: string // ISO 8601 +} + +const STORAGE_KEY = 'stepfi-idempotency' +const TTL_MS = 24 * 60 * 60 * 1000 // 24 hours + +function getRecords(): IdempotencyRecord[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter( + (r): r is IdempotencyRecord => + typeof r === 'object' && + r !== null && + typeof (r as IdempotencyRecord).idempotencyKey === 'string' && + typeof (r as IdempotencyRecord).xdrHash === 'string' && + typeof (r as IdempotencyRecord).transactionHash === 'string' && + typeof (r as IdempotencyRecord).type === 'string' && + typeof (r as IdempotencyRecord).createdAt === 'string' + ) + } catch { + return [] + } +} + +function saveRecords(records: IdempotencyRecord[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(records)) + } catch { + // localStorage full or unavailable — silently drop + } +} + +function pruneExpired(records: IdempotencyRecord[]): IdempotencyRecord[] { + const now = Date.now() + return records.filter((r) => { + const createdAt = new Date(r.createdAt).getTime() + return now - createdAt < TTL_MS + }) +} + +async function hashXdr(xdr: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(xdr) + + if (typeof crypto !== 'undefined' && crypto.subtle) { + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') + } + + throw new Error('Web Crypto API not available for XDR hashing.') +} + +export const idempotencyService = { + /** Generate a UUID v4 idempotency key. */ + generateKey(): string { + return crypto.randomUUID() + }, + + /** + * Hash an XDR string for deduplication. + * Returns a hex-encoded SHA-256 hash. + */ + hashXdr, + + /** + * Check if an XDR hash has already been submitted. + * Returns the existing record or null. + * Expired records (> 24 hours) are pruned before lookup. + */ + findExisting(xdrHash: string): IdempotencyRecord | null { + const records = pruneExpired(getRecords()) + // Persist the pruned list so expired entries don't accumulate + saveRecords(records) + return records.find((r) => r.xdrHash === xdrHash) ?? null + }, + + /** + * Check if an idempotency key has already been submitted. + * Returns the existing record or null. + * Expired records are pruned before lookup. + */ + findByKey(idempotencyKey: string): IdempotencyRecord | null { + const records = pruneExpired(getRecords()) + saveRecords(records) + return records.find((r) => r.idempotencyKey === idempotencyKey) ?? null + }, + + /** + * Store a completed transaction record for future deduplication. + */ + store(record: IdempotencyRecord): void { + const records = pruneExpired(getRecords()) + records.push(record) + saveRecords(records) + }, + + /** + * Clear all idempotency records (for testing / manual reset). + */ + clear(): void { + localStorage.removeItem(STORAGE_KEY) + }, +} diff --git a/src/services/transactions.service.ts b/src/services/transactions.service.ts index e05e537..58a3545 100644 --- a/src/services/transactions.service.ts +++ b/src/services/transactions.service.ts @@ -1,4 +1,5 @@ import { api } from './api' +import { idempotencyService } from './idempotency.service' import type { SubmittedTransaction, TransactionType } from '../types' export const transactionsService = { @@ -6,17 +7,57 @@ export const transactionsService = { * Submits a signed XDR transaction to the Stellar network via the backend. * * Backend contract (POST /transactions/submit, SubmitTransactionRequestDto): - * body is `{ xdr, type }` — the field is named `xdr`, not `signedXdr`. + * body is `{ xdr, type, idempotency_key? }` — the field is named `xdr`, not `signedXdr`. * Response envelope is `{ success, data: { transactionHash, status }, message }`. + * + * Idempotency: an idempotency key is auto-generated and sent to the API. + * The XDR is also hashed and checked against localStorage to prevent + * accidental duplicate submissions on the client side. Records expire + * after 24 hours. */ submit: async ( signedXdr: string, type: TransactionType ): Promise => { + const idempotencyKey = idempotencyService.generateKey() + const xdrHash = await idempotencyService.hashXdr(signedXdr) + + // Check for duplicate XDR hash (same transaction already submitted) + const existingByHash = idempotencyService.findExisting(xdrHash) + if (existingByHash) { + return { + transactionHash: existingByHash.transactionHash, + status: 'pending', + } + } + + // Check for duplicate idempotency key (extremely unlikely but possible + // if the same key was reused due to a retry before store() completed) + const existingByKey = idempotencyService.findByKey(idempotencyKey) + if (existingByKey) { + return { + transactionHash: existingByKey.transactionHash, + status: 'pending', + } + } + const res = await api.post('/transactions/submit', { xdr: signedXdr, type, + idempotency_key: idempotencyKey, }) - return res.data.data + + const result: SubmittedTransaction = res.data.data + + // Store the record for future deduplication + idempotencyService.store({ + idempotencyKey, + xdrHash, + transactionHash: result.transactionHash, + type, + createdAt: new Date().toISOString(), + }) + + return result }, } diff --git a/src/types/index.ts b/src/types/index.ts index 255f252..50c1164 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -98,6 +98,15 @@ export interface SubmittedTransaction { status: 'pending' } +/** Idempotency record stored in localStorage to prevent duplicate submissions */ +export interface IdempotencyRecord { + idempotencyKey: string + xdrHash: string + transactionHash: string + type: TransactionType + createdAt: string +} + export interface PoolInfo { totalDeposits: number totalLiquidity: number