From 21d8fc3ba765b2a7c1dbb1ecce8e5101d805626a Mon Sep 17 00:00:00 2001 From: Tony Jin <689351+tonyjin@users.noreply.github.com> Date: Mon, 21 Jul 2025 15:05:50 -0700 Subject: [PATCH] fix: Optimize resume txn logic using Wormholescan --- src/routes/__tests__/operator.test.ts | 137 +++++++++++++++++++++ src/routes/operator.ts | 164 ++++++++++++++++++++------ 2 files changed, 268 insertions(+), 33 deletions(-) create mode 100644 src/routes/__tests__/operator.test.ts diff --git a/src/routes/__tests__/operator.test.ts b/src/routes/__tests__/operator.test.ts new file mode 100644 index 000000000..ac4413692 --- /dev/null +++ b/src/routes/__tests__/operator.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import RouteOperator from '../operator'; +import { TransactionId } from '@wormhole-foundation/sdk'; + +// Mock the config +vi.mock('config', () => ({ + default: { + wormholeApi: 'https://api.wormholescan.io/', + }, +})); + +// Mock fetch +global.fetch = vi.fn(); + +describe('RouteOperator', () => { + let routeOperator: RouteOperator; + + beforeEach(() => { + routeOperator = new RouteOperator(); + vi.clearAllMocks(); + }); + + describe('resumeFromTx with Wormholescan API', () => { + it('should use Wormholescan API to identify CCTP routes', async () => { + const mockTx: TransactionId = { + chain: 'Ethereum', + txid: '0x1234567890abcdef', + }; + + // Mock Wormholescan API response for CCTP transfer + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + operations: [ + { + content: { + standarizedProperties: { + appIds: ['CCTP_WORMHOLE_INTEGRATION'], + }, + }, + }, + ], + }), + }); + + // Mock the route's resumeIfManual to simulate CCTP route success + const mockReceipt = { state: 'Attested' }; + const cctpRoute = routeOperator.routes['ManualCCTP']; + if (cctpRoute) { + cctpRoute.resumeIfManual = vi.fn().mockResolvedValue(mockReceipt); + } + + const result = await routeOperator.resumeFromTx(mockTx); + + // Verify Wormholescan API was called + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.wormholescan.io/api/v1/operations?txHash=0x1234567890abcdef', + { headers: { accept: 'application/json' } }, + ); + + // Verify only CCTP routes were tried + if (cctpRoute) { + expect(cctpRoute.resumeIfManual).toHaveBeenCalled(); + } + + // Verify non-CCTP routes were not tried + const tokenBridgeRoute = routeOperator.routes['ManualTokenBridge']; + if (tokenBridgeRoute && tokenBridgeRoute.resumeIfManual) { + expect(tokenBridgeRoute.resumeIfManual).not.toHaveBeenCalled(); + } + }); + + it('should fall back to brute force when Wormholescan API fails', async () => { + const mockTx: TransactionId = { + chain: 'Ethereum', + txid: '0x1234567890abcdef', + }; + + // Mock Wormholescan API to fail + (global.fetch as any).mockRejectedValueOnce(new Error('API Error')); + + // Mock all routes to fail except one + Object.values(routeOperator.routes).forEach((route) => { + route.resumeIfManual = vi.fn().mockResolvedValue(null); + }); + + const tokenBridgeRoute = routeOperator.routes['ManualTokenBridge']; + const mockReceipt = { state: 'Attested' }; + if (tokenBridgeRoute) { + tokenBridgeRoute.resumeIfManual = vi + .fn() + .mockResolvedValue(mockReceipt); + } + + const result = await routeOperator.resumeFromTx(mockTx); + + // Verify all routes were tried (brute force) + Object.values(routeOperator.routes).forEach((route) => { + expect(route.resumeIfManual).toHaveBeenCalled(); + }); + + expect(result).toEqual({ + route: 'ManualTokenBridge', + receipt: mockReceipt, + }); + }); + + it('should handle Wormholescan API returning no operations', async () => { + const mockTx: TransactionId = { + chain: 'Ethereum', + txid: '0x1234567890abcdef', + }; + + // Mock Wormholescan API response with no operations + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + operations: [], + }), + }); + + // Mock a route to succeed + const tokenBridgeRoute = routeOperator.routes['ManualTokenBridge']; + const mockReceipt = { state: 'Attested' }; + if (tokenBridgeRoute) { + tokenBridgeRoute.resumeIfManual = vi + .fn() + .mockResolvedValue(mockReceipt); + } + + await routeOperator.resumeFromTx(mockTx); + + // Verify it fell back to brute force + expect(tokenBridgeRoute.resumeIfManual).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/routes/operator.ts b/src/routes/operator.ts index 33aee9a13..4d3c1f9df 100644 --- a/src/routes/operator.ts +++ b/src/routes/operator.ts @@ -75,21 +75,136 @@ export default class RouteOperator { } async resumeFromTx(tx: TransactionId): Promise { - // This function identifies which route a transaction corresponds using brute force. - // It tries to call resume() on every manual route until one of them succeeds. - // - // This was just the simpler approach. In the future we can possibly optimize this by - // trying some tricks to identify which route the transaction is for, but this would - // come at the cost of added code, complexity, and potential bugs. - // - // That trade-off might not be worth it though - - return new Promise((resolve, reject) => { - // This promise runs resumeIfManual on each route in parallel and resolves as soon - // as it finds a receipt from any of the available routes. This is different from just using - // Promise.race, because we only want to resolve under specific conditions. - // - // The assumption is that at most one route will produce a receipt. + // First, try to identify the route via Wormholescan API + const routesToTry = await this.getRoutesFromWormholescan(tx); + + if (routesToTry.length > 0) { + // Try only the specific routes identified by Wormholescan + const result = await this.trySpecificRoutes(tx, routesToTry); + if (result !== null) { + return result; + } + } + + // Fall back to brute force approach if: + // 1. Wormholescan API didn't return any routes + // 2. The identified routes didn't succeed + return this.tryAllRoutes(tx); + } + + private async getRoutesFromWormholescan( + tx: TransactionId, + ): Promise { + try { + const response = await fetch( + `${config.wormholeApi}api/v1/operations?txHash=${tx.txid}`, + { headers: { accept: 'application/json' } }, + ); + + if (!response.ok) { + return []; + } + + const data = await response.json(); + const operations = data?.operations; + + if (!operations || operations.length === 0) { + return []; + } + + // Get appIds from the first matching operation + const appIds = + operations[0]?.content?.standarizedProperties?.appIds || []; + + // Map appIds to route names + const routeNames = new Set(); + + for (const appId of appIds) { + switch (appId) { + case 'CCTP_WORMHOLE_INTEGRATION': + routeNames.add('ManualCCTP'); + routeNames.add('AutomaticCCTPRoute'); + routeNames.add('CCTPRoute'); + routeNames.add('CCTPExecutorRoute'); + routeNames.add('CCTPv2StandardExecutorRoute'); + routeNames.add('CCTPv2FastExecutorRoute'); + break; + case 'PORTAL_TOKEN_BRIDGE': + routeNames.add('ManualTokenBridge'); + routeNames.add('AutomaticTokenBridgeRoute'); + routeNames.add('TokenBridgeRoute'); + routeNames.add('TokenBridgeExecutorRoute'); + break; + case 'NATIVE_TOKEN_TRANSFER': + routeNames.add('ManualNtt'); + routeNames.add('NttExecutorRoute'); + break; + case 'GENERIC_RELAYER': + // Could be various routes, add common relayer-based routes + routeNames.add('AutomaticTokenBridgeRoute'); + routeNames.add('AutomaticCCTPRoute'); + break; + // TBTCRoute doesn't have a specific appId mapping in Wormholescan + // It will be tried in the fallback brute force approach + } + } + + // Filter to only routes that are actually configured + return Array.from(routeNames).filter((name) => name in this.routes); + } catch (error) { + // Silently fail and return empty array to trigger fallback + return []; + } + } + + private async trySpecificRoutes( + tx: TransactionId, + routeNames: string[], + ): Promise { + return new Promise((resolve) => { + let attempts = 0; + const totalAttempts = routeNames.length; + + if (totalAttempts === 0) { + resolve(null); + return; + } + + for (const name of routeNames) { + const route = this.routes[name]; + if (!route) { + attempts += 1; + if (attempts === totalAttempts) { + resolve(null); + } + continue; + } + + route + .resumeIfManual(tx) + .then((receipt) => { + if (receipt !== null) { + resolve({ route: name, receipt }); + } else { + attempts += 1; + if (attempts === totalAttempts) { + resolve(null); + } + } + }) + .catch(() => { + attempts += 1; + if (attempts === totalAttempts) { + resolve(null); + } + }); + } + }); + } + + private async tryAllRoutes(tx: TransactionId): Promise { + // This is the original brute force implementation + return new Promise((resolve) => { const totalAttemptsToMake = Object.keys(this.routes).length; let failedAttempts = 0; @@ -103,27 +218,10 @@ export default class RouteOperator { failedAttempts += 1; } }) - .catch((e) => { + .catch(() => { failedAttempts += 1; - // Possible reasons for error here: - // - // - Given transaction does not correspond to this route. - // We expect this case to happen because it's how we narrow down - // which route this transaction corresponds to. It's not a problem. - // - // - Otherwise, perhaps this is corresponding route but some other error - // happened when fetching the metadata required to construct a receipt. - // - // We handle both of these the same way for now - by continuing. - // - // If we add logic to identify the route in a different way in the future, - // we can possibly handle these two error cases differently. - // - // If we reach the end of the for-loop without a successful result from resume() - // then we tell the user that the transaction can't be resumed. }) .finally(() => { - // If we failed to get a receipt from all routes, resolve to null if (failedAttempts === totalAttemptsToMake) { resolve(null); }