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 019e8a1..5edf6c2 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