From 2fdc26a19ea71cac6e0ede2900a8783b10002d96 Mon Sep 17 00:00:00 2001 From: Ricardo Olarte Date: Thu, 24 Aug 2023 08:26:01 -0500 Subject: [PATCH] feat: Add cosmwasm/sei vaa logs support (#663) --- event-watcher/src/config/index.ts | 5 +- event-watcher/src/consts.ts | 4 + event-watcher/src/index.ts | 2 +- event-watcher/src/watchers/CosmwasmWatcher.ts | 326 +++++++++++----- .../src/watchers/SeiExplorerWatcher.ts | 354 ++++++++++++++++++ event-watcher/src/watchers/utils.ts | 3 + 6 files changed, 588 insertions(+), 106 deletions(-) create mode 100644 event-watcher/src/watchers/SeiExplorerWatcher.ts diff --git a/event-watcher/src/config/index.ts b/event-watcher/src/config/index.ts index 256019ea9..8c95cd122 100644 --- a/event-watcher/src/config/index.ts +++ b/event-watcher/src/config/index.ts @@ -51,9 +51,10 @@ export const supportedChains: ChainName[] = [ // 'aptos', // 'injective', // 'near', + 'sei', 'solana', 'sui', // 'terra', - // 'terra2', - // 'xpla', + 'terra2', + 'xpla', ]; diff --git a/event-watcher/src/consts.ts b/event-watcher/src/consts.ts index 1d8c8f99f..fc6ba20e8 100644 --- a/event-watcher/src/consts.ts +++ b/event-watcher/src/consts.ts @@ -49,6 +49,7 @@ export const RPCS_BY_CHAIN: { [key in ChainName]?: string } = { terra: 'https://terra-classic-fcd.publicnode.com', // 'https://columbus-fcd.terra.dev', terra2: 'https://phoenix-lcd.terra.dev', xpla: 'https://dimension-lcd.xpla.dev', + sei: 'https://sei-rest.brocha.in', // https://docs.sei.io/develop/resources }; // Separating for now so if we max out infura we can keep Polygon going @@ -68,6 +69,9 @@ export const ALGORAND_INFO = { token: '', }; +export const SEI_EXPLORER_GRAPHQL = 'https://pacific-1-graphql.alleslabs.dev/v1/graphql'; +export const SEI_EXPLORER_TXS = 'https://celatone-api.alleslabs.dev/txs/sei/pacific-1/'; + // without this, axios request will error `Z_BUF_ERROR`: https://github.com/axios/axios/issues/5346 export const AXIOS_CONFIG_JSON: AxiosRequestConfig = { headers: { diff --git a/event-watcher/src/index.ts b/event-watcher/src/index.ts index c6bb3a816..49795e1b8 100644 --- a/event-watcher/src/index.ts +++ b/event-watcher/src/index.ts @@ -44,7 +44,7 @@ class EventWatcher { } // TEST - // const watcher = makeFinalizedWatcher('sui'); + // const watcher = makeFinalizedWatcher('sei'); // watcher.setDB(this.db); // watcher.setServices(this.sns); // watcher.watch(); diff --git a/event-watcher/src/watchers/CosmwasmWatcher.ts b/event-watcher/src/watchers/CosmwasmWatcher.ts index b4f19cba1..119220ac7 100644 --- a/event-watcher/src/watchers/CosmwasmWatcher.ts +++ b/event-watcher/src/watchers/CosmwasmWatcher.ts @@ -1,11 +1,11 @@ import { CONTRACTS, CosmWasmChainName } from '@certusone/wormhole-sdk/lib/cjs/utils/consts'; import axios from 'axios'; import { AXIOS_CONFIG_JSON, RPCS_BY_CHAIN } from '../consts'; -import { makeBlockKey, makeVaaKey } from '../databases/utils'; +import { makeBlockKey, makeVaaKey, makeVaaLog } from '../databases/utils'; import BaseWatcher from './BaseWatcher'; import { SHA256 } from 'jscrypto/SHA256'; import { Base64 } from 'jscrypto/Base64'; -import { VaaLog } from '../databases/types'; +import { VaaLog, VaasByBlock } from '../databases/types'; export class CosmwasmWatcher extends BaseWatcher { latestBlockTag: string; @@ -51,109 +51,229 @@ export class CosmwasmWatcher extends BaseWatcher { throw new Error(`Unable to parse result of ${this.latestBlockTag} on ${this.rpc}`); } - // async getMessagesForBlocks(fromBlock: number, toBlock: number): Promise { - // const address = CONTRACTS.MAINNET[this.chain].core; - // if (!address) { - // throw new Error(`Core contract not defined for ${this.chain}`); - // } - // this.logger.debug(`core contract for ${this.chain} is ${address}`); - // let vaasByBlock: VaasByBlock = {}; - // this.logger.info(`fetching info for blocks ${fromBlock} to ${toBlock}`); + override async getMessagesForBlocks(fromBlock: number, toBlock: number): Promise { + const address = CONTRACTS.MAINNET[this.chain].core; + if (!address) { + throw new Error(`Core contract not defined for ${this.chain}`); + } + this.logger.debug(`core contract for ${this.chain} is ${address}`); + let vaasByBlock: VaasByBlock = {}; + this.logger.info(`fetching info for blocks ${fromBlock} to ${toBlock}`); + + // For each block number, call {RPC}/{getBlockTag}/{block_number} + // Foreach block.data.txs[] do hexToHash() to get the txHash + // Then call {RPC}/{hashTag}/{hash} to get the logs/events + // Walk the logs/events + + for (let blockNumber = fromBlock; blockNumber <= toBlock; blockNumber++) { + this.logger.debug('Getting block number ' + blockNumber); + const blockResult: CosmwasmBlockResult = ( + await axios.get(`${this.rpc}/${this.getBlockTag}${blockNumber}`) + ).data; + if (!blockResult || !blockResult.block.data) { + throw new Error('bad result for block ${blockNumber}'); + } + const blockKey = makeBlockKey( + blockNumber.toString(), + new Date(blockResult.block.header.time).toISOString(), + ); + vaasByBlock[blockKey] = []; + let vaaKey: string = ''; + let numTxs: number = 0; + if (blockResult.block.data.txs) { + numTxs = blockResult.block.data.txs.length; + } + for (let i = 0; i < numTxs; i++) { + // The following check is not needed because of the check for numTxs. + // But typescript wanted it anyway. + if (!blockResult.block.data.txs) { + continue; + } + let hash: string = this.hexToHash(blockResult.block.data.txs[i]); + this.logger.debug('blockNumber = ' + blockNumber + ', txHash[' + i + '] = ' + hash); + // console.log('Attempting to get hash', `${this.rpc}/${this.hashTag}${hash}`); + try { + const hashResult: CosmwasmHashResult = ( + await axios.get(`${this.rpc}/${this.hashTag}${hash}`, AXIOS_CONFIG_JSON) + ).data; + if (hashResult && hashResult.tx_response.events) { + const numEvents = hashResult.tx_response.events.length; + for (let j = 0; j < numEvents; j++) { + let type: string = hashResult.tx_response.events[j].type; + if (type === 'wasm') { + if (hashResult.tx_response.events[j].attributes) { + let attrs = hashResult.tx_response.events[j].attributes; + let emitter: string = ''; + let sequence: string = ''; + let coreContract: boolean = false; + // only care about _contract_address, message.sender and message.sequence + const numAttrs = attrs.length; + for (let k = 0; k < numAttrs; k++) { + const key = Buffer.from(attrs[k].key, 'base64').toString().toLowerCase(); + this.logger.debug('Encoded Key = ' + attrs[k].key + ', decoded = ' + key); + if (key === 'message.sender') { + emitter = Buffer.from(attrs[k].value, 'base64').toString(); + } else if (key === 'message.sequence') { + sequence = Buffer.from(attrs[k].value, 'base64').toString(); + } else if (key === '_contract_address' || key === 'contract_address') { + let addr = Buffer.from(attrs[k].value, 'base64').toString(); + if (addr === address) { + coreContract = true; + } + } + } + if (coreContract && emitter !== '' && sequence !== '') { + vaaKey = makeVaaKey(hash, this.chain, emitter, sequence); + this.logger.debug('blockKey: ' + blockKey); + this.logger.debug('Making vaaKey: ' + vaaKey); + vaasByBlock[blockKey] = [...(vaasByBlock[blockKey] || []), vaaKey]; + } + } + } + } + } else { + this.logger.error('There were no hashResults'); + } + } catch (e: any) { + // console.error(e); + if ( + e?.response?.status === 500 && + e?.response?.data?.code === 2 && + e?.response?.data?.message.startsWith('json: error calling MarshalJSON') + ) { + // Just skip this one... + } else { + // Rethrow the error because we only want to catch the above error + throw e; + } + } + } + } + return vaasByBlock; + } + + override async getVaaLogs(fromBlock: number, toBlock: number): Promise { + const vaaLogs: VaaLog[] = []; + const address = CONTRACTS.MAINNET[this.chain].core; + + if (!address) { + throw new Error(`Core contract not defined for ${this.chain}`); + } + + this.logger.debug(`core contract for ${this.chain} is ${address}`); + this.logger.info(`fetching info for blocks ${fromBlock} to ${toBlock}`); + + // For each block number, call {RPC}/{getBlockTag}/{block_number} + // Foreach block.data.txs[] do hexToHash() to get the txHash + // Then call {RPC}/{hashTag}/{hash} to get the logs/events + // Walk the logs/events - // // For each block number, call {RPC}/{getBlockTag}/{block_number} - // // Foreach block.data.txs[] do hexToHash() to get the txHash - // // Then call {RPC}/{hashTag}/{hash} to get the logs/events - // // Walk the logs/events + for (let blockNumber = fromBlock; blockNumber <= toBlock; blockNumber++) { + this.logger.debug('Getting block number ' + blockNumber); - // for (let blockNumber = fromBlock; blockNumber <= toBlock; blockNumber++) { - // this.logger.debug('Getting block number ' + blockNumber); - // const blockResult: CosmwasmBlockResult = ( - // await axios.get(`${this.rpc}/${this.getBlockTag}${blockNumber}`) - // ).data; - // if (!blockResult || !blockResult.block.data) { - // throw new Error('bad result for block ${blockNumber}'); - // } - // const blockKey = makeBlockKey( - // blockNumber.toString(), - // new Date(blockResult.block.header.time).toISOString() - // ); - // vaasByBlock[blockKey] = []; - // let vaaKey: string = ''; - // let numTxs: number = 0; - // if (blockResult.block.data.txs) { - // numTxs = blockResult.block.data.txs.length; - // } - // for (let i = 0; i < numTxs; i++) { - // // The following check is not needed because of the check for numTxs. - // // But typescript wanted it anyway. - // if (!blockResult.block.data.txs) { - // continue; - // } - // let hash: string = this.hexToHash(blockResult.block.data.txs[i]); - // this.logger.debug('blockNumber = ' + blockNumber + ', txHash[' + i + '] = ' + hash); - // // console.log('Attempting to get hash', `${this.rpc}/${this.hashTag}${hash}`); - // try { - // const hashResult: CosmwasmHashResult = ( - // await axios.get(`${this.rpc}/${this.hashTag}${hash}`, AXIOS_CONFIG_JSON) - // ).data; - // if (hashResult && hashResult.tx_response.events) { - // const numEvents = hashResult.tx_response.events.length; - // for (let j = 0; j < numEvents; j++) { - // let type: string = hashResult.tx_response.events[j].type; - // if (type === 'wasm') { - // if (hashResult.tx_response.events[j].attributes) { - // let attrs = hashResult.tx_response.events[j].attributes; - // let emitter: string = ''; - // let sequence: string = ''; - // let coreContract: boolean = false; - // // only care about _contract_address, message.sender and message.sequence - // const numAttrs = attrs.length; - // for (let k = 0; k < numAttrs; k++) { - // const key = Buffer.from(attrs[k].key, 'base64').toString().toLowerCase(); - // this.logger.debug('Encoded Key = ' + attrs[k].key + ', decoded = ' + key); - // if (key === 'message.sender') { - // emitter = Buffer.from(attrs[k].value, 'base64').toString(); - // } else if (key === 'message.sequence') { - // sequence = Buffer.from(attrs[k].value, 'base64').toString(); - // } else if (key === '_contract_address' || key === 'contract_address') { - // let addr = Buffer.from(attrs[k].value, 'base64').toString(); - // if (addr === address) { - // coreContract = true; - // } - // } - // } - // if (coreContract && emitter !== '' && sequence !== '') { - // vaaKey = makeVaaKey(hash, this.chain, emitter, sequence); - // this.logger.debug('blockKey: ' + blockKey); - // this.logger.debug('Making vaaKey: ' + vaaKey); - // vaasByBlock[blockKey] = [...(vaasByBlock[blockKey] || []), vaaKey]; - // } - // } - // } - // } - // } else { - // this.logger.error('There were no hashResults'); - // } - // } catch (e: any) { - // // console.error(e); - // if ( - // e?.response?.status === 500 && - // e?.response?.data?.code === 2 && - // e?.response?.data?.message.startsWith('json: error calling MarshalJSON') - // ) { - // // Just skip this one... - // } else { - // // Rethrow the error because we only want to catch the above error - // throw e; - // } - // } - // } - // } - // return vaasByBlock; - // } + const blockResult: CosmwasmBlockResult = ( + await axios.get(`${this.rpc}/${this.getBlockTag}${blockNumber}`) + ).data; - override getVaaLogs(fromBlock: number, toBlock: number): Promise { - throw new Error('Not Implemented'); + if (!blockResult || !blockResult.block.data) { + throw new Error(`bad result for block ${blockNumber}`); + } + + let numTxs: number = 0; + if (blockResult.block.data.txs) { + numTxs = blockResult.block.data.txs.length; + } + + for (let i = 0; i < numTxs; i++) { + // The following check is not needed because of the check for numTxs. + // But typescript wanted it anyway. + if (!blockResult.block.data.txs) { + continue; + } + + let hash: string = this.hexToHash(blockResult.block.data.txs[i]); + this.logger.debug('blockNumber = ' + blockNumber + ', txHash[' + i + '] = ' + hash); + // console.log('Attempting to get hash', `${this.rpc}/${this.hashTag}${hash}`); + try { + const hashResult: CosmwasmHashResult = ( + await axios.get(`${this.rpc}/${this.hashTag}${hash}`, AXIOS_CONFIG_JSON) + ).data; + + if (hashResult && hashResult.tx_response.events) { + const numEvents = hashResult.tx_response.events.length; + for (let j = 0; j < numEvents; j++) { + let type: string = hashResult.tx_response.events[j].type; + if (type === 'wasm') { + if (hashResult.tx_response.events[j].attributes) { + let attrs = hashResult.tx_response.events[j].attributes; + let emitter: string = ''; + let sequence: string = ''; + let coreContract: boolean = false; + let payload = null; + + // only care about _contract_address, message.sender and message.sequence + const numAttrs = attrs.length; + for (let k = 0; k < numAttrs; k++) { + const key = Buffer.from(attrs[k].key, 'base64').toString().toLowerCase(); + const value = Buffer.from(attrs[k].value, 'base64').toString().toLowerCase(); + // console.log({ key, value }); + this.logger.debug('Encoded Key = ' + attrs[k].key + ', decoded = ' + key); + if (key === 'message.sender') { + emitter = Buffer.from(attrs[k].value, 'base64').toString(); + } else if (key === 'message.sequence') { + sequence = Buffer.from(attrs[k].value, 'base64').toString(); + } else if (key === 'message.message') { + // TODO: verify that this is the correct way to decode the payload (message.message) + payload = Buffer.from(attrs[k].value, 'base64').toString(); + } else if (key === '_contract_address' || key === 'contract_address') { + let addr = Buffer.from(attrs[k].value, 'base64').toString(); + if (addr === address) { + coreContract = true; + } + } + } + + if (coreContract && emitter !== '' && sequence !== '') { + this.logger.debug('blockNumber: ' + blockNumber); + + const chainName = this.chain; + const sender = emitter; + const txHash = hash; + + const vaaLog = makeVaaLog({ + chainName, + emitter, + sequence, + txHash, + sender, + blockNumber, + payload, + }); + + vaaLogs.push(vaaLog); + } + } + } + } + } else { + this.logger.error('There were no hashResults'); + } + } catch (e: any) { + // console.error(e); + if ( + e?.response?.status === 500 && + e?.response?.data?.code === 2 && + e?.response?.data?.message.startsWith('json: error calling MarshalJSON') + ) { + // Just skip this one... + } else { + // Rethrow the error because we only want to catch the above error + throw e; + } + } + } + } + return vaaLogs; } } @@ -193,7 +313,7 @@ type CosmwasmBlockResult = { }; }; -type CosmwasmHashResult = { +export type CosmwasmHashResult = { tx: { body: { messages: string[]; diff --git a/event-watcher/src/watchers/SeiExplorerWatcher.ts b/event-watcher/src/watchers/SeiExplorerWatcher.ts new file mode 100644 index 000000000..8128bc6e3 --- /dev/null +++ b/event-watcher/src/watchers/SeiExplorerWatcher.ts @@ -0,0 +1,354 @@ +import { CONTRACTS } from '@certusone/wormhole-sdk/lib/cjs/utils/consts'; +import axios from 'axios'; +import { sleep } from '../common'; +import { AXIOS_CONFIG_JSON, SEI_EXPLORER_GRAPHQL, SEI_EXPLORER_TXS } from '../consts'; +import { VaaLog, VaasByBlock } from '../databases/types'; +import { makeBlockKey, makeVaaKey, makeVaaLog } from '../databases/utils'; +import { CosmwasmHashResult, CosmwasmWatcher } from './CosmwasmWatcher'; + +type SeiExplorerAccountTransactionsResponse = { + data: { + account_transactions: { + block: { height: number; timestamp: string }; + transaction: { + account: { address: string }; + hash: string; + success: boolean; + messages: any[]; + is_clear_admin: boolean; + is_execute: boolean; + is_ibc: boolean; + is_instantiate: boolean; + is_migrate: boolean; + is_send: boolean; + is_store_code: boolean; + is_update_admin: boolean; + }; + }[]; + }; +}; + +export class SeiExplorerWatcher extends CosmwasmWatcher { + constructor() { + super('sei'); + // arbitrarily large since the code here is capable of pulling all logs from all via indexer pagination + this.maximumBatchSize = 1_000_000; + } + + makeGraphQLQuery(offset: number, pageSize: number) { + return { + query: + 'query getTxsByAddressPagination($expression: account_transactions_bool_exp, $offset: Int!, $pageSize: Int!) {\n account_transactions(\n where: $expression\n order_by: {block_height: desc}\n offset: $offset\n limit: $pageSize\n ) {\n block {\n height\n timestamp\n }\n transaction {\n account {\n address\n }\n hash\n success\n messages\n is_clear_admin\n is_execute\n is_ibc\n is_instantiate\n is_migrate\n is_send\n is_store_code\n is_update_admin\n }\n is_signer\n }\n}', + variables: { expression: { account_id: { _eq: 42 } }, offset, pageSize }, + // 42 is the account id of sei1gjrrme22cyha4ht2xapn3f08zzw6z3d4uxx6fyy9zd5dyr3yxgzqqncdqn + // returned by getAccountIdByAddressQueryDocument + operationName: 'getTxsByAddressPagination', + }; + } + + override async getFinalizedBlockNumber(): Promise { + const query = this.makeGraphQLQuery(0, 1); + this.logger.debug(`Query string = ${JSON.stringify(query)}`); + const bulkTxnResult = ( + await axios.post( + SEI_EXPLORER_GRAPHQL, + query, + AXIOS_CONFIG_JSON, + ) + ).data; + const blockHeight = bulkTxnResult?.data?.account_transactions?.[0]?.block?.height; + if (blockHeight) { + if (blockHeight !== this.latestBlockHeight) { + this.latestBlockHeight = blockHeight; + this.logger.debug('blockHeight = ' + blockHeight); + } + return blockHeight; + } + throw new Error(`Unable to parse result of ${this.latestBlockTag} on ${this.rpc}`); + } + + // retrieve blocks for core contract + // compare block height with what is passed in + override async getMessagesForBlocks(fromBlock: number, toBlock: number): Promise { + const address = CONTRACTS.MAINNET[this.chain].core; + if (!address) { + throw new Error(`Core contract not defined for ${this.chain}`); + } + this.logger.debug(`core contract for ${this.chain} is ${address}`); + let vaasByBlock: VaasByBlock = {}; + this.logger.info(`fetching info for blocks ${fromBlock} to ${toBlock}`); + + const limit: number = 50; + let done: boolean = false; + let skip: number = 0; + while (!done) { + const query = this.makeGraphQLQuery(skip, limit); + this.logger.debug(`Query string = ${JSON.stringify(query)}`); + const bulkTxnResult = ( + await axios.post( + SEI_EXPLORER_GRAPHQL, + query, + AXIOS_CONFIG_JSON, + ) + ).data; + if (!bulkTxnResult?.data?.account_transactions) { + throw new Error('bad bulkTxnResult'); + } + skip += bulkTxnResult.data.account_transactions.length; + const bulkTxns = bulkTxnResult.data.account_transactions; + if (bulkTxns.length === 0) { + throw new Error('No transactions'); + } + for (let i: number = 0; i < bulkTxns.length; ++i) { + // Walk the transactions + const txn = bulkTxns[i]; + const height: number = txn.block.height; + const hash = txn.transaction.hash.replace('\\x', '').toUpperCase(); + this.logger.debug(`Found one: ${fromBlock}, ${height}, ${toBlock}, ${hash}`); + if ( + height >= fromBlock && + height <= toBlock && + txn.transaction.is_execute && + txn.transaction.is_ibc + ) { + // We only care about the transactions in the given block range + // Sei uses IBC message emission + const blockKey = makeBlockKey( + txn.block.height.toString(), + new Date(`${txn.block.timestamp}Z`).toISOString(), + ); + // Now get the logs for that transaction... + // This is straight from CosmwasmWatcher, could probably optimize + try { + await sleep(500); // don't make the RPC upset + let hashResult: CosmwasmHashResult | undefined; + try { + // try hitting the node first + hashResult = ( + await axios.get(`${this.rpc}/${this.hashTag}${hash}`, AXIOS_CONFIG_JSON) + ).data; + } catch (e: any) { + if (e?.response?.status === 404) { + // the node is mysteriously missing some transactions, but so is this ='( + hashResult = (await axios.get(`${SEI_EXPLORER_TXS}${hash}`, AXIOS_CONFIG_JSON)) + .data; + } + } + if (hashResult && hashResult.tx_response.events) { + const numEvents = hashResult.tx_response.events.length; + for (let j = 0; j < numEvents; j++) { + let type: string = hashResult.tx_response.events[j].type; + if (type === 'wasm') { + if (hashResult.tx_response.events[j].attributes) { + let attrs = hashResult.tx_response.events[j].attributes; + let emitter: string = ''; + let sequence: string = ''; + let coreContract: boolean = false; + // only care about _contract_address, message.sender and message.sequence + const numAttrs = attrs.length; + for (let k = 0; k < numAttrs; k++) { + const key = Buffer.from(attrs[k].key, 'base64').toString().toLowerCase(); + this.logger.debug('Encoded Key = ' + attrs[k].key + ', decoded = ' + key); + if (key === 'message.sender') { + emitter = Buffer.from(attrs[k].value, 'base64').toString(); + } else if (key === 'message.sequence') { + sequence = Buffer.from(attrs[k].value, 'base64').toString(); + } else if (key === '_contract_address' || key === 'contract_address') { + let addr = Buffer.from(attrs[k].value, 'base64').toString(); + if (addr === address) { + coreContract = true; + } + } + } + if (coreContract && emitter !== '' && sequence !== '') { + const vaaKey = makeVaaKey(hash, this.chain, emitter, sequence); + this.logger.debug('blockKey: ' + blockKey); + this.logger.debug('Making vaaKey: ' + vaaKey); + vaasByBlock[blockKey] = [...(vaasByBlock[blockKey] || []), vaaKey]; + } + } + } + } + } else { + this.logger.error('There were no hashResults'); + } + } catch (e: any) { + // console.error(e); + if ( + e?.response?.status === 500 && + e?.response?.data?.code === 2 && + e?.response?.data?.message.startsWith('json: error calling MarshalJSON') + ) { + // Just skip this one... + } else { + // Rethrow the error because we only want to catch the above error + throw e; + } + } + } + if (height < fromBlock) { + this.logger.debug('Breaking out due to height < fromBlock'); + done = true; + break; + } + } + if (bulkTxns.length < limit) { + this.logger.debug('Breaking out due to ran out of txns.'); + done = true; + } + } + // NOTE: this does not set an empty entry for the latest block since we don't know if the graphql response + // is synced with the block height. Therefore, the latest block will only update when a new transaction appears. + return vaasByBlock; + } + + override async getVaaLogs(fromBlock: number, toBlock: number): Promise { + const vaaLogs: VaaLog[] = []; + const address = CONTRACTS.MAINNET[this.chain].core; + + if (!address) { + throw new Error(`Core contract not defined for ${this.chain}`); + } + + this.logger.debug(`core contract for ${this.chain} is ${address}`); + this.logger.info(`fetching info for blocks ${fromBlock} to ${toBlock}`); + + const limit: number = 50; + let done: boolean = false; + let skip: number = 0; + while (!done) { + const query = this.makeGraphQLQuery(skip, limit); + this.logger.debug(`Query string = ${JSON.stringify(query)}`); + const bulkTxnResult = ( + await axios.post( + SEI_EXPLORER_GRAPHQL, + query, + AXIOS_CONFIG_JSON, + ) + ).data; + if (!bulkTxnResult?.data?.account_transactions) { + throw new Error('bad bulkTxnResult'); + } + skip += bulkTxnResult.data.account_transactions.length; + const bulkTxns = bulkTxnResult.data.account_transactions; + if (bulkTxns.length === 0) { + throw new Error('No transactions'); + } + for (let i: number = 0; i < bulkTxns.length; ++i) { + // Walk the transactions + const txn = bulkTxns[i]; + const height: number = txn.block.height; + const hash = txn.transaction.hash.replace('\\x', '').toUpperCase(); + this.logger.debug(`Found one: ${fromBlock}, ${height}, ${toBlock}, ${hash}`); + if ( + height >= fromBlock && + height <= toBlock && + txn.transaction.is_execute && + txn.transaction.is_ibc + ) { + // We only care about the transactions in the given block range + // Sei uses IBC message emission + const blockNumber = txn.block.height.toString(); + // Now get the logs for that transaction... + // This is straight from CosmwasmWatcher, could probably optimize + try { + await sleep(500); // don't make the RPC upset + let hashResult: CosmwasmHashResult | undefined; + try { + // try hitting the node first + hashResult = ( + await axios.get(`${this.rpc}/${this.hashTag}${hash}`, AXIOS_CONFIG_JSON) + ).data; + } catch (e: any) { + if (e?.response?.status === 404) { + // the node is mysteriously missing some transactions, but so is this ='( + hashResult = (await axios.get(`${SEI_EXPLORER_TXS}${hash}`, AXIOS_CONFIG_JSON)) + .data; + } + } + if (hashResult && hashResult.tx_response.events) { + const numEvents = hashResult.tx_response.events.length; + for (let j = 0; j < numEvents; j++) { + let type: string = hashResult.tx_response.events[j].type; + if (type === 'wasm') { + if (hashResult.tx_response.events[j].attributes) { + let attrs = hashResult.tx_response.events[j].attributes; + let emitter: string = ''; + let sequence: string = ''; + let coreContract: boolean = false; + let payload = null; + + // only care about _contract_address, message.sender and message.sequence + const numAttrs = attrs.length; + for (let k = 0; k < numAttrs; k++) { + const key = Buffer.from(attrs[k].key, 'base64').toString().toLowerCase(); + this.logger.debug('Encoded Key = ' + attrs[k].key + ', decoded = ' + key); + if (key === 'message.sender') { + emitter = Buffer.from(attrs[k].value, 'base64').toString(); + } else if (key === 'message.sequence') { + sequence = Buffer.from(attrs[k].value, 'base64').toString(); + } else if (key === 'message.message') { + // TODO: verify that this is the correct way to decode the payload (message.message) + payload = Buffer.from(attrs[k].value, 'base64').toString(); + } else if (key === '_contract_address' || key === 'contract_address') { + let addr = Buffer.from(attrs[k].value, 'base64').toString(); + if (addr === address) { + coreContract = true; + } + } + } + if (coreContract && emitter !== '' && sequence !== '') { + this.logger.debug('blockKey: ' + blockNumber); + + const chainName = this.chain; + const sender = emitter; + const txHash = hash; + + const vaaLog = makeVaaLog({ + chainName, + emitter, + sequence, + txHash, + sender, + blockNumber, + payload, + }); + + vaaLogs.push(vaaLog); + } + } + } + } + } else { + this.logger.error('There were no hashResults'); + } + } catch (e: any) { + // console.error(e); + if ( + e?.response?.status === 500 && + e?.response?.data?.code === 2 && + e?.response?.data?.message.startsWith('json: error calling MarshalJSON') + ) { + // Just skip this one... + } else { + // Rethrow the error because we only want to catch the above error + throw e; + } + } + } + if (height < fromBlock) { + this.logger.debug('Breaking out due to height < fromBlock'); + done = true; + break; + } + } + if (bulkTxns.length < limit) { + this.logger.debug('Breaking out due to ran out of txns.'); + done = true; + } + } + // NOTE: this does not set an empty entry for the latest block since we don't know if the graphql response + // is synced with the block height. Therefore, the latest block will only update when a new transaction appears. + return vaaLogs; + } +} diff --git a/event-watcher/src/watchers/utils.ts b/event-watcher/src/watchers/utils.ts index 326a049ac..e20770f30 100644 --- a/event-watcher/src/watchers/utils.ts +++ b/event-watcher/src/watchers/utils.ts @@ -17,6 +17,7 @@ import { SolanaWatcher } from './SolanaWatcher'; import { TerraExplorerWatcher } from './TerraExplorerWatcher'; import { SuiWatcher } from './SuiWatcher'; import { WatcherOptionTypes } from './types'; +import { SeiExplorerWatcher } from './SeiExplorerWatcher'; export function makeFinalizedWatcher(chainName: ChainName): WatcherOptionTypes { if (chainName === 'solana') { @@ -49,6 +50,8 @@ export function makeFinalizedWatcher(chainName: ChainName): WatcherOptionTypes { return new CosmwasmWatcher(chainName as CosmWasmChainName); } else if (chainName === 'sui') { return new SuiWatcher(); + } else if (chainName === 'sei') { + return new SeiExplorerWatcher(); } else { throw new Error(`Attempted to create finalized watcher for unsupported chain ${chainName}`); }