Skip to content

Commit 63aaa73

Browse files
committed
ft_watcher: worker to invoke ft
test: initial swap monitor testing ft_swap: parse redeem params to get fill vaa add id for redeem event ft_watcher: parse swap event and input ft_watcher: plug swap layer into ft watcher Signed-off-by: bingyuyap <bingyu.yap.21@gmail.com>
1 parent de8f1f8 commit 63aaa73

8 files changed

+305
-25
lines changed

database/fast-transfer-schema.sql

+12
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ DROP TABLE IF EXISTS fast_transfer_executions;
44
DROP TABLE IF EXISTS fast_transfer_settlements;
55
DROP TABLE IF EXISTS auction_logs;
66
DROP TABLE IF EXISTS auction_history_mapping;
7+
DROP TABLE IF EXISTS redeem_swaps;
78

89
DROP TYPE IF EXISTS FastTransferStatus;
910
DROP TYPE IF EXISTS FastTransferProtocol;
@@ -92,3 +93,14 @@ CREATE TABLE auction_history_mapping (
9293
auction_pubkey VARCHAR(255) PRIMARY KEY,
9394
index INT NOT NULL
9495
);
96+
97+
-- Redeem Swaps table to track the final swap before funds reach the user's account
98+
CREATE TABLE redeem_swaps (
99+
fill_vaa_id VARCHAR(255) PRIMARY KEY,
100+
tx_hash VARCHAR(255) NOT NULL,
101+
recipient VARCHAR(255) NOT NULL,
102+
output_token VARCHAR(255) NOT NULL,
103+
output_amount BIGINT NOT NULL,
104+
relaying_fee BIGINT NOT NULL,
105+
timestamp TIMESTAMP NOT NULL
106+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"to": "0xdA11B3bc8705D84BEae4a796035bDcCc9b59d1ee",
3+
"from": "0x3E5e9111Ae8eB78Fe1CC3bb8915d5D461F3Ef9A9",
4+
"contractAddress": null,
5+
"transactionIndex": 0,
6+
"logsBloom": "0x0200000000400100008000010000000100000000000000000800010000020000000200000000000001000200000000000000000000000008000000000080000000000000000000400800000800000000000000000000000000000000000000000002000002000002800000000001380000000000010100000000001000840000000000000000040000020000000000020000010001000020000000000000040000000000000020000200000000000000000000000000000000000000000000000000001200000040000000000000000000000000000000000000000000002000400002000000000000000018000000000c080000000800000000000000000000",
7+
"blockHash": "0x823e2da477c5e8f8aff4bec177b11adca3ad16550d65faf38a3895e4c23d503b",
8+
"transactionHash": "0x8e61395ff443d67697fdafad62403dca90f2ffb0181e141b0c1ed52090873d13",
9+
"logs": [
10+
{
11+
"transactionIndex": 0,
12+
"blockNumber": 20034952,
13+
"transactionHash": "0x8e61395ff443d67697fdafad62403dca90f2ffb0181e141b0c1ed52090873d13",
14+
"address": "0xdA11B3bc8705D84BEae4a796035bDcCc9b59d1ee",
15+
"topics": [
16+
"0x5cdf07ad0fc222442720b108e3ed4c4640f0fadc2ab2253e66f259a0fea83480",
17+
"0x00000000000000000000000095ced938f7991cd0dfcb48f0a06a40fa1af46ebc"
18+
],
19+
"data": "0x000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000004a817c80000000000000000000000000000000000000000000000000000000000009f6a8c",
20+
"logIndex": 7,
21+
"blockHash": "0x823e2da477c5e8f8aff4bec177b11adca3ad16550d65faf38a3895e4c23d503b"
22+
}
23+
],
24+
"blockNumber": 20034952,
25+
"confirmations": 1,
26+
"status": 1,
27+
"type": 2,
28+
"byzantium": true
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"hash": "0x8e61395ff443d67697fdafad62403dca90f2ffb0181e141b0c1ed52090873d13",
3+
"type": 2,
4+
"accessList": [],
5+
"blockHash": "0x823e2da477c5e8f8aff4bec177b11adca3ad16550d65faf38a3895e4c23d503b",
6+
"blockNumber": 20034952,
7+
"transactionIndex": 0,
8+
"confirmations": 1,
9+
"from": "0x3E5e9111Ae8eB78Fe1CC3bb8915d5D461F3Ef9A9",
10+
"to": "0xdA11B3bc8705D84BEae4a796035bDcCc9b59d1ee",
11+
"nonce": 19,
12+
"data": "0x604009a900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000001800100000000010026ef88e25acfa030a19569d2e6bf14c4c79a74d8b13d290c070f29a61082f4306b33c82d9a3159a1f97e94ed5b06a06aa343f39ac63a773093417b10d1ccdfe60066b24580000000000001cb0406e59555bf0371b7c4fff1812a11a8d92dad02ad422062971d61dcce2cd000000000000000022001c6fa7af3bedbad3a3d65f36aabc97431b1bbe4c2d2f6e0e47ca60203452f5d6100000000000000000000000000000000000000000000000000000004a8b7328c00000005000000000000000000000027aa2c95ecc476da8c735e951c5308579c8a1cb5b068dc7427a85554c8e0dc562300000000000000000000000092e813b6baf1d17618586118c1a3cfffe2d283dc00720100019fb9f4f1d72bf9e5bd12c996bb162a33ceed72d2e24e985bca01b09c21663b1e000000000000000000000000da11b3bc8705d84beae4a796035bdccc9b59d1ee002d0100000000000000000000000095ced938f7991cd0dfcb48f0a06a40fa1af46ebc02000f42400000009f6a8c0000000000000000000000000000000000000000000000000000000000000000f80000000000000005000000000000000000000027a65fc943419a5ad590042fd67c9791fd015acf53a54cc823edb8ff81b9ed722e000000000000000000000000bd3fa81b58ba92a82136038b25adec7066af315500000000000000000000000092e813b6baf1d17618586118c1a3cfffe2d283dc00000000c6fa7af3bedbad3a3d65f36aabc97431b1bbe4c2d2f6e0e47ca60203452f5d6100000000000000000000000092e813b6baf1d17618586118c1a3cfffe2d283dc00000000000000000000000000000000000000000000000000000004a8b7328ccb0406e59555bf0371b7c4fff1812a11a8d92dad02ad422062971d61dcce2cd000000000000000000000000000000000000000000000000000000000000000000000000000000041cb279020da108b02ca3efc668b320ed18b3daf092b86c39a748ee523a9142e214d4c3191d296681b4efd8c1399da400f2963bf2e8a4b0c869b2b4084596da3291c00000000000000000000000000000000000000000000000000000000000000",
13+
"r": "0x700b0cf7513be7506ac000bfe48c87ff69f15cff40ddf457477f8a722d0238f9",
14+
"s": "0x212632d962cdadfb8edf9a2fd54d99db96bce957d03f7b077b64db3613218279",
15+
"v": 1,
16+
"creates": null,
17+
"chainId": 1
18+
}

watcher/src/fastTransfer/consts.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Chain, Network } from '@wormhole-foundation/sdk-base';
1+
import { Network } from '@wormhole-foundation/sdk-base';
22

33
export type FastTransferContracts = 'MatchingEngine' | 'TokenRouter' | 'USDCMint';
44

@@ -16,6 +16,8 @@ export interface SolanaContractAddresses {
1616
export interface EthereumContractAddresses {
1717
TokenRouter: string;
1818
CircleBridge?: string;
19+
// Devnet has no swap layer as they need the mainnet quotes from Uniswap
20+
SwapLayer?: string;
1921
}
2022

2123
export type ContractAddresses = SolanaContractAddresses | EthereumContractAddresses;
@@ -24,6 +26,7 @@ export type FastTransferContractAddresses = {
2426
[key in Network]?: {
2527
Solana?: SolanaContractAddresses;
2628
ArbitrumSepolia?: EthereumContractAddresses;
29+
Ethereum?: EthereumContractAddresses;
2730
};
2831
};
2932

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { ethers } from 'ethers';
2+
import { RedeemSwap } from '../types';
3+
import { parseVaa } from '@wormhole-foundation/wormhole-monitor-common';
4+
5+
class SwapLayerParser {
6+
private provider: ethers.providers.JsonRpcProvider;
7+
private swapLayerAddress: string;
8+
private swapLayerInterface: ethers.utils.Interface;
9+
10+
constructor(provider: ethers.providers.JsonRpcProvider, swapLayerAddress: string) {
11+
this.provider = provider;
12+
this.swapLayerAddress = swapLayerAddress;
13+
this.swapLayerInterface = new ethers.utils.Interface([
14+
'event Redeemed(address indexed recipient, address outputToken, uint256 outputAmount, uint256 relayingFee)',
15+
]);
16+
}
17+
18+
async parseSwapLayerTransaction(txHash: string, blockTime: number): Promise<RedeemSwap | null> {
19+
const receipt = await this.provider.getTransactionReceipt(txHash);
20+
21+
const tx = await this.provider.getTransaction(txHash);
22+
if (!receipt || !tx) return null;
23+
24+
// Remove the function selector (first 4 bytes)
25+
const inputData = '0x' + tx.data.slice(10);
26+
27+
// Use AbiCoder to decode the raw input data
28+
let fillVaaId: string = '';
29+
const abiCoder = new ethers.utils.AbiCoder();
30+
try {
31+
const decodedInput = abiCoder.decode(['bytes', 'tuple(bytes, bytes, bytes)'], inputData);
32+
33+
const encodedWormholeMessage = decodedInput[1][0];
34+
if (encodedWormholeMessage && encodedWormholeMessage.length >= 8) {
35+
const vaaBytes = Buffer.from(encodedWormholeMessage.slice(2), 'hex'); // Remove leading '0x'
36+
const parsedVaa = parseVaa(vaaBytes);
37+
38+
fillVaaId = `${parsedVaa.emitterChain}/${parsedVaa.emitterAddress.toString('hex')}/${
39+
parsedVaa.sequence
40+
}`;
41+
}
42+
} catch (error) {
43+
console.error('Error decoding input data:', error);
44+
}
45+
46+
const swapEvent = receipt.logs
47+
.filter((log) => log.address.toLowerCase() === this.swapLayerAddress.toLowerCase())
48+
.map((log) => {
49+
try {
50+
return this.swapLayerInterface.parseLog(log);
51+
} catch (e) {
52+
return null;
53+
}
54+
})
55+
.find((event) => event && event.name === 'Redeemed');
56+
57+
if (!swapEvent) return null;
58+
59+
return {
60+
tx_hash: txHash,
61+
recipient: swapEvent.args.recipient,
62+
output_amount: BigInt(swapEvent.args.outputAmount.toString()),
63+
output_token: swapEvent.args.outputToken,
64+
timestamp: new Date(blockTime * 1000),
65+
relaying_fee: BigInt(swapEvent.args.relayingFee.toString()),
66+
fill_vaa_id: fillVaaId,
67+
};
68+
}
69+
70+
async getFTSwapInRange(fromBlock: number, toBlock: number): Promise<RedeemSwap[]> {
71+
const filter = {
72+
address: this.swapLayerAddress,
73+
fromBlock,
74+
toBlock,
75+
topics: [this.swapLayerInterface.getEventTopic('Redeemed')],
76+
};
77+
78+
const logs = await this.provider.getLogs(filter);
79+
80+
const blocks: Map<number, ethers.providers.Block> = new Map();
81+
82+
const results = await Promise.all(
83+
logs.map(async (log) => {
84+
const blockTime = await this.fetchBlockTime(blocks, log.blockNumber);
85+
const txHash = log.transactionHash;
86+
return this.parseSwapLayerTransaction(txHash, blockTime);
87+
})
88+
);
89+
90+
return results.filter((result): result is RedeemSwap => result !== null);
91+
}
92+
93+
private async fetchBlockTime(
94+
blocks: Map<number, ethers.providers.Block>,
95+
blockNumber: number
96+
): Promise<number> {
97+
let block = blocks.get(blockNumber);
98+
if (!block) {
99+
block = await this.provider.getBlock(blockNumber);
100+
blocks.set(blockNumber, block);
101+
}
102+
return block.timestamp;
103+
}
104+
}
105+
106+
export default SwapLayerParser;

watcher/src/fastTransfer/types.ts

+10
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,13 @@ export type AuctionUpdatedEvent = {
142142
name: 'AuctionUpdated';
143143
data: AuctionUpdated;
144144
};
145+
146+
export type RedeemSwap = {
147+
tx_hash: string;
148+
recipient: string;
149+
output_token: string;
150+
output_amount: bigint;
151+
relaying_fee: bigint;
152+
timestamp: Date;
153+
fill_vaa_id: string;
154+
};

watcher/src/watchers/FTEVMWatcher.ts

+54-23
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,25 @@ import { ethers } from 'ethers';
77
import { AXIOS_CONFIG_JSON, RPCS_BY_CHAIN } from '../consts';
88
import { makeBlockKey } from '../databases/utils';
99
import TokenRouterParser from '../fastTransfer/tokenRouter/parser';
10-
import { MarketOrder } from '../fastTransfer/types';
10+
import SwapLayerParser from '../fastTransfer/swapLayer/parser';
11+
import { MarketOrder, RedeemSwap } from '../fastTransfer/types';
1112
import { Block } from './EVMWatcher';
1213
import { BigNumber } from 'ethers';
1314
import axios from 'axios';
1415
import { sleep } from '@wormhole-foundation/wormhole-monitor-common';
16+
1517
export type BlockTag = 'finalized' | 'safe' | 'latest';
1618

1719
export class FTEVMWatcher extends Watcher {
1820
finalizedBlockTag: BlockTag;
1921
lastTimestamp: number;
2022
latestFinalizedBlockNumber: number;
2123
tokenRouterAddress: string;
24+
swapLayerAddress: string | undefined;
2225
rpc: string;
2326
provider: ethers.providers.JsonRpcProvider;
24-
parser: TokenRouterParser;
27+
tokenRouterParser: TokenRouterParser;
28+
swapLayerParser: SwapLayerParser | null;
2529
pg: Knex | null = null;
2630

2731
constructor(
@@ -35,9 +39,13 @@ export class FTEVMWatcher extends Watcher {
3539
this.latestFinalizedBlockNumber = 0;
3640
this.finalizedBlockTag = finalizedBlockTag;
3741
this.tokenRouterAddress = FAST_TRANSFER_CONTRACTS[network]?.[chain]?.TokenRouter!;
42+
this.swapLayerAddress = FAST_TRANSFER_CONTRACTS[network]?.[chain]?.SwapLayer;
3843
this.provider = new ethers.providers.JsonRpcProvider(RPCS_BY_CHAIN[network][chain]);
3944
this.rpc = RPCS_BY_CHAIN[this.network][this.chain]!;
40-
this.parser = new TokenRouterParser(this.network, chain, this.provider);
45+
this.tokenRouterParser = new TokenRouterParser(this.network, chain, this.provider);
46+
this.swapLayerParser = this.swapLayerAddress
47+
? new SwapLayerParser(this.provider, this.swapLayerAddress)
48+
: null;
4149
this.logger.debug('FTWatcher', network, chain, finalizedBlockTag);
4250
// hacky way to not connect to the db in tests
4351
// this is to allow ci to run without a db
@@ -124,61 +132,84 @@ export class FTEVMWatcher extends Watcher {
124132
}
125133

126134
async getFtMessagesForBlocks(fromBlock: number, toBlock: number): Promise<string> {
127-
const { results, lastBlockTime } = await this.parser.getFTResultsInRange(fromBlock, toBlock);
135+
const tokenRouterPromise = this.tokenRouterParser.getFTResultsInRange(fromBlock, toBlock);
136+
const swapLayerPromise = this.swapLayerParser?.getFTSwapInRange(fromBlock, toBlock) || [];
137+
138+
const [tokenRouterResults, swapLayerResults] = await Promise.all([
139+
tokenRouterPromise,
140+
swapLayerPromise,
141+
]);
142+
143+
if (tokenRouterResults.results.length) {
144+
await this.saveBatch(
145+
tokenRouterResults.results,
146+
'market_orders',
147+
'fast_vaa_id',
148+
fromBlock,
149+
toBlock
150+
);
151+
}
128152

129-
if (results.length) {
130-
await this.saveFastTransfers(results, fromBlock, toBlock);
153+
if (swapLayerResults.length) {
154+
await this.saveBatch(swapLayerResults, 'redeem_swaps', 'fill_vaa_id', fromBlock, toBlock);
131155
}
156+
157+
// we do not need to compare the lastBlockTime from tokenRouter and swapLayer as they both use toBlock
158+
const lastBlockTime = tokenRouterResults.lastBlockTime;
132159
return makeBlockKey(toBlock.toString(), lastBlockTime.toString());
133160
}
134161

135-
// saves fast transfers in smaller batches to reduce the impact in any case anything fails
162+
// saves items in smaller batches to reduce the impact in any case anything fails
136163
// retry with exponential backoff is used here
137-
async saveFastTransfers(
138-
fastTransfers: MarketOrder[],
139-
fromBlock: number,
140-
toBlock: number
164+
private async saveBatch<T>(
165+
items: T[],
166+
tableName: string,
167+
conflictColumn: string,
168+
fromBlock?: number,
169+
toBlock?: number
141170
): Promise<void> {
142171
if (!this.pg) {
143172
return;
144173
}
145174

146175
const batchSize = 50;
147176
const maxRetries = 3;
148-
const totalBatches = Math.ceil(fastTransfers.length / batchSize);
177+
const totalBatches = Math.ceil(items.length / batchSize);
149178

150-
this.logger.debug(
151-
`Attempting to save ${fastTransfers.length} fast transfers in batches of ${batchSize}`
152-
);
179+
this.logger.debug(`Attempting to save ${items.length} ${tableName} in batches of ${batchSize}`);
153180

154-
for (let batchIndex = 0; batchIndex < fastTransfers.length; batchIndex += batchSize) {
155-
const batch = fastTransfers.slice(batchIndex, batchIndex + batchSize);
181+
for (let batchIndex = 0; batchIndex < items.length; batchIndex += batchSize) {
182+
const batch = items.slice(batchIndex, batchIndex + batchSize);
156183
const batchNumber = Math.floor(batchIndex / batchSize) + 1;
157184

158185
for (let attempt = 1; attempt <= maxRetries; attempt++) {
159186
try {
160-
await this.pg('market_orders').insert(batch).onConflict('fast_vaa_id').merge();
187+
await this.pg(tableName).insert(batch).onConflict(conflictColumn).merge();
161188
this.logger.info(
162-
`Successfully saved batch ${batchNumber}/${totalBatches} (${batch.length} transfers)`
189+
`Successfully saved batch ${batchNumber}/${totalBatches} (${batch.length} ${tableName})`
163190
);
164191
break;
165192
} catch (e) {
166193
if (attempt === maxRetries) {
194+
const errorMessage = `Failed to save batch ${batchNumber}/${totalBatches} of ${tableName} after ${maxRetries} attempts`;
167195
this.logger.error(
168-
`Failed to save batch ${batchNumber}/${totalBatches} from block ${fromBlock} - ${toBlock} after ${maxRetries} attempts`,
196+
fromBlock && toBlock
197+
? `${errorMessage} from block ${fromBlock} - ${toBlock}`
198+
: errorMessage,
169199
e
170200
);
171201
} else {
172-
// Wait before retrying (exponential backoff)
173202
this.logger.warn(
174-
`Attempt ${attempt} failed for batch ${batchNumber}/${totalBatches}. Retrying...`
203+
`Attempt ${attempt} failed for batch ${batchNumber}/${totalBatches} of ${tableName}. Retrying...`
175204
);
176205
await sleep(1000 * Math.pow(2, attempt - 1));
177206
}
178207
}
179208
}
180209
}
181-
this.logger.info(`Completed saving fast transfers from block ${fromBlock} - ${toBlock}`);
210+
this.logger.info(
211+
`Completed saving ${items.length} ${tableName} from ${fromBlock} to ${toBlock}`
212+
);
182213
}
183214
}
184215

0 commit comments

Comments
 (0)