From c00b6fb0191331c8ac880c9ea2628181e3acee26 Mon Sep 17 00:00:00 2001 From: dykdee Date: Thu, 30 Jul 2026 15:34:02 +0100 Subject: [PATCH] feat(frontend): fix and harden order recovery from coordinator after reload The frontend already attempted to recover pending/refundable swaps from the coordinator on mount, but the request was silently broken: it sent ?eth=&stellar= query params while the coordinator's /api/orders/history route only accepts a single `address` param (matched against either side of the order). Every recovery request hit the address_required 400 path and fell back to local-only state, so recovery never actually worked after a reload. - Extract recovery logic into frontend/src/lib/orderRecovery.ts: - fetchCoordinatorOrders() issues one correctly-shaped request per connected address, tolerates one side failing, and only throws (triggering the local-cache fallback) when every request fails. - mergeTransactions() dedupes local and recovered orders by hashlock / on-chain order id / tx hash (falling back to id), letting the coordinator's authoritative record win on conflicts. - mapCoordinatorOrderToTransaction()/isRealHash()/isRealTransaction() moved out of the component so they're independently testable. - Wire TransactionHistory.tsx to the fixed service. - Add unit tests for mapping, dedup rules, per-address fetch fanout, partial/total coordinator failure, and fake-hash filtering. - Add component tests covering: recovery after reconnecting a wallet, duplicate suppression between local and recovered orders, and fallback to the local cache when the coordinator is unreachable. Closes #415 --- .../components/TransactionHistory.test.tsx | 166 ++++++++++ .../src/components/TransactionHistory.tsx | 141 +-------- frontend/src/lib/orderRecovery.test.ts | 283 ++++++++++++++++++ frontend/src/lib/orderRecovery.ts | 253 ++++++++++++++++ 4 files changed, 714 insertions(+), 129 deletions(-) create mode 100644 frontend/src/components/TransactionHistory.test.tsx create mode 100644 frontend/src/lib/orderRecovery.test.ts create mode 100644 frontend/src/lib/orderRecovery.ts diff --git a/frontend/src/components/TransactionHistory.test.tsx b/frontend/src/components/TransactionHistory.test.tsx new file mode 100644 index 0000000..3f0da55 --- /dev/null +++ b/frontend/src/components/TransactionHistory.test.tsx @@ -0,0 +1,166 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, afterEach, describe, expect, test, vi } from 'vitest'; +import TransactionHistory from './TransactionHistory'; + +vi.mock('../config/networks', () => ({ + isTestnet: () => true, +})); + +vi.mock('../features/refund/RefundDialog', () => ({ + default: () => null, +})); + +const STORAGE_KEY = 'oversync_transactions_v2'; + +function coordinatorOrder(overrides: Partial> = {}) { + return { + id: 'order-recovered', + direction: 'eth_to_xlm', + status: 'src_locked', + hashlock: '0xhashlockrecovered', + src: { + chain: 'ethereum', + address: '0xEthAddress', + asset: 'ETH', + amount: '1000000000000000000', + safetyDeposit: '0', + orderId: '0xonchainorderid', + lockTx: '0xrecoveredlocktx', + lockBlock: 1, + timelock: 9999999999, + }, + dst: { + chain: 'stellar', + address: 'GSTELLARADDRESS', + asset: 'XLM', + amount: '10000000', + orderId: null, + lockTx: null, + lockBlock: null, + timelock: null, + }, + secret: { revealed: false, preimage: null, revealedTx: null }, + resolver: '0xResolverContract', + createdAt: Math.floor(Date.now() / 1000), + updatedAt: Math.floor(Date.now() / 1000), + ...overrides, + }; +} + +describe('TransactionHistory recovery', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + test('recovers orders from the coordinator after reconnecting a wallet and renders them', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ transactions: [coordinatorOrder()] }), + })); + vi.stubGlobal('fetch', fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByText('ETH Sepolia')).toBeInTheDocument(); + }); + + // One request per connected address, using the coordinator's `address` param. + expect(fetchMock).toHaveBeenCalledTimes(2); + const requestedUrls = fetchMock.mock.calls.map((call) => String(call[0])); + expect(requestedUrls.some((u) => u.includes('address=0xEthAddress'))).toBe(true); + expect(requestedUrls.some((u) => u.includes('address=GSTELLARADDRESS'))).toBe(true); + + // Recovered order persisted to local storage for the next reload. + const stored = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]'); + expect(stored).toHaveLength(1); + expect(stored[0].id).toBe('order-recovered'); + }); + + test('de-duplicates a locally pending order against its coordinator-recovered counterpart', async () => { + const localTx = { + id: 'local-temp-id', + txHash: '0xrecoveredlocktx', + fromNetwork: 'ETH Sepolia', + toNetwork: 'Stellar Testnet', + fromToken: 'ETH', + toToken: 'XLM', + amount: '1', + estimatedAmount: '1', + status: 'pending', + timestamp: Date.now(), + direction: 'eth-to-xlm', + ethTxHash: '0xrecoveredlocktx', + }; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify([localTx])); + + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ transactions: [coordinatorOrder({ id: 'coordinator-final-id' })] }), + })); + vi.stubGlobal('fetch', fetchMock); + + render(); + + await waitFor(() => { + const stored = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]'); + expect(stored).toHaveLength(1); + }); + + const stored = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]'); + // The coordinator's record wins since it carries authoritative on-chain data, + // but the two entries were recognised as the same order (shared tx hash). + expect(stored[0].id).toBe('coordinator-final-id'); + + // Only a single row rendered for the deduped order, not two. + expect(screen.getAllByText('ETH Sepolia')).toHaveLength(1); + }); + + test('falls back to the local cache when the coordinator request fails', async () => { + const localTx = { + id: 'local-only-order', + txHash: '0xlocalonlytx', + fromNetwork: 'ETH Sepolia', + toNetwork: 'Stellar Testnet', + fromToken: 'ETH', + toToken: 'XLM', + amount: '1', + estimatedAmount: '1', + status: 'pending', + timestamp: Date.now(), + direction: 'eth-to-xlm', + }; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify([localTx])); + + const fetchMock = vi.fn(async () => { + throw new Error('network down'); + }); + vi.stubGlobal('fetch', fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByText('ETH Sepolia')).toBeInTheDocument(); + }); + + // Cached local order still renders even though recovery failed. + expect(screen.getByText(/1 ETH/)).toBeInTheDocument(); + }); + + test('does not query the coordinator when no wallet is connected', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByText('No transactions yet')).toBeInTheDocument(); + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/TransactionHistory.tsx b/frontend/src/components/TransactionHistory.tsx index e7b3520..478db12 100644 --- a/frontend/src/components/TransactionHistory.tsx +++ b/frontend/src/components/TransactionHistory.tsx @@ -9,38 +9,13 @@ import { classifyOrderFreshness } from '../lib/orderFreshness'; import { buildHtlcReceipt } from '../lib/parseHtlcReceipt'; import type { Address } from 'viem'; import HtlcTimeline from './HtlcTimeline'; - -interface Transaction { - id: string; - txHash: string; - fromNetwork: string; - toNetwork: string; - fromToken: string; - toToken: string; - amount: string; - estimatedAmount: string; - status: 'pending' | 'completed' | 'cancelled' | 'failed'; - timestamp: number; - ethTxHash?: string; - stellarTxHash?: string; - ethAddress?: string; - stellarAddress?: string; - direction: 'eth-to-xlm' | 'xlm-to-eth'; - // Refund support - // ETH-side refund metadata (eth-to-xlm; populated when ETH is locked on-chain) - onChainOrderId?: string; // bytes32 hex (v1) or uint256 string (v2) - htlcContractAddress?: string; // contract holding the locked ETH - htlcContractMode?: 'v1-mainnet-htlc' | 'v2-escrow'; - timelockUnixSeconds?: number; - amountWei?: string; - // Generic refund tracking (works for both directions) - refundTxHash?: string; - refundNetwork?: 'ethereum' | 'stellar'; // which chain the refund lives on - refundedAt?: number; - autoRefundFailed?: boolean; - autoRefundError?: string; - networkMode?: 'mainnet' | 'testnet'; -} +import { + fetchCoordinatorOrders, + isRealHash, + isRealTransaction, + mergeTransactions, + type Transaction, +} from '../lib/orderRecovery'; interface TransactionHistoryProps { ethAddress?: string; @@ -53,90 +28,10 @@ const API_BASE_URL = import.meta.env.PROD ? '' : (import.meta as any).env?.VITE_API_BASE_URL || PRODUCTION_API_BASE_URL; -// Hash patterns that indicate fabricated/demo data, used to filter out legacy entries -// persisted by older builds. New entries can never match these because v2 only stores -// real on-chain hashes returned from the coordinator. -const KNOWN_FAKE_HASHES = new Set([ - '0x1234567890abcdef1234567890abcdef12345678', - '0xabcdef1234567890abcdef1234567890abcdef12', - '0x9876543210fedcba9876543210fedcba98765432', - '0x0000000000000000000000000000000000000000000000000000000000000000', - '0x0000000000000000000000000000000000000000', -]); - -function isRealHash(hash?: string): boolean { - if (!hash) return true; - if (KNOWN_FAKE_HASHES.has(hash)) return false; - if (hash.startsWith('mock_')) return false; - if (hash.startsWith('placeholder')) return false; - if (/^0x0+$/.test(hash)) return false; - return true; -} - -function isRealTransaction(tx: Transaction): boolean { - return isRealHash(tx.txHash) && isRealHash(tx.ethTxHash) && isRealHash(tx.stellarTxHash); -} - const isTestnetTx = (tx: Transaction): boolean => { return tx.networkMode === 'testnet' || (tx.networkMode === undefined && isTestnet()); }; -function mapCoordinatorOrderToTransaction(order: any): Transaction { - if (order.fromToken || order.fromNetwork) { - return order as Transaction; - } - - const isEthToXlm = order.direction === 'eth_to_xlm' || order.direction === 'eth-to-xlm'; - const isTestnetMode = isTestnet(); - - let status: Transaction['status'] = 'pending'; - if (order.status === 'completed') { - status = 'completed'; - } else if (order.status === 'failed' || order.status === 'expired') { - status = 'failed'; - } else if (order.status === 'refunded') { - status = 'cancelled'; - } - - const srcAmount = order.src?.amount - ? (isEthToXlm ? parseFloat(order.src.amount) / 1e18 : parseFloat(order.src.amount) / 1e7).toString() - : '0'; - const dstAmount = order.dst?.amount - ? (isEthToXlm ? parseFloat(order.dst.amount) / 1e7 : parseFloat(order.dst.amount) / 1e18).toString() - : '0'; - - return { - id: order.id, - txHash: order.src?.lockTx || order.id, - fromNetwork: isEthToXlm - ? (isTestnetMode ? 'ETH Sepolia' : 'ETH Mainnet') - : (isTestnetMode ? 'Stellar Testnet' : 'Stellar Mainnet'), - toNetwork: isEthToXlm - ? (isTestnetMode ? 'Stellar Testnet' : 'Stellar Mainnet') - : (isTestnetMode ? 'ETH Sepolia' : 'ETH Mainnet'), - fromToken: isEthToXlm ? 'ETH' : 'XLM', - toToken: isEthToXlm ? 'XLM' : 'ETH', - amount: srcAmount, - estimatedAmount: dstAmount, - status, - timestamp: order.createdAt ? order.createdAt * 1000 : Date.now(), - ethTxHash: isEthToXlm ? order.src?.lockTx : order.dst?.lockTx, - stellarTxHash: isEthToXlm ? order.dst?.lockTx : order.src?.lockTx, - ethAddress: isEthToXlm ? order.src?.address : order.dst?.address, - stellarAddress: isEthToXlm ? order.dst?.address : order.src?.address, - direction: isEthToXlm ? 'eth-to-xlm' : 'xlm-to-eth', - onChainOrderId: order.src?.orderId, - htlcContractAddress: order.src?.chain === 'ethereum' ? order.resolver : undefined, - htlcContractMode: order.src?.safetyDeposit ? 'v2-escrow' : 'v1-mainnet-htlc', - timelockUnixSeconds: order.src?.timelock, - amountWei: order.src?.amount, - refundTxHash: order.status === 'refunded' ? order.secret?.revealedTx : undefined, - refundNetwork: isEthToXlm ? 'ethereum' : 'stellar', - refundedAt: order.status === 'refunded' ? order.updatedAt * 1000 : undefined, - networkMode: isTestnetMode ? 'testnet' : 'mainnet' - }; -} - export default function TransactionHistory({ ethAddress, stellarAddress }: TransactionHistoryProps) { const [transactions, setTransactions] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -190,32 +85,20 @@ export default function TransactionHistory({ ethAddress, stellarAddress }: Trans }, []); const refreshFromCoordinator = useCallback(async () => { - const apiBase = API_BASE_URL; + const local = loadFromStorage(); if (!ethAddress && !stellarAddress) { - setTransactions(loadFromStorage()); + setTransactions(local); return; } setIsLoading(true); try { - const params = new URLSearchParams(); - if (ethAddress) params.set('eth', ethAddress); - if (stellarAddress) params.set('stellar', stellarAddress); - const res = await fetch(`${apiBase}/api/orders/history?${params.toString()}`); - if (!res.ok) throw new Error(`Coordinator returned ${res.status}`); - const body = await res.json(); - const remote: Transaction[] = Array.isArray(body?.transactions) - ? body.transactions.map(mapCoordinatorOrderToTransaction).filter(isRealTransaction) - : []; - const local = loadFromStorage(); - const byId = new Map(); - for (const tx of local) byId.set(tx.id, tx); - for (const tx of remote) byId.set(tx.id, tx); - const merged = Array.from(byId.values()).sort((a, b) => b.timestamp - a.timestamp); + const remote = await fetchCoordinatorOrders(API_BASE_URL, { ethAddress, stellarAddress }); + const merged = mergeTransactions(local, remote); localStorage.setItem(STORAGE_KEY, JSON.stringify(merged)); setTransactions(merged); } catch (err) { console.warn('Coordinator history unavailable, falling back to local cache:', err); - setTransactions(loadFromStorage()); + setTransactions(local); } finally { setIsLoading(false); } diff --git a/frontend/src/lib/orderRecovery.test.ts b/frontend/src/lib/orderRecovery.test.ts new file mode 100644 index 0000000..b3771fa --- /dev/null +++ b/frontend/src/lib/orderRecovery.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, test } from 'vitest'; +import { + fetchCoordinatorOrders, + isRealHash, + isRealTransaction, + mapCoordinatorOrderToTransaction, + mergeTransactions, + type Transaction, +} from './orderRecovery'; + +function makeCoordinatorOrder(overrides: Partial> = {}) { + return { + id: 'order-123', + direction: 'eth_to_xlm', + status: 'src_locked', + hashlock: '0xhashlock123', + src: { + chain: 'ethereum', + address: '0xEthAddress', + asset: 'ETH', + amount: '1000000000000000000', + safetyDeposit: '0', + orderId: '0xonchainorderid', + lockTx: '0xethlocktx', + lockBlock: 1, + timelock: 9999999999, + }, + dst: { + chain: 'stellar', + address: 'GSTELLARADDRESS', + asset: 'XLM', + amount: '10000000', + orderId: null, + lockTx: null, + lockBlock: null, + timelock: null, + }, + secret: { revealed: false, preimage: null, revealedTx: null }, + resolver: '0xResolverContract', + createdAt: 1_700_000_000, + updatedAt: 1_700_000_100, + ...overrides, + }; +} + +function makeLocalTransaction(overrides: Partial = {}): Transaction { + return { + id: 'order-123', + txHash: '0xethlocktx', + fromNetwork: 'ETH Sepolia', + toNetwork: 'Stellar Testnet', + fromToken: 'ETH', + toToken: 'XLM', + amount: '1', + estimatedAmount: '1', + status: 'pending', + timestamp: 1_700_000_000_000, + direction: 'eth-to-xlm', + ...overrides, + }; +} + +describe('mapCoordinatorOrderToTransaction', () => { + test('maps a raw coordinator order into the UI Transaction shape', () => { + const tx = mapCoordinatorOrderToTransaction(makeCoordinatorOrder()); + + expect(tx.id).toBe('order-123'); + expect(tx.direction).toBe('eth-to-xlm'); + expect(tx.status).toBe('pending'); + expect(tx.hashlock).toBe('0xhashlock123'); + expect(tx.onChainOrderId).toBe('0xonchainorderid'); + expect(tx.ethTxHash).toBe('0xethlocktx'); + expect(tx.timelockUnixSeconds).toBe(9999999999); + }); + + test('passes through already-mapped local transactions unchanged', () => { + const local = makeLocalTransaction(); + expect(mapCoordinatorOrderToTransaction(local)).toBe(local); + }); + + test('maps refunded orders to cancelled status with refund metadata', () => { + const tx = mapCoordinatorOrderToTransaction( + makeCoordinatorOrder({ + status: 'refunded', + secret: { revealed: false, preimage: null, revealedTx: '0xrefundtx' }, + }) + ); + expect(tx.status).toBe('cancelled'); + expect(tx.refundTxHash).toBe('0xrefundtx'); + }); +}); + +describe('isRealHash / isRealTransaction', () => { + test('flags known fake/demo hashes as not real', () => { + expect(isRealHash('0x1234567890abcdef1234567890abcdef12345678')).toBe(false); + expect(isRealHash('mock_something')).toBe(false); + expect(isRealHash('0x0000000000000000000000000000000000000000')).toBe(false); + }); + + test('treats a genuine-looking hash as real', () => { + expect(isRealHash('0xabc123def4567890')).toBe(true); + expect(isRealHash(undefined)).toBe(true); + }); + + test('isRealTransaction rejects a transaction with any fake hash', () => { + const tx = makeLocalTransaction({ ethTxHash: '0x1234567890abcdef1234567890abcdef12345678' }); + expect(isRealTransaction(tx)).toBe(false); + }); +}); + +describe('mergeTransactions', () => { + test('dedupes a locally-created order against its coordinator-recovered counterpart by id', () => { + const local = [makeLocalTransaction()]; + const remote = [mapCoordinatorOrderToTransaction(makeCoordinatorOrder())]; + + const merged = mergeTransactions(local, remote); + expect(merged).toHaveLength(1); + // Remote (authoritative) data wins. + expect(merged[0].hashlock).toBe('0xhashlock123'); + expect(merged[0].onChainOrderId).toBe('0xonchainorderid'); + }); + + test('dedupes by hashlock even when ids differ', () => { + const local = [makeLocalTransaction({ id: 'local-temp-id' })]; + const remote = [ + mapCoordinatorOrderToTransaction(makeCoordinatorOrder({ id: 'coordinator-final-id' })), + ]; + + const merged = mergeTransactions(local, remote); + expect(merged).toHaveLength(1); + expect(merged[0].id).toBe('coordinator-final-id'); + }); + + test('dedupes by on-chain order id when ids and hashlocks differ', () => { + const local = [ + makeLocalTransaction({ id: 'local-1', onChainOrderId: '0xonchainorderid' }), + ]; + const remote = [ + mapCoordinatorOrderToTransaction( + makeCoordinatorOrder({ id: 'remote-1', hashlock: '0xdifferenthashlock' }) + ), + ]; + + const merged = mergeTransactions(local, remote); + expect(merged).toHaveLength(1); + }); + + test('dedupes by shared tx hash when ids and hashlocks differ', () => { + const local = [ + makeLocalTransaction({ id: 'local-2', ethTxHash: '0xsharedtxhash', onChainOrderId: undefined }), + ]; + const remote = [ + mapCoordinatorOrderToTransaction( + makeCoordinatorOrder({ + id: 'remote-2', + hashlock: '0xdifferenthashlock', + src: { ...makeCoordinatorOrder().src, orderId: '0xdifferentorderid', lockTx: '0xsharedtxhash' }, + }) + ), + ]; + + const merged = mergeTransactions(local, remote); + expect(merged).toHaveLength(1); + }); + + test('keeps genuinely distinct orders separate', () => { + const local = [makeLocalTransaction({ id: 'order-a', txHash: '0xlocaltxa' })]; + const remote = [ + mapCoordinatorOrderToTransaction( + makeCoordinatorOrder({ + id: 'order-b', + hashlock: '0xotherhashlock', + src: { ...makeCoordinatorOrder().src, orderId: '0xotherorderid', lockTx: '0xothertx' }, + }) + ), + ]; + + const merged = mergeTransactions(local, remote); + expect(merged).toHaveLength(2); + }); + + test('sorts merged results by most recent timestamp first', () => { + const older = makeLocalTransaction({ id: 'older', txHash: '0xoldertx', timestamp: 1000 }); + const newer = makeLocalTransaction({ id: 'newer', txHash: '0xnewertx', timestamp: 2000 }); + + const merged = mergeTransactions([older], [newer]); + expect(merged.map((t) => t.id)).toEqual(['newer', 'older']); + }); +}); + +describe('fetchCoordinatorOrders', () => { + test('queries the coordinator once per connected address and merges results', async () => { + const calls: string[] = []; + const fetchImpl = (async (input: RequestInfo | URL) => { + const url = String(input); + calls.push(url); + if (url.includes('address=0xEth')) { + return { + ok: true, + json: async () => ({ transactions: [makeCoordinatorOrder({ id: 'eth-order' })] }), + } as Response; + } + return { + ok: true, + json: async () => ({ transactions: [makeCoordinatorOrder({ id: 'stellar-order' })] }), + } as Response; + }) as typeof fetch; + + const result = await fetchCoordinatorOrders( + 'https://coordinator.example', + { ethAddress: '0xEth', stellarAddress: 'GSTELLAR' }, + fetchImpl + ); + + expect(calls).toHaveLength(2); + expect(calls[0]).toContain('/api/orders/history?address=0xEth'); + expect(calls[1]).toContain('/api/orders/history?address=GSTELLAR'); + expect(result.map((t) => t.id).sort()).toEqual(['eth-order', 'stellar-order']); + }); + + test('returns an empty list when no addresses are connected', async () => { + const fetchImpl = (async () => { + throw new Error('should not be called'); + }) as unknown as typeof fetch; + + const result = await fetchCoordinatorOrders('https://coordinator.example', {}, fetchImpl); + expect(result).toEqual([]); + }); + + test('throws when every request fails, so callers can fall back to the local cache', async () => { + const fetchImpl = (async () => ({ ok: false, status: 500, json: async () => ({}) }) as Response) as typeof fetch; + + await expect( + fetchCoordinatorOrders('https://coordinator.example', { ethAddress: '0xEth' }, fetchImpl) + ).rejects.toThrow(); + }); + + test('still returns data from the address that succeeded when the other fails', async () => { + const fetchImpl = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('address=0xEth')) { + return { + ok: true, + json: async () => ({ transactions: [makeCoordinatorOrder({ id: 'eth-order' })] }), + } as Response; + } + return { ok: false, status: 500, json: async () => ({}) } as Response; + }) as typeof fetch; + + const result = await fetchCoordinatorOrders( + 'https://coordinator.example', + { ethAddress: '0xEth', stellarAddress: 'GSTELLAR' }, + fetchImpl + ); + + expect(result.map((t) => t.id)).toEqual(['eth-order']); + }); + + test('filters out orders with fabricated/demo hashes', async () => { + const fetchImpl = (async () => ({ + ok: true, + json: async () => ({ + transactions: [ + makeCoordinatorOrder({ + id: 'fake-order', + src: { + ...makeCoordinatorOrder().src, + lockTx: '0x1234567890abcdef1234567890abcdef12345678', + }, + }), + ], + }), + }) as Response) as typeof fetch; + + const result = await fetchCoordinatorOrders( + 'https://coordinator.example', + { ethAddress: '0xEth' }, + fetchImpl + ); + + expect(result).toEqual([]); + }); +}); diff --git a/frontend/src/lib/orderRecovery.ts b/frontend/src/lib/orderRecovery.ts new file mode 100644 index 0000000..a6a8d96 --- /dev/null +++ b/frontend/src/lib/orderRecovery.ts @@ -0,0 +1,253 @@ +/** + * orderRecovery.ts + * + * Frontend-side recovery of pending/refundable swaps from the coordinator + * API. This is what lets a user close the tab (or lose localStorage) and + * still see — and refund — their in-flight orders after reconnecting a + * wallet. + * + * The coordinator's `/api/orders/history` endpoint only accepts a single + * `address` query param and matches it against either side of the order + * (`src_address` OR `dst_address`). Since a swap always has one Ethereum + * address and one Stellar address, recovering "everything for this user" + * means issuing one request per connected address and merging the results. + * + * No RPC calls are made here — everything comes from the coordinator's + * persisted order state. + */ + +import { isTestnet } from '../config/networks'; + +export interface Transaction { + id: string; + txHash: string; + fromNetwork: string; + toNetwork: string; + fromToken: string; + toToken: string; + amount: string; + estimatedAmount: string; + status: 'pending' | 'completed' | 'cancelled' | 'failed'; + timestamp: number; + ethTxHash?: string; + stellarTxHash?: string; + ethAddress?: string; + stellarAddress?: string; + direction: 'eth-to-xlm' | 'xlm-to-eth'; + /** sha256 hashlock as returned by the coordinator, when known. */ + hashlock?: string; + // Refund support + // ETH-side refund metadata (eth-to-xlm; populated when ETH is locked on-chain) + onChainOrderId?: string; // bytes32 hex (v1) or uint256 string (v2) + htlcContractAddress?: string; // contract holding the locked ETH + htlcContractMode?: 'v1-mainnet-htlc' | 'v2-escrow'; + timelockUnixSeconds?: number; + amountWei?: string; + // Generic refund tracking (works for both directions) + refundTxHash?: string; + refundNetwork?: 'ethereum' | 'stellar'; // which chain the refund lives on + refundedAt?: number; + autoRefundFailed?: boolean; + autoRefundError?: string; + networkMode?: 'mainnet' | 'testnet'; +} + +export interface RecoveryAddresses { + ethAddress?: string; + stellarAddress?: string; +} + +// Hash patterns that indicate fabricated/demo data, used to filter out legacy +// entries persisted by older builds. New entries can never match these +// because v2 only stores real on-chain hashes returned from the coordinator. +const KNOWN_FAKE_HASHES = new Set([ + '0x1234567890abcdef1234567890abcdef12345678', + '0xabcdef1234567890abcdef1234567890abcdef12', + '0x9876543210fedcba9876543210fedcba98765432', + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000', +]); + +export function isRealHash(hash?: string): boolean { + if (!hash) return true; + if (KNOWN_FAKE_HASHES.has(hash)) return false; + if (hash.startsWith('mock_')) return false; + if (hash.startsWith('placeholder')) return false; + if (/^0x0+$/.test(hash)) return false; + return true; +} + +export function isRealTransaction(tx: Transaction): boolean { + return isRealHash(tx.txHash) && isRealHash(tx.ethTxHash) && isRealHash(tx.stellarTxHash); +} + +/** + * Map a raw coordinator order (as serialised by + * `coordinator/src/server/routes/orders.ts`) into the UI's `Transaction` + * shape. Locally-created transactions are passed through unchanged (they + * already have `fromToken`/`fromNetwork`). + */ +export function mapCoordinatorOrderToTransaction(order: any): Transaction { + if (order.fromToken || order.fromNetwork) { + return order as Transaction; + } + + const isEthToXlm = order.direction === 'eth_to_xlm' || order.direction === 'eth-to-xlm'; + const isTestnetMode = isTestnet(); + + let status: Transaction['status'] = 'pending'; + if (order.status === 'completed') { + status = 'completed'; + } else if (order.status === 'failed' || order.status === 'expired') { + status = 'failed'; + } else if (order.status === 'refunded') { + status = 'cancelled'; + } + + const srcAmount = order.src?.amount + ? (isEthToXlm ? parseFloat(order.src.amount) / 1e18 : parseFloat(order.src.amount) / 1e7).toString() + : '0'; + const dstAmount = order.dst?.amount + ? (isEthToXlm ? parseFloat(order.dst.amount) / 1e7 : parseFloat(order.dst.amount) / 1e18).toString() + : '0'; + + return { + id: order.id, + txHash: order.src?.lockTx || order.id, + fromNetwork: isEthToXlm + ? (isTestnetMode ? 'ETH Sepolia' : 'ETH Mainnet') + : (isTestnetMode ? 'Stellar Testnet' : 'Stellar Mainnet'), + toNetwork: isEthToXlm + ? (isTestnetMode ? 'Stellar Testnet' : 'Stellar Mainnet') + : (isTestnetMode ? 'ETH Sepolia' : 'ETH Mainnet'), + fromToken: isEthToXlm ? 'ETH' : 'XLM', + toToken: isEthToXlm ? 'XLM' : 'ETH', + amount: srcAmount, + estimatedAmount: dstAmount, + status, + timestamp: order.createdAt ? order.createdAt * 1000 : Date.now(), + ethTxHash: isEthToXlm ? order.src?.lockTx : order.dst?.lockTx, + stellarTxHash: isEthToXlm ? order.dst?.lockTx : order.src?.lockTx, + ethAddress: isEthToXlm ? order.src?.address : order.dst?.address, + stellarAddress: isEthToXlm ? order.dst?.address : order.src?.address, + direction: isEthToXlm ? 'eth-to-xlm' : 'xlm-to-eth', + hashlock: order.hashlock, + onChainOrderId: order.src?.orderId, + htlcContractAddress: order.src?.chain === 'ethereum' ? order.resolver : undefined, + htlcContractMode: order.src?.safetyDeposit ? 'v2-escrow' : 'v1-mainnet-htlc', + timelockUnixSeconds: order.src?.timelock, + amountWei: order.src?.amount, + refundTxHash: order.status === 'refunded' ? order.secret?.revealedTx : undefined, + refundNetwork: isEthToXlm ? 'ethereum' : 'stellar', + refundedAt: order.status === 'refunded' ? order.updatedAt * 1000 : undefined, + networkMode: isTestnetMode ? 'testnet' : 'mainnet', + }; +} + +/** + * Fetch every order the coordinator knows about for the connected + * addresses. Issues one request per address (the coordinator only + * supports a single `address` filter) and merges/dedupes the raw results + * by `id` before mapping them into `Transaction`s. + * + * Throws if every request fails so callers can fall back to cached data. + */ +export async function fetchCoordinatorOrders( + apiBase: string, + addresses: RecoveryAddresses, + fetchImpl: typeof fetch = fetch +): Promise { + const targets = [addresses.ethAddress, addresses.stellarAddress].filter( + (a): a is string => Boolean(a) + ); + if (targets.length === 0) return []; + + const settled = await Promise.allSettled( + targets.map(async (address) => { + const params = new URLSearchParams({ address, limit: '50' }); + const res = await fetchImpl(`${apiBase}/api/orders/history?${params.toString()}`); + if (!res.ok) throw new Error(`Coordinator returned ${res.status}`); + const body = await res.json(); + return Array.isArray(body?.transactions) ? body.transactions : []; + }) + ); + + const failures = settled.filter((s): s is PromiseRejectedResult => s.status === 'rejected'); + if (failures.length === settled.length) { + // Every request failed — surface the first error so callers can fall + // back to the local cache instead of silently showing an empty list. + throw failures[0].reason; + } + + const byId = new Map(); + for (const result of settled) { + if (result.status !== 'fulfilled') continue; + for (const order of result.value) { + if (order?.id) byId.set(order.id, order); + } + } + + return Array.from(byId.values()) + .map(mapCoordinatorOrderToTransaction) + .filter(isRealTransaction); +} + +/** + * Build the set of identifiers a transaction can be recognised by. Two + * transactions that share any signal are considered the same underlying + * order — this is what lets a locally-created pending swap (which only + * knows its `id`/tx hashes) merge cleanly with the richer record the + * coordinator returns after recovery (which also carries `hashlock` and + * `onChainOrderId`). + */ +function transactionSignals(tx: Transaction): string[] { + const signals: string[] = [`id:${tx.id}`]; + if (tx.hashlock) signals.push(`hashlock:${tx.hashlock}`); + if (tx.onChainOrderId) signals.push(`orderid:${tx.onChainOrderId}`); + if (tx.txHash && isRealHash(tx.txHash)) signals.push(`tx:${tx.txHash}`); + if (tx.ethTxHash && isRealHash(tx.ethTxHash)) signals.push(`tx:${tx.ethTxHash}`); + if (tx.stellarTxHash && isRealHash(tx.stellarTxHash)) signals.push(`tx:${tx.stellarTxHash}`); + return signals; +} + +/** + * Merge locally-created transactions with orders recovered from the + * coordinator, de-duplicating by hashlock / on-chain order id / tx hash + * (falling back to `id`). When both sides describe the same order, the + * coordinator's version wins since it reflects authoritative on-chain + * state (lock tx hashes, timelocks, refund status, etc). + * + * Order is preserved by most recent `timestamp` first. + */ +export function mergeTransactions(local: Transaction[], remote: Transaction[]): Transaction[] { + const merged: Transaction[] = []; + const signalToIndex = new Map(); + + const upsert = (tx: Transaction) => { + const signals = transactionSignals(tx); + let index = -1; + for (const signal of signals) { + const existing = signalToIndex.get(signal); + if (existing !== undefined) { + index = existing; + break; + } + } + + if (index === -1) { + index = merged.length; + merged.push(tx); + } else { + merged[index] = { ...merged[index], ...tx }; + } + + for (const signal of signals) { + signalToIndex.set(signal, index); + } + }; + + for (const tx of local) upsert(tx); + for (const tx of remote) upsert(tx); + + return merged.sort((a, b) => b.timestamp - a.timestamp); +}