Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions src/routes/__tests__/operator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

Check failure on line 1 in src/routes/__tests__/operator.test.ts

View workflow job for this annotation

GitHub Actions / build-library

Cannot find module 'vitest' or its corresponding type declarations.

Check failure on line 1 in src/routes/__tests__/operator.test.ts

View workflow job for this annotation

GitHub Actions / build-hosted

Cannot find module 'vitest' or its corresponding type declarations.
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);

Check warning on line 53 in src/routes/__tests__/operator.test.ts

View workflow job for this annotation

GitHub Actions / lint

'result' is assigned a value but never used

Check failure on line 53 in src/routes/__tests__/operator.test.ts

View workflow job for this annotation

GitHub Actions / build-library

'result' is declared but its value is never read.

Check failure on line 53 in src/routes/__tests__/operator.test.ts

View workflow job for this annotation

GitHub Actions / build-hosted

'result' is declared but its value is never read.

// 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();
});
});
});
164 changes: 131 additions & 33 deletions src/routes/operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,21 +75,136 @@ export default class RouteOperator {
}

async resumeFromTx(tx: TransactionId): Promise<TxInfo | null> {
// 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<string[]> {
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<string>();

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<TxInfo | null> {
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<TxInfo | null> {
// This is the original brute force implementation
return new Promise((resolve) => {
const totalAttemptsToMake = Object.keys(this.routes).length;
let failedAttempts = 0;

Expand All @@ -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);
}
Expand Down
Loading